Skip to content

Commit bf05c52

Browse files
feat(lint): unused-import via agda-unused shell-out (#7)
## Summary Implements the last spec'd v0 Agda lint (`docs/arghda-spec.adoc` §Linter rules, line 106): shell out to `agda-unused` and re-emit findings as `unused-import` **warn** diagnostics. **Validated end-to-end against a live `agda-unused` binary** (built locally for this work). ## Design (matches real `agda-unused` behaviour) - **`src/unused.rs`** — an `agda.rs`-style shell-out. Runs `agda-unused <file> --json -i <root>` **per file, in local mode**. - *Why local, not `--global`:* live testing showed `--global` treats the given root's imports as project roots and reports nothing for them; **local mode flags imports unused within a file** — the rule's namesake. - *`LC_ALL=C.UTF-8`:* `agda-unused` reads source in the process locale and aborts on the first multi-byte character otherwise — and real Agda is all UTF-8. (Same fix echo-types' docs use for `agda` itself.) - Parses the real `{ "type": "none"|"unused"|"error", "message": … }` wrapper and the findings text (`/path/File.agda:line,col-col` + ` unused import ‘Name’`). A run that yields no JSON (tool crash) is surfaced as an `error` kind, so one bad file never aborts the scan. - **Opt-in** behind `scan --unused` (it needs the external tool and re-checks each file). **Graceful degradation** with a `note:` when the binary is absent — mirroring how `check` tolerates a missing `agda`. - `lib.rs` keeps `unused` module-qualified (no flat re-export, to avoid clashing with `agda::check_file`). - `dist-newstyle/` gitignored; fixture `tests/fixtures/unused/{Main,Helper}.agda` (a genuine unused import, pragma-clean). ## Verification - `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, `cargo build`, `cargo test` all green (lib suite 35 → 41). - **Live dogfood:** `scan --unused` on the fixture emits exactly `unused-import: unused import ‘Helper’`; with `agda-unused` off `PATH` it degrades with a note and 0 warns. - Parser unit tests pinned to the **real** captured JSON + findings format. Note: CI runners don't have `agda-unused`, so the shell-out path isn't exercised in CI (the parser is unit-tested; the binary glue degrades gracefully) — exactly the pattern `agda.rs` uses for `agda`. First of three sequenced engine-polish PRs (3 → 2 → 1): **`unused-import`** (this), then `.arghda/config.toml` persistence, then the DAG `headlines` field. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_019GiSiEfgZCte35dyykgBHs
2 parents 054d54d + 7feae63 commit bf05c52

8 files changed

Lines changed: 315 additions & 15 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
# Rust build artefacts
22
target/
33

4+
# Haskell / cabal build artefacts (from building agda-unused locally)
5+
dist-newstyle/
6+
47
# Editor / tooling backups
58
*.bak
69
*~

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: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ pub mod hash;
1212
pub mod lint;
1313
pub mod proven;
1414
pub mod timestamp;
15+
pub mod unused;
1516
pub mod watcher;
1617
pub mod workspace;
1718

src/main.rs

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
use anyhow::{Context, Result};
22
use arghda_core::lint::LintContext;
33
use arghda_core::{
4-
build_dag, check_file, default_rules, event, graph, rules_with_config, run_lints, watcher,
5-
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};
88
use std::path::{Path, PathBuf};
@@ -39,6 +39,11 @@ enum Cmd {
3939
/// (`unpinned-headline` rule). Defaults to the spec pattern.
4040
#[arg(long)]
4141
headline_pattern: Option<String>,
42+
/// Also run the external `agda-unused` analyser and re-emit its
43+
/// findings as `unused-import` warnings (requires `agda-unused` on
44+
/// PATH; skipped with a note if absent).
45+
#[arg(long)]
46+
unused: bool,
4247
/// Emit the report as JSON instead of human-readable text.
4348
#[arg(long)]
4449
json: bool,
@@ -101,8 +106,9 @@ fn main() -> Result<()> {
101106
path,
102107
entry,
103108
headline_pattern,
109+
unused,
104110
json,
105-
} => scan(&path, &entry, headline_pattern.as_deref(), json)?,
111+
} => scan(&path, &entry, headline_pattern.as_deref(), unused, json)?,
106112
Cmd::Check {
107113
file,
108114
include_root,
@@ -142,6 +148,7 @@ fn scan(
142148
include_root: &Path,
143149
entry: &[PathBuf],
144150
headline_pattern: Option<&str>,
151+
unused: bool,
145152
json: bool,
146153
) -> Result<()> {
147154
let (roots, rules) = resolve_roots_and_rules(include_root, entry, headline_pattern)?;
@@ -151,9 +158,6 @@ fn scan(
151158
};
152159

153160
let mut reports = Vec::new();
154-
let mut hard_blocks = 0usize;
155-
let mut warns = 0usize;
156-
157161
for entry in WalkDir::new(include_root)
158162
.into_iter()
159163
.filter_map(|e| e.ok())
@@ -164,17 +168,45 @@ fn scan(
164168
}
165169
let report =
166170
run_lints(path, &ctx, &rules).with_context(|| format!("linting {}", path.display()))?;
167-
hard_blocks += report.hard_blocks().count();
168-
warns += report.warns().count();
169171
reports.push(report);
170172
}
173+
let files_scanned = reports.len();
174+
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.
178+
if unused {
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);
190+
}
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+
);
198+
}
199+
}
200+
201+
let hard_blocks: usize = reports.iter().map(|r| r.hard_blocks().count()).sum();
202+
let warns: usize = reports.iter().map(|r| r.warns().count()).sum();
171203

172204
if json {
173205
let payload = serde_json::json!({
174206
"version": "0.1",
175207
"include_root": include_root,
176208
"entry_modules": roots,
177-
"files_scanned": reports.len(),
209+
"files_scanned": files_scanned,
178210
"hard_blocks": hard_blocks,
179211
"warns": warns,
180212
"reports": reports,

0 commit comments

Comments
 (0)