Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 31 additions & 7 deletions crates/codescythe/analyze.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Vec<_>>();
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,
Expand Down Expand Up @@ -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;
}
Expand Down
63 changes: 63 additions & 0 deletions crates/codescythe/analyze/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use super::*;
fn parse_file(cwd: &Path, path: &Path) -> Result<FileData> {
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 {
Expand Down Expand Up @@ -32,6 +33,7 @@ fn parse_file(cwd: &Path, path: &Path) -> Result<FileData> {
);
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);
}
Expand Down Expand Up @@ -155,6 +157,17 @@ impl FileCache {
})
.collect())
}

pub(super) fn expects_unused_file(&self, index: usize) -> Result<bool> {
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 {
Expand Down Expand Up @@ -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,
Expand All @@ -251,6 +312,7 @@ pub(super) struct FileData {
pub(super) suppressed_import_conflict_sources: BTreeSet<String>,
pub(super) suppressed_import_conflict_preload_sources: BTreeSet<String>,
pub(super) local_references: BTreeSet<String>,
pub(super) expects_unused_file: bool,
}

#[derive(Debug, Clone)]
Expand Down Expand Up @@ -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,
}
}

Expand Down
63 changes: 63 additions & 0 deletions crates/codescythe/analyze/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
50 changes: 50 additions & 0 deletions crates/codescythe_cli/e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
2 changes: 2 additions & 0 deletions docs/src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down