From a03ccf5ce7dcd7e99d814de01f8c45e3548fadc6 Mon Sep 17 00:00:00 2001 From: HackPoint <6758579+HackPoint@users.noreply.github.com> Date: Sat, 1 Aug 2026 23:20:31 +0300 Subject: [PATCH] fix(coverage): intercept .output, which was read whole because the list stopped one word short MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.out` was in LOG_EXTS and `.output` was not. Extension matching is exact, so `.output` never matched `out` and every build or test run redirected to one was read whole — 54 recorded reads, 9,547 tokens at or above the threshold. The list lives in three places (lumen_core::coverage::LOG_EXTS, INTERCEPT_TEMPLATE, and the repo's developer hook) and the word was missing from all of them, because nothing compared the shell copies to the constant. The new drift test drives its assertion from the constant, so the next addition cannot land in one copy only. One trap while writing it: `out|` is a substring of `output|`, so a `contains` check would report `.out` as routed even if only `.output` were present. The arm is split on its own delimiters instead. Tests, per the rule that every fix carries all four: unit - classify() for .output, the whole LOG_EXTS set, and .outputs staying out drift - both intercept copies route every extension in the constant e2e - the generated hook run for real against a 400-line .output: exits 2 and names compress_logs bench - a new scenario, 13,842 -> 1,062 tokens (92.3% saved) No existing benchmark scenario moved: the baseline diff is nine added lines and nothing else. --- .claude/hooks/lumen_read_intercept.sh | 2 +- crates/lumen-core/src/coverage.rs | 31 ++++- .../lumen-mcp/tests/optimizer_baseline.json | 9 ++ crates/lumen-mcp/tests/optimizer_bench.rs | 7 ++ lumenator/src-tauri/src/setup.rs | 106 +++++++++++++++++- 5 files changed, 152 insertions(+), 3 deletions(-) diff --git a/.claude/hooks/lumen_read_intercept.sh b/.claude/hooks/lumen_read_intercept.sh index 2367d00..a564d28 100755 --- a/.claude/hooks/lumen_read_intercept.sh +++ b/.claude/hooks/lumen_read_intercept.sh @@ -118,7 +118,7 @@ case "$EXT" in rs|py|pyi|ts|tsx) FILE_TYPE="source" ;; - log|out|txt) + log|out|output|txt) FILE_TYPE="log" ;; *) diff --git a/crates/lumen-core/src/coverage.rs b/crates/lumen-core/src/coverage.rs index 754e10a..b5b734c 100644 --- a/crates/lumen-core/src/coverage.rs +++ b/crates/lumen-core/src/coverage.rs @@ -32,7 +32,12 @@ pub const SOURCE_EXTS: &[&str] = &["rs", "py", "pyi", "ts", "mts", "cts", "tsx"]; /// Extensions routed to `compress_logs` rather than to an outline. -pub const LOG_EXTS: &[&str] = &["log", "out", "txt"]; +/// +/// `output` is here because `out` was, and the two are the same kind of file. A build or test run +/// redirected to `something.output` was read whole — 54 recorded reads, 9,547 tokens at or above the +/// threshold — purely because the list stopped one word short. Extension matching is exact, so +/// `.output` never matched `out`. +pub const LOG_EXTS: &[&str] = &["log", "out", "output", "txt"]; /// Extensions the hooks actually intercept today. /// @@ -177,6 +182,30 @@ mod tests { assert_eq!(classify("a.dylib", None), Scope::Unmeasurable); } + #[test] + fn output_files_are_intercepted_like_every_other_log_kind() { + // `.out` was in the list and `.output` was not, so a build or test run redirected to + // `something.output` was read whole. Extension matching is exact — `.output` never matched + // `out` — which is why one missing word cost 9,547 tokens across 54 recorded reads. + assert_eq!(classify("build.output", Some(500)), Scope::Optimizable); + assert_eq!(classify("/tmp/test.OUTPUT", Some(300)), Scope::Optimizable); + // Still subject to the threshold like anything else. + assert_eq!(classify("build.output", Some(10)), Scope::BelowThreshold); + } + + #[test] + fn the_log_kinds_are_the_ones_compress_logs_can_actually_help_with() { + for e in LOG_EXTS { + assert_eq!( + classify(&format!("a.{e}"), Some(500)), + Scope::Optimizable, + "{e} is in LOG_EXTS but is not classified as optimizable" + ); + } + // A near-miss that must stay out: `.outputs` is not `.output`. + assert_eq!(classify("a.outputs", Some(500)), Scope::UncoveredKind); + } + #[test] fn a_large_unhandled_kind_is_the_coverage_backlog_not_a_leak() { // md/scss/css/js at >=300 lines: real tokens spent, but nothing wrong with the router. diff --git a/crates/lumen-mcp/tests/optimizer_baseline.json b/crates/lumen-mcp/tests/optimizer_baseline.json index b82215f..ce56994 100644 --- a/crates/lumen-mcp/tests/optimizer_baseline.json +++ b/crates/lumen-mcp/tests/optimizer_baseline.json @@ -3,6 +3,7 @@ "(inline)compress_logs": "e01e50e4310f7465", "app.log": "938a893edd0f0d39", "big.rs": "c1c363809b8ec51f", + "build.output": "dfe6d57117e3675d", "crash.log": "3f323125b2195ba1", "decls.rs": "cc0c3f86f715224f", "mod.py": "02488faac89903ab", @@ -28,6 +29,14 @@ "route": "compress_logs", "is_error": false }, + "compress_logs/output_extension": { + "tool": "compress_logs", + "full_tokens": 13842, + "returned_tokens": 1062, + "saved_tokens": 12780, + "route": "compress_logs", + "is_error": false + }, "compress_logs/repetitive_path": { "tool": "compress_logs", "full_tokens": 18380, diff --git a/crates/lumen-mcp/tests/optimizer_bench.rs b/crates/lumen-mcp/tests/optimizer_bench.rs index 375cc34..b5337d2 100644 --- a/crates/lumen-mcp/tests/optimizer_bench.rs +++ b/crates/lumen-mcp/tests/optimizer_bench.rs @@ -312,6 +312,13 @@ fn scenarios() -> Vec { fixture: Some(("unique.log", incompressible_log(400))), args: |p| json!({ "path": p }), }, + Scenario { + id: "compress_logs/output_extension", + tool: "compress_logs", + intent: "a .output file is a log; it was read whole until the list gained the word", + fixture: Some(("build.output", repetitive_log(18, 25))), + args: |p| json!({ "path": p }), + }, Scenario { id: "compress_logs/inline_text", tool: "compress_logs", diff --git a/lumenator/src-tauri/src/setup.rs b/lumenator/src-tauri/src/setup.rs index fc07bc9..3db4fed 100644 --- a/lumenator/src-tauri/src/setup.rs +++ b/lumenator/src-tauri/src/setup.rs @@ -1264,7 +1264,7 @@ fi EXT=$(echo "${FILE_PATH##*.}" | tr '[:upper:]' '[:lower:]') case "$EXT" in rs|py|pyi|ts|tsx) FILE_TYPE="source" ;; - log|out|txt) FILE_TYPE="log" ;; + log|out|output|txt) FILE_TYPE="log" ;; *) exit 0 ;; esac @@ -4521,6 +4521,110 @@ mod tests { /// appearing to work. A row missing token_source is indistinguishable from a bytes/4 /// estimate, and one missing req_key cannot be deduplicated, so a partial writer /// quietly degrades every figure built on the ledger. + #[test] + fn the_generated_intercept_actually_redirects_a_large_output_file() { + // End to end through the real script: a .output file above the threshold must be blocked + // and pointed at compress_logs. The unit test above checks the list; this checks that the + // shell built from it behaves. + let h = TempDir::new().unwrap(); + let script = h.path().join("intercept.sh"); + std::fs::write( + &script, + desired_intercept_script("/tmp/x.db", &h.path().join("mcp").to_string_lossy()), + ) + .unwrap(); + // The guard needs an executable at LUMEN_MCP_BIN or it fails open before deciding. + let mcp = h.path().join("mcp"); + std::fs::write(&mcp, "#!/bin/sh\nexit 0\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&mcp, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + + let big = h.path().join("build.output"); + std::fs::write(&big, "a line of build output\n".repeat(400)).unwrap(); + + let payload = format!( + r#"{{"tool_name":"Read","tool_input":{{"file_path":"{}"}},"session_id":"out-e2e"}}"#, + big.to_string_lossy() + ); + let out = std::process::Command::new("bash") + .arg(&script) + .env("LUMEN_MCP_BIN", &mcp) + .env("TMPDIR", h.path()) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .and_then(|mut c| { + use std::io::Write; + c.stdin.take().unwrap().write_all(payload.as_bytes())?; + c.wait_with_output() + }) + .expect("run the intercept"); + + // Exit 2 is the block: the hook tells Claude Code to use the tool instead. + assert_eq!( + out.status.code(), + Some(2), + "a 400-line .output file must be redirected, not passed through. stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + let msg = String::from_utf8_lossy(&out.stderr); + assert!( + msg.contains("compress_logs"), + "an .output file is a log, so it must be sent to compress_logs: {msg}" + ); + } + + #[test] + fn both_intercept_copies_route_every_log_extension_including_output() { + // The list lives in three places: lumen_core::coverage::LOG_EXTS, INTERCEPT_TEMPLATE here, + // and the repo's developer hook. `.output` was missing from all the shell copies because + // nobody was comparing them to the constant — so this drives the assertion from the + // constant rather than from a hand-written list that could drift the same way. + let generated = desired_intercept_script("/tmp/x.db", "/tmp/mcp"); + let repo = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../.claude/hooks/lumen_read_intercept.sh"); + + let mut copies = vec![("generated".to_string(), generated)]; + if let Ok(body) = std::fs::read_to_string(&repo) { + copies.push(("repo".to_string(), body)); + } + + for (which, body) in &copies { + // The case arm itself, not the FILE_TYPE assignment: the generated copy puts both on + // one line while the repo copy splits them, so keying on the assignment finds the wrong + // line in one of them. + let arm = body + .lines() + .map(str::trim) + .find(|l| l.starts_with("log|")) + .unwrap_or_else(|| panic!("[{which}] no log case arm found in the intercept")); + for ext in lumen_core::coverage::LOG_EXTS { + // Split on the case-arm delimiters rather than substring-matching: `out|` is a + // substring of `output|`, so a plain `contains` would report `.out` as routed even + // if only `.output` were present. + let arm_exts: Vec<&str> = arm + .split(')') + .next() + .unwrap_or("") + .split('|') + .map(str::trim) + .collect(); + assert!( + arm_exts.contains(ext), + "[{which}] the log arm does not route .{ext}: {arm}" + ); + } + } + assert!( + copies.len() == 2 || std::env::var("CI").is_err(), + "the repo hook copy should be present in a source checkout" + ); + } + #[test] fn both_meter_hooks_agree_on_the_provenance_they_record() { // Replaces a test that grepped both scripts for column *names*. That is why an exit-code