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