From 2e9e2a4cd9387d57bfd707a542b3e7669c6c862c Mon Sep 17 00:00:00 2001 From: Long Ho Date: Wed, 29 Jul 2026 22:16:27 -0400 Subject: [PATCH] feat: add unused-file expectations --- README.md | 9 +++++ crates/codescythe/analyze.rs | 38 ++++++++++++++---- crates/codescythe/analyze/parse.rs | 63 ++++++++++++++++++++++++++++++ crates/codescythe/analyze/tests.rs | 63 ++++++++++++++++++++++++++++++ crates/codescythe_cli/e2e.rs | 50 ++++++++++++++++++++++++ docs/src/render.ts | 2 + 6 files changed, 218 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 833b0f9..9a696cb 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,15 @@ patterns. `import.meta.glob` marks the matched project files and their exports as used; computed patterns and non-literal dynamic imports remain outside the supported graph. +Use a reasoned file-header directive for a file that must stay detached: + +```ts +// codescythe-expect-error unused-file -- loaded by framework +``` + +Codescythe suppresses that file's unused-file issue. If an entry point later +reaches the file, analysis fails so the stale expectation cannot hide usage. + ## Fixing Run Codescythe with `--fix` to apply supported removals. The fix pass removes diff --git a/crates/codescythe/analyze.rs b/crates/codescythe/analyze.rs index 5b39549..b6d99c7 100644 --- a/crates/codescythe/analyze.rs +++ b/crates/codescythe/analyze.rs @@ -796,6 +796,28 @@ pub fn analyze_path( &mut queued_files, )?; + let mut expected_unused_file_indexes = HashSet::new(); + for index in 0..total_files { + if files.expects_unused_file(index)? { + expected_unused_file_indexes.insert(index); + } + } + let stale_expectations = expected_unused_file_indexes + .iter() + .copied() + .filter(|index| { + entry_indexes.contains(index) + || (used_files.contains(index) && !test_file_indexes.contains(index)) + }) + .map(|index| files.relative(index)) + .collect::>(); + if !stale_expectations.is_empty() { + anyhow::bail!( + "unused-file expectation failed; file is reachable from an entry point:\n{}", + stale_expectations.join("\n") + ); + } + let internal_test_usages = mark_internal_exports_used_by_tests( &mut files, &module_resolver, @@ -834,13 +856,15 @@ pub fn analyze_path( let is_test = test_file_indexes.contains(&index); if !is_used && !is_entry && !is_test { - issues.files.insert( - relative.clone(), - FileIssue { - path: relative.clone(), - }, - ); - unused_file_indexes.insert(index); + if !expected_unused_file_indexes.contains(&index) { + issues.files.insert( + relative.clone(), + FileIssue { + path: relative.clone(), + }, + ); + unused_file_indexes.insert(index); + } if !options.include_unreachable_exports { continue; } diff --git a/crates/codescythe/analyze/parse.rs b/crates/codescythe/analyze/parse.rs index 2be9e8d..5fded54 100644 --- a/crates/codescythe/analyze/parse.rs +++ b/crates/codescythe/analyze/parse.rs @@ -3,6 +3,7 @@ use super::*; fn parse_file(cwd: &Path, path: &Path) -> Result { let source = fs::read_to_string(path) .with_context(|| format!("failed to read source file {}", path.display()))?; + let expects_unused_file = expects_unused_file(&source); let source_type = source_type_for_path(path)?; let allocator = Allocator::default(); let ParserReturn { @@ -32,6 +33,7 @@ fn parse_file(cwd: &Path, path: &Path) -> Result { ); visitor.visit_program(&program); let mut file = visitor.finish(); + file.expects_unused_file = expects_unused_file; for export in file.exports.values_mut() { (export.line, export.col) = line_col(&source, export.name_span.start); } @@ -155,6 +157,17 @@ impl FileCache { }) .collect()) } + + pub(super) fn expects_unused_file(&self, index: usize) -> Result { + if let Some(file) = &self.parsed[index] { + return Ok(file.expects_unused_file); + } + + let source = fs::read_to_string(&self.paths[index]).with_context(|| { + format!("failed to read source file {}", self.paths[index].display()) + })?; + Ok(expects_unused_file(&source)) + } } fn parse_thread_count() -> usize { @@ -233,6 +246,54 @@ fn is_import_conflict_preload_suppression(text: &str) -> bool { .is_some_and(|reason| !reason.trim().is_empty()) } +fn expects_unused_file(source: &str) -> bool { + let mut in_block_comment = false; + + 'lines: for (index, line) in source.lines().enumerate() { + let mut remaining = line.trim_start_matches('\u{feff}').trim(); + + loop { + if in_block_comment { + let Some((_, rest)) = remaining.split_once("*/") else { + break; + }; + in_block_comment = false; + remaining = rest.trim(); + } + + if remaining.is_empty() || (index == 0 && remaining.starts_with("#!")) { + break; + } + + if let Some(comment) = remaining.strip_prefix("//") { + if is_expect_unused_file(comment) { + return true; + } + continue 'lines; + } + + if let Some(comment) = remaining.strip_prefix("/*") { + if let Some((_, rest)) = comment.split_once("*/") { + remaining = rest.trim(); + continue; + } + in_block_comment = true; + break; + } + + return false; + } + } + + false +} + +fn is_expect_unused_file(text: &str) -> bool { + text.trim() + .strip_prefix("codescythe-expect-error unused-file --") + .is_some_and(|reason| !reason.trim().is_empty()) +} + #[derive(Debug, Clone)] pub(super) struct FileData { pub(super) path: PathBuf, @@ -251,6 +312,7 @@ pub(super) struct FileData { pub(super) suppressed_import_conflict_sources: BTreeSet, pub(super) suppressed_import_conflict_preload_sources: BTreeSet, pub(super) local_references: BTreeSet, + pub(super) expects_unused_file: bool, } #[derive(Debug, Clone)] @@ -373,6 +435,7 @@ impl FileVisitor { suppressed_import_conflict_preload_sources: self .suppressed_import_conflict_preload_sources, local_references: self.local_references, + expects_unused_file: false, } } diff --git a/crates/codescythe/analyze/tests.rs b/crates/codescythe/analyze/tests.rs index 793a276..cc5464d 100644 --- a/crates/codescythe/analyze/tests.rs +++ b/crates/codescythe/analyze/tests.rs @@ -16,6 +16,69 @@ fn finds_unused_exports_and_files_in_knip_style_fixture() { assert!(!analysis.issues.exports.contains_key("index.ts")); } +#[test] +fn expect_error_suppresses_an_expected_unused_file() { + let analysis = analyze_inline_project(&[ + ("src/entry.ts", "console.log('entry');\n"), + ( + "src/detached.ts", + "// codescythe-expect-error unused-file -- loaded by framework\nexport const detached = 1;\n", + ), + ]); + + assert!(!analysis.issues.files.contains_key("src/detached.ts")); + assert!(!analysis.issues.exports.contains_key("src/detached.ts")); + assert_eq!(analysis.counters.files, 0); + assert_eq!(analysis.counters.exports, 0); +} + +#[test] +fn expect_error_requires_a_reason() { + let analysis = analyze_inline_project(&[ + ("src/entry.ts", "console.log('entry');\n"), + ( + "src/detached.ts", + "// codescythe-expect-error unused-file\nexport const detached = 1;\n", + ), + ]); + + assert_unused_file(&analysis, "src/detached.ts"); +} + +#[test] +fn expect_error_fails_when_file_becomes_reachable() { + let tempdir = tempfile::tempdir().unwrap(); + let cwd = tempdir.path(); + write_file( + cwd, + "codescythe.json", + r#"{ + "entry": "src/entry.ts", + "project": "src/**/*.ts" + }"#, + ); + write_file( + cwd, + "src/entry.ts", + "import { detached } from './detached';\nconsole.log(detached);\n", + ); + write_file( + cwd, + "src/detached.ts", + "// codescythe-expect-error unused-file -- loaded by framework\nexport const detached = 1;\n", + ); + + let config = crate::load_config(cwd, None).unwrap(); + let error = analyze_path(cwd, &config, AnalysisOptions::default()).unwrap_err(); + let message = format!("{error:#}"); + + assert!( + message.contains("unused-file expectation failed"), + "{message}" + ); + assert!(message.contains("src/detached.ts"), "{message}"); +} + #[cfg(unix)] #[test] fn follows_runfiles_style_symlinked_source_directories() { diff --git a/crates/codescythe_cli/e2e.rs b/crates/codescythe_cli/e2e.rs index 7dea5cb..c08cb4a 100644 --- a/crates/codescythe_cli/e2e.rs +++ b/crates/codescythe_cli/e2e.rs @@ -77,6 +77,56 @@ fn cli_resolves_oxc_resolution_fixture() { .contains_key("unusedExtension")); } +#[test] +fn cli_enforces_expected_unused_files() { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after UNIX_EPOCH") + .as_nanos(); + let fixture = env::temp_dir().join(format!( + "codescythe-e2e-expect-error-{}-{nanos}", + std::process::id() + )); + fs::create_dir_all(fixture.join("src")).unwrap(); + fs::write( + fixture.join("codescythe.json"), + r#"{"entry":"src/main.ts","project":"src/**/*.ts"}"#, + ) + .unwrap(); + fs::write(fixture.join("src/main.ts"), "console.log('entry');\n").unwrap(); + fs::write( + fixture.join("src/detached.ts"), + "// codescythe-expect-error unused-file -- loaded by framework\nexport const detached = 1;\n", + ) + .unwrap(); + + let cli = runfile("crates/codescythe_cli/codescythe"); + let suppressed = Command::new(&cli) + .args(["-C", path_arg(&fixture), "--json"]) + .output() + .expect("failed to run codescythe CLI"); + assert!(suppressed.status.success(), "{}", output_text(&suppressed)); + let analysis: Value = + serde_json::from_slice(&suppressed.stdout).expect("CLI stdout should be JSON"); + assert!(analysis["issues"]["files"].as_object().unwrap().is_empty()); + + fs::write( + fixture.join("src/main.ts"), + "import { detached } from './detached';\nconsole.log(detached);\n", + ) + .unwrap(); + let stale = Command::new(&cli) + .args(["-C", path_arg(&fixture)]) + .output() + .expect("failed to run codescythe CLI"); + assert_eq!(stale.status.code(), Some(2), "{}", output_text(&stale)); + let stderr = String::from_utf8_lossy(&stale.stderr); + assert!(stderr.contains("unused-file expectation failed"), "{stderr}"); + assert!(stderr.contains("src/detached.ts"), "{stderr}"); + + fs::remove_dir_all(fixture).unwrap(); +} + #[test] fn cli_profile_writes_to_stderr_without_polluting_json() { let output = Command::new(runfile("crates/codescythe_cli/codescythe_profiling")) diff --git a/docs/src/render.ts b/docs/src/render.ts index 5ab63e2..1d1cf66 100644 --- a/docs/src/render.ts +++ b/docs/src/render.ts @@ -705,6 +705,8 @@ const pages: Page[] = [ ], "unresolved": [] }`), + h('p', null, 'For a file that must stay detached, add a reasoned file-header expectation. Codescythe suppresses its unused-file issue, then fails if an entry point later reaches it.'), + h(CodeBlock, { language: 'ts' }, `// codescythe-expect-error unused-file -- loaded by framework`), ), h( PageSection,