Skip to content

Commit 7feae63

Browse files
committed
fix(unused): align to live agda-unused (per-file local mode, UTF-8 locale)
Validated against a freshly-built agda-unused binary and adjusted the design to match real behaviour: - Local per-file mode, not `--global`. `--global` treats the given root's imports as project roots and reports nothing for them; local mode (`agda-unused <file> --json -i <root>`) flags imports unused within a file — the rule's namesake. Invoked once per scanned file; findings attach to that file's report (no canonical-path merge needed). - `LC_ALL=C.UTF-8` on the invocation. agda-unused reads source in the process locale and aborts on the first multi-byte char otherwise; Agda is all UTF-8. - Parser pinned to the real `{type,message}` JSON and the real findings text (`/path/File.agda:line,col-col` + ` unused import ‘Name’`, Unicode quotes). A run that yields no JSON (tool crash) is surfaced as an `error` kind rather than aborting the scan. - Drops `merge_unused` and the roots-based `find_unused`; `lib.rs` no longer flat-re-exports (avoids clashing with `agda::check_file`). - Fixtures carry the `--safe --without-K` pragma so the dogfood shows only the unused-import finding. Live dogfood: `scan --unused` on the fixture emits exactly `unused-import: unused import ‘Helper’`; with agda-unused off PATH it degrades with a note (0 warns). README + CHANGELOG updated; unused-import removed from "Not yet". fmt/clippy/test green (lib 41 tests). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019GiSiEfgZCte35dyykgBHs
1 parent 54106d9 commit 7feae63

7 files changed

Lines changed: 150 additions & 144 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ All notable changes to arghda-core are documented here. The format follows
3131
`^[a-z][A-Za-z0-9-]*$`, per the spec). Detects top-level (column-0)
3232
signatures only, which gives the export-only filter for free; tolerant of
3333
multi-line `using` lists; self-skips when no `Smoke.agda` is in scope.
34+
- `unused-import` (warn): re-emits the findings of the external `agda-unused`
35+
tool (spec §Linter rules). Opt-in behind `scan --unused`; runs `agda-unused`
36+
per file in local mode with `LC_ALL=C.UTF-8`, parses its `--json` output,
37+
and re-emits each finding as an `unused-import` warning attributed to the
38+
file. Degrades gracefully (with a note) when `agda-unused` is not on `PATH`,
39+
mirroring how `check` tolerates a missing `agda`.
3440
- RSR scaffolding: `.machine_readable/6a2/` artefacts, `0-AI-MANIFEST.a2ml`,
3541
`Justfile`, `.well-known/`, and community-health files.
3642
- Content-hash invalidation of `proven`: promotion records a SHA-256 of the

README.md

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ record.
3030
its default `^[a-z][A-Za-z0-9-]*$` is deliberately broad, so operators
3131
narrow it to their own headline-naming convention. Self-skips when no
3232
`Smoke.agda` is in scope (e.g. a single-file `check`)
33+
- `unused-import` (warn) — re-emits the findings of the external
34+
[`agda-unused`](https://github.com/msuperdock/agda-unused) tool. Opt-in
35+
behind `scan --unused` (it runs `agda-unused` per file in local mode and
36+
re-checks each file); skipped with a note if the binary is not on `PATH`.
37+
Invoked with `LC_ALL=C.UTF-8` so it can read UTF-8 Agda sources
3338
- Workspace state machine — transitions are file moves, each logged to
3439
`.arghda/events.jsonl` (`claim`, `promote`, `reject`, `requeue`,
3540
`invalidate`)
@@ -51,12 +56,12 @@ plus standalone scratch files. `scan` also flags the files deliberately
5156
outside the `--safe --without-K` kernel cone (`Fidelity.agda`, the cubical
5257
island, the postulated shadow).
5358

54-
Not yet: `unused-import` (shells out to `agda-unused`); the DAG `headlines`
55-
field (the extractor now exists for `unpinned-headline`, but the per-node
56-
`headlines` array in the DAG schema is still unpopulated); persisting the
57-
headline pattern in `.arghda/config.toml` (currently a CLI flag); the Groove
58-
service manifest. (`missing-without-k` is subsumed by `missing-safe-pragma`,
59-
which already reports a missing `--without-K`.)
59+
Not yet: the DAG `headlines` field (the extractor now exists for
60+
`unpinned-headline`, but the per-node `headlines` array in the DAG schema is
61+
still unpopulated); persisting the headline pattern in `.arghda/config.toml`
62+
(currently a CLI flag); the Groove service manifest. (`missing-without-k` is
63+
subsumed by `missing-safe-pragma`, which already reports a missing
64+
`--without-K`.)
6065

6166
## Build
6267

src/lib.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,4 @@ pub use diagnostic::{Diagnostic, LintReport, Severity};
2222
pub use event::{Event, EventKind};
2323
pub use graph::{build as build_graph, ImportGraph};
2424
pub use lint::{default_rules, rules_with_config, run_lints, LintRule, RuleConfig};
25-
pub use unused::{find_unused, UnusedOutcome};
2625
pub use workspace::{StaleEntry, State, Workspace};

src/main.rs

Lines changed: 23 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
use anyhow::{Context, Result};
22
use arghda_core::lint::LintContext;
33
use arghda_core::{
4-
build_dag, check_file, default_rules, event, find_unused, graph, rules_with_config, run_lints,
5-
watcher, Diagnostic, LintReport, LintRule, RuleConfig, State, Workspace,
4+
build_dag, check_file, default_rules, event, graph, rules_with_config, run_lints, unused,
5+
watcher, LintRule, RuleConfig, State, Workspace,
66
};
77
use clap::{Parser, Subcommand};
8-
use std::collections::HashMap;
98
use std::path::{Path, PathBuf};
109
use std::time::Duration;
1110
use walkdir::WalkDir;
@@ -173,18 +172,29 @@ fn scan(
173172
}
174173
let files_scanned = reports.len();
175174

176-
// Optional project-level unused-code pass via the external `agda-unused`.
175+
// Optional unused-code pass via the external `agda-unused` (per file,
176+
// local mode). Opt-in because it needs the external tool and re-checks
177+
// each file. Findings attach to that file's report.
177178
if unused {
178-
let outcome = find_unused(include_root, &roots)?;
179-
if !outcome.available {
180-
eprintln!("note: `agda-unused` not found on PATH; skipping unused-import findings");
181-
} else {
182-
if outcome.kind.as_deref() == Some("error") {
183-
eprintln!(
184-
"note: agda-unused reported an analysis error; unused-import findings may be incomplete"
185-
);
179+
let mut available = true;
180+
let mut saw_error = false;
181+
for report in &mut reports {
182+
let check = unused::check_file(&report.file, include_root)?;
183+
if !check.available {
184+
available = false;
185+
break;
186+
}
187+
saw_error |= check.kind.as_deref() == Some("error");
188+
for d in check.diagnostics {
189+
report.push(d);
186190
}
187-
merge_unused(&mut reports, outcome.diagnostics);
191+
}
192+
if !available {
193+
eprintln!("note: `agda-unused` not found on PATH; skipping unused-import findings");
194+
} else if saw_error {
195+
eprintln!(
196+
"note: agda-unused could not analyse some files; unused-import findings may be incomplete"
197+
);
188198
}
189199
}
190200

@@ -222,32 +232,6 @@ fn scan(
222232
Ok(())
223233
}
224234

225-
/// Attribute `agda-unused` findings to the scanned per-file reports by
226-
/// canonical path. A finding for a file that was not scanned (rare) gets its
227-
/// own report so nothing is silently dropped.
228-
fn merge_unused(reports: &mut Vec<LintReport>, diags: Vec<Diagnostic>) {
229-
let mut by_canon: HashMap<PathBuf, usize> = HashMap::new();
230-
for (idx, r) in reports.iter().enumerate() {
231-
if let Ok(c) = std::fs::canonicalize(&r.file) {
232-
by_canon.insert(c, idx);
233-
}
234-
}
235-
for d in diags {
236-
let canon = std::fs::canonicalize(&d.file).ok();
237-
if let Some(idx) = canon.as_ref().and_then(|c| by_canon.get(c).copied()) {
238-
reports[idx].push(d);
239-
} else {
240-
let idx = reports.len();
241-
if let Some(c) = canon {
242-
by_canon.insert(c, idx);
243-
}
244-
let mut r = LintReport::new(d.file.clone());
245-
r.push(d);
246-
reports.push(r);
247-
}
248-
}
249-
}
250-
251235
fn check(file: &Path, include_root: Option<&Path>, json: bool) -> Result<()> {
252236
if !file.is_file() {
253237
anyhow::bail!("file not found: {}", file.display());

0 commit comments

Comments
 (0)