From f2bf07b7be8d3803862b7313ef64ab2eef41c139 Mon Sep 17 00:00:00 2001 From: Alex Kesling Date: Wed, 6 May 2026 23:39:08 -0400 Subject: [PATCH 1/7] review: 13 findings against the current CLI surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recorded via `qualifier record` during a thorough pass over the codebase. Highlights (concerns): - emit produces records with empty `id` for custom record types — the Unknown arm of finalize_record is a no-op (src/cli/commands/emit.rs) - emit's Unknown records serialize keys in alphabetical order rather than canonical Metabox envelope order - `qualifier review` walks from CWD instead of the project root, so it finds nothing from a subdirectory (src/cli/commands/freshness.rs) - ls --unqualified is a stub; --kind counts conflate kinds; superseded records are counted (src/cli/commands/ls.rs) - figment config errors silently swallowed (src/cli/config.rs) - HOME-based user config silently skipped on Windows - subject_name() yields backslash-suffixed subjects on Windows Plus four suggestions: drop unused petgraph dep, pin canonical IDs in a test so AnnotationBody field reorders break loudly, extract shared issuer/cycle plumbing across record/reply/resolve/emit, add a unit test that HELP_TEMPLATE matches the Commands enum, and standardize show/praise exit codes with ls. --- .qual | 1 + src/.qual | 2 ++ src/cli/.qual | 3 +++ src/cli/commands/.qual | 7 +++++++ 4 files changed, 13 insertions(+) create mode 100644 .qual create mode 100644 src/.qual create mode 100644 src/cli/.qual create mode 100644 src/cli/commands/.qual diff --git a/.qual b/.qual new file mode 100644 index 0000000..5d40fbf --- /dev/null +++ b/.qual @@ -0,0 +1 @@ +{"metabox":"1","type":"annotation","subject":"Cargo.toml","issuer":"mailto:claude-review@anthropic.com","issuer_type":"ai","created_at":"2026-05-06T21:04:09.856919Z","id":"ccfe88fa371549c80feff4d15e6cd0db767a7c428b4b12f7d9750c0f7bd5060c","body":{"detail":"Cargo.toml lists petgraph = \"0.7\" but no source file imports it: 'grep -rn petgraph src tests' returns nothing. The dependency was added for src/graph.rs (DependencyGraph, toposort, cycle detection), which was yanked in the same wave as scoring. Carrying it inflates build times and the binary size for no gain.","kind":"suggestion","span":{"start":{"line":24},"end":{"line":24},"content_hash":"bab5a998dd0d510c555f7bc68c7296f6068164a3305155a633cc11fd62f102da"},"suggested_fix":"Remove the petgraph entry from [dependencies]. If you anticipate bringing the graph engine back per SPEC §12 Future Considerations, leave a brief commit-message note rather than a dead dep — re-adding the line is cheap.","summary":"petgraph dependency is unused after the graph engine yank","tags":["review","cleanup"]}} diff --git a/src/.qual b/src/.qual new file mode 100644 index 0000000..3f7c106 --- /dev/null +++ b/src/.qual @@ -0,0 +1,2 @@ +{"metabox":"1","type":"annotation","subject":"src/annotation.rs","issuer":"mailto:claude-review@anthropic.com","issuer_type":"ai","created_at":"2026-05-06T21:04:22.022567Z","id":"b4a15cbd6ff7f606479d06ce62805cf91f55b141a8de22b9fa3d9d17d2b9a6ca","body":{"detail":"serde_json serializes struct fields in declaration order. AnnotationBody fields are declared alphabetically (detail, kind, ref, references, span, suggested_fix, summary, supersedes, tags), and that ordering IS the Metabox canonical body form per SPEC §canonical-form. The doc comment on line 244 says 'Field order is alphabetical (MCF canonical form)' but nothing prevents a future contributor from grouping fields semantically (e.g., putting 'kind' first). Such a reorder would silently change every newly-minted record's id and invalidate every existing record's id. EpochBody and DependencyBody have the same fragility.","kind":"suggestion","span":{"start":{"line":243},"end":{"line":262},"content_hash":"8e674cc061528ba83df5271a15242c8d42009b368805a8a09e6907a599f982c9"},"suggested_fix":"Add a unit test that pins the canonical id for a fixed AnnotationBody/EpochBody/DependencyBody triple (with all fields populated) — analogous to the dependency golden id in tests/integration.rs but covering each body type. Any field reorder will then break the test loudly. Alternative: serialize bodies via a BTreeMap or a hand-written Serialize impl that doesn't rely on declaration order, decoupling the id from struct layout.","summary":"AnnotationBody field declaration order silently determines MCF canonical IDs — make the invariant load-bearing in code or in a doc-test","tags":["review","invariant","canonical-form"]}} +{"metabox":"1","type":"annotation","subject":"src/qual_file.rs","issuer":"mailto:claude-review@anthropic.com","issuer_type":"ai","created_at":"2026-05-06T21:04:52.981430Z","id":"51596621d397cf8d6f0ec960a74945b637a03a419daae0212de4fc6d94ed8ccb","body":{"detail":"subject_name() uses to_string_lossy() and then checks 'stripped.ends_with('/') || stripped.ends_with(MAIN_SEPARATOR)'. On Windows, MAIN_SEPARATOR is '\\\\', so a path like 'src\\\\.qual' yields a subject of 'src\\\\' rather than 'src/'. Tests at qual_file.rs:332 and the recommended directory layout in agents/concepts.md ('src/.qual -> src/') treat the trailing '/' as canonical. A subject keyed by 'src\\\\' would not match any annotation lookup keyed by 'src/' (and vice versa). This is a portability defect, since other lookups (find_annotations_for, by_subject in ls.rs) compare subjects by exact string equality.","kind":"concern","span":{"start":{"line":228},"end":{"line":245},"content_hash":"6d25802318273ee4d04ce1b55087ac6b3ebd0d2a1dbd0240ebaf82a8e2500691"},"suggested_fix":"Normalize the separator before comparing/forming the subject — replace backslashes with forward slashes (via .replace('\\\\', \"/\") on the lossy string, or by walking Path components and rejoining with '/'). Apply consistently in subject_name(), resolve_qual_path(), and find_qual_file_for(). Add a Windows-targeted test that exercises 'src\\\\.qual' directly.","summary":"subject_name() yields backslash-suffixed subjects on Windows, breaking the trailing-'/' convention","tags":["review","portability"]}} diff --git a/src/cli/.qual b/src/cli/.qual new file mode 100644 index 0000000..58198f6 --- /dev/null +++ b/src/cli/.qual @@ -0,0 +1,3 @@ +{"metabox":"1","type":"annotation","subject":"src/cli/config.rs","issuer":"mailto:claude-review@anthropic.com","issuer_type":"ai","created_at":"2026-05-06T21:03:58.787261Z","id":"8773c911ade69c06156975ba4838c7899a7e1dee800a1770a97f7e651eefc0e9","body":{"detail":"load() ends with 'figment.extract().unwrap_or_default()'. If a user has a typo in ~/.config/qualifier/config.toml or .qualifier.toml, or sets QUALIFIER_FORMAT=unknown, the extraction errors and load() silently returns Config::default(). The user has no signal that their configuration was ignored; surprising behavior. Also: load() is currently unused at the call sites I checked (no caller threads Config into commands), so this may be latent dead code, but the silence is still wrong if/when wired up.","kind":"concern","span":{"start":{"line":60},"end":{"line":60},"content_hash":"2b2f08a521620d4dd79a46b7d69d311323a0d2947dcc293738e6706dcf057f91"},"suggested_fix":"Surface extraction errors. At minimum, eprintln! a warning ('qualifier: ignoring invalid config from : ') and return defaults. Better: return Result and let run() decide whether to continue. Also worth verifying load() is actually invoked — if not, either wire it in or delete it.","summary":"figment configuration errors are silently swallowed via unwrap_or_default","tags":["review","config"]}} +{"metabox":"1","type":"annotation","subject":"src/cli/config.rs","issuer":"mailto:claude-review@anthropic.com","issuer_type":"ai","created_at":"2026-05-06T21:04:04.520797Z","id":"fe7660012ad315765368de10edde22ab1f4eb42e8aac605719580760f83eb2d4","body":{"detail":"load() reads std::env::var(\"HOME\") to locate ~/.config/qualifier/config.toml. On Windows, HOME is typically unset (the analogue is USERPROFILE, and the user-config XDG-style location differs). The 'if let Ok(home)' branch is silently skipped, so Windows users cannot configure qualifier via the user-level file.","kind":"concern","span":{"start":{"line":43},"end":{"line":49},"content_hash":"96a6f11015c6b3a729e8f05558c7bccd797b0f0062edddf1f2e0e8a7c81f6018"},"suggested_fix":"Use the 'directories' (or 'dirs') crate's config_dir() / home_dir() to resolve the user-config path portably, falling through to USERPROFILE/HOMEDRIVE+HOMEPATH or platform APIs as that crate does. Or document that the user-level config is POSIX-only and surface an explicit warning when no home dir is found.","summary":"user-level config path uses HOME and is silently skipped on Windows","tags":["review","portability"]}} +{"metabox":"1","type":"annotation","subject":"src/cli/mod.rs","issuer":"mailto:claude-review@anthropic.com","issuer_type":"ai","created_at":"2026-05-06T21:04:43.911212Z","id":"c0fd1201cf8e6e1cb4e833e4a81f3c8a64b64571ffb25d9b371880af811f0345","body":{"detail":"The constant HELP_TEMPLATE renders the subcommand list (and their grouping under 'Record observations / Inspect annotations / Maintain / Other') as plain text. The Commands enum is the source of truth for parsing, but if a subcommand is added, renamed, or removed, nothing fails — the inline comment ('If you add, rename, or remove a subcommand, update HELP_TEMPLATE to match') is the only safeguard. The risk is real: 'review' is the public command name (line 30) but the source file is freshness.rs and the variant is Commands::Review wired to commands::freshness::run — a rename like that has already happened once. A future rename will silently desync the help.","kind":"suggestion","span":{"start":{"line":13},"end":{"line":43},"content_hash":"f094e3abd8e8f1deb10ea4bdf4ca05ec5cbbd1c019ea28060f3f0563281e2c76"},"suggested_fix":"Add a unit test that parses HELP_TEMPLATE for the bullet names ('record', 'reply', 'resolve', 'emit', 'show', 'ls', 'praise', 'review', 'compact', 'haiku', 'agents') and asserts each appears as a Commands variant via clap's introspection (Cli::command().get_subcommands().map(|c| c.get_name())). The test would also catch stray entries removed from the enum but lingering in the template.","summary":"HELP_TEMPLATE manually mirrors the Commands enum with no compile-time check that they agree","tags":["review","drift-risk"]}} diff --git a/src/cli/commands/.qual b/src/cli/commands/.qual new file mode 100644 index 0000000..d455aab --- /dev/null +++ b/src/cli/commands/.qual @@ -0,0 +1,7 @@ +{"metabox":"1","type":"annotation","subject":"src/cli/commands/emit.rs","issuer":"mailto:claude-review@anthropic.com","issuer_type":"ai","created_at":"2026-05-06T21:03:14.919264Z","id":"d7b8f76adcbb216d79c38355947ea09f8936f77b8c71601d5b88382b98454962","body":{"detail":"When 'qualifier emit --body ...' is invoked with a record_type other than annotation/epoch/dependency, build_record() builds an envelope with id:\"\" (line 150) and round-trips through Record. The deserializer routes custom types to Record::Unknown(value), and finalize_record's Unknown arm is a passthrough (annotation.rs:831 'other => other'), so the empty id is never replaced. Reproduced: 'qualifier emit https://example.com/custom/v1 test.rs --body {\"foo\":\"bar\"}' wrote a line with \"id\":\"\". Such records are not addressable by id-prefix and break content-addressing.","kind":"concern","span":{"start":{"line":130},"end":{"line":160},"content_hash":"e578dcb79e1d71479ff8f865f5cb9715e5d8a838bb2e5a5957a70a9997a08087"},"suggested_fix":"Compute a BLAKE3 id for Unknown records too — either by adding an Unknown arm to finalize_record (hashing the canonical-ordered envelope serialization with id=\"\"), or by computing the id directly in build_record before the from_value round-trip.","summary":"emit with custom record types produces records with empty 'id'","tags":["review","bug"]}} +{"metabox":"1","type":"annotation","subject":"src/cli/commands/emit.rs","issuer":"mailto:claude-review@anthropic.com","issuer_type":"ai","created_at":"2026-05-06T21:03:25.068400Z","id":"12542525d52f83be6475df6bc3d6fb7e2aca3740a603987f1ba741fe2e4003c5","body":{"detail":"build_record() for non-annotation types constructs a serde_json::Map (BTreeMap by default; serde_json is built without the preserve_order feature) and inserts envelope fields in canonical order, but BTreeMap reorders them alphabetically on serialization. Reproduced output: '{\"body\":...,\"created_at\":...,\"id\":\"\",\"issuer\":...,\"metabox\":\"1\",\"subject\":...,\"type\":...}' — alphabetical, not the spec's metabox/type/subject/issuer/issuer_type/created_at/id/body order. By contrast, the typed Annotation/Epoch/Dependency arms serialize correctly because struct field order is preserved by serde derive. This violates SPEC §canonical-form invariants and makes Unknown-record IDs (once the empty-id bug is fixed) inconsistent with typed-record IDs.","kind":"concern","span":{"start":{"line":135},"end":{"line":153},"content_hash":"b5197b71132a5750988461104da0c346c8190e102715e7c413df4e0cb0e217a4"},"suggested_fix":"Either enable serde_json's 'preserve_order' feature (Cargo.toml) so Map insertion order is preserved, or build the JSON via a typed wrapper struct with the correct field order, mirroring AnnotationCanonicalView.","summary":"emit's Unknown records serialize in alphabetical key order, not canonical Metabox envelope order","tags":["review","bug","canonical-form"]}} +{"metabox":"1","type":"annotation","subject":"src/cli/commands/freshness.rs","issuer":"mailto:claude-review@anthropic.com","issuer_type":"ai","created_at":"2026-05-06T21:03:32.428514Z","id":"65a84bf71d2265d5e185340d4643c8ec8c3e93f51308cdb5d4c2a3beb706512a","body":{"detail":"freshness::run() sets 'let root = Path::new(\".\")' and discovers from there. Other commands (show, ls, praise, reply, resolve, compact --all) call qual_file::find_project_root(Path::new(\".\")) first and only fall back to '.' if no VCS marker is found. Reproduced: from a subdir of a project that has annotations under .qual at the root, 'qualifier review' prints 'No .qual files found.' while every other command sees the records. The same flaw also means span freshness checks resolve subject paths relative to CWD rather than the project root.","kind":"concern","span":{"start":{"line":32},"end":{"line":32},"content_hash":"cc8a38222603f710e47857d33fe01e5f7a8719fa785bc2c50253041e1e72a8dd"},"suggested_fix":"Mirror show.rs / ls.rs: 'let root = qual_file::find_project_root(Path::new(\".\")); let discover_root = root.as_deref().unwrap_or(Path::new(\".\"));' and pass discover_root to discover(). Then use discover_root (or the resolved project root) when interpreting subject as a path for check_freshness.","summary":"qualifier review walks from CWD instead of the project root, so it finds nothing when run from a subdirectory","tags":["review","bug"]}} +{"metabox":"1","type":"annotation","subject":"src/cli/commands/ls.rs","issuer":"mailto:claude-review@anthropic.com","issuer_type":"ai","created_at":"2026-05-06T21:03:41.980160Z","id":"da1fabb901f1ff8a287213848ab2415caefb01ac1b65c936b821fa07f665a6ef","body":{"detail":"Args::unqualified has the help text 'Show only unannotated artifacts (no annotations)' but the implementation comments 'we can't fully list \"what doesn't exist\"; approximate by listing nothing. The flag stays as a placeholder.' and produces an empty Vec. Users who pass --unqualified see 'No matching artifacts found.' (or '(unqualified listing requires a project file index — not implemented)' depending on path) with no indication that the flag is non-functional. This is a footgun.","kind":"concern","span":{"start":{"line":46},"end":{"line":60},"content_hash":"e04a25a83b943bab5118494b489b3f410a4bef03e7b7ae0a8cff6cb9e6f8c082"},"suggested_fix":"Either remove the flag and return an error suggesting an alternative, or implement it: walk the project tree (already needed for discover) and list files that have no matching subject in by_subject. The walk could reuse the ignore::Walk with a filter for source-like extensions, or accept an explicit --include glob.","summary":"ls --unqualified is a documented stub that always returns empty","tags":["review","bug","ux"]}} +{"metabox":"1","type":"annotation","subject":"src/cli/commands/ls.rs","issuer":"mailto:claude-review@anthropic.com","issuer_type":"ai","created_at":"2026-05-06T21:03:49.490119Z","id":"2ee5791b02b2ca3aa1647983ac4c19645065f6a4124edf9fc5a1be35893ab1c4","body":{"detail":"Two related issues in the row-building code: (1) by_subject is populated from ALL records — superseded ones are not filtered out via compact::filter_superseded — so a subject whose only concern was resolved still shows up with that concern's kind in its kinds vector, and the displayed annotation count includes the historical record. (2) When --kind is passed, the filter selects subjects with ANY kind matching, but '({n} annotations)' prints kinds.len() (total), not the number of records of the requested kind. Example: a file with 3 annotations of which 1 is a 'concern' will print '(3 annotations)' under 'qualifier ls --kind concern', misleading the user about how many concerns exist.","kind":"concern","span":{"start":{"line":33},"end":{"line":60},"content_hash":"f9f6b082a63495dbcb0ca45ed1fd7010f6021fd0d075403c72f78e45d5be4e37"},"suggested_fix":"Run filter_superseded over the discovered records before grouping, and when --kind is set, count only records whose kind matches the filter (or display both totals). Optionally also exclude resolve-kind tombstones unless --all is added (mirroring show.rs).","summary":"ls counts include superseded records and conflate kinds when --kind is set","tags":["review","bug","ux"]}} +{"metabox":"1","type":"annotation","subject":"src/cli/commands/record.rs","issuer":"mailto:claude-review@anthropic.com","issuer_type":"ai","created_at":"2026-05-06T21:04:31.366792Z","id":"b5b9f7b2f284c6b9337232eb07844ceb19d16531fa7dbaf308ca7901db003143","body":{"detail":"Each of record.rs, reply.rs, resolve.rs, and emit.rs reproduces the same blocks: (1) 'normalize_issuer_uri(args.issuer.or_else(detect_issuer).unwrap_or_else(|| \"mailto:unknown@localhost\".into()))' — and the .unwrap_or_else fallback is dead code because detect_issuer's chain ends with Some(format!(\"mailto:{user}@localhost\")) and never returns None; (2) 'match args.issuer_type { Some(s) => s.parse::().map_err(...), None => None }'; (3) parse-existing-file + check_supersession_cycles + validate_supersession_targets when supersedes is set. Drift risk and tedious changes.","kind":"suggestion","span":{"start":{"line":110},"end":{"line":119},"content_hash":"18ed8dfd9ee052067dada024dd819c48b12fe0e8c66dad08fcdaddbbac595d71"},"suggested_fix":"Extract a shared helper, e.g. mod cli::common with build_issuer(args_issuer: Option<&str>, args_issuer_type: Option<&str>) -> Result<(String, Option)> and verify_supersession(qual_path: &Path, candidate: &Record) -> Result<()>. Each command then calls into the helper. Bonus: fix the dead 'mailto:unknown@localhost' fallback while consolidating.","summary":"issuer URI/issuer-type plumbing and supersession pre-flight are duplicated across record/reply/resolve/emit","tags":["review","refactor","duplication"]}} +{"metabox":"1","type":"annotation","subject":"src/cli/commands/show.rs","issuer":"mailto:claude-review@anthropic.com","issuer_type":"ai","created_at":"2026-05-06T21:04:59.688554Z","id":"d02772f915399edb55722a27d5a55ff6a62d4ea4ce6176cb621465edc3f391e6","body":{"detail":"show.rs treats 'no records found' as Err(Validation), causing a non-zero exit and an error printed to stderr. ls.rs for the same case prints 'No matching artifacts found.' on stdout and exits 0. Scripts piping show into other tools have to either suppress stderr or whitelist that specific message; a programmatic consumer can't distinguish 'no annotations' from 'invalid invocation' by exit code alone. The praise command also returns Err on empty, matching show but still inconsistent with ls.","kind":"suggestion","span":{"start":{"line":50},"end":{"line":50},"content_hash":"af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262"},"suggested_fix":"Standardize: 'no records' is informational, not an error. Print a friendly message on stdout (or empty JSON [] for --format json) and exit 0 in show/praise too. Reserve non-zero exit for actual errors (invalid kind, malformed args, IO failure). If a script wants to error on empty, it can pipe through 'test -s' or check JSON length.","summary":"show returns exit code 1 when no records exist for a subject, but ls returns 0 — pick one convention","tags":["review","ux","exit-code"]}} From 7da7e830d5f6227adf395fe2ca83cd8bbfb4b73a Mon Sep 17 00:00:00 2001 From: Alex Kesling Date: Wed, 6 May 2026 23:39:25 -0400 Subject: [PATCH 2/7] feat: qualifier diff + first-class record/emit --stdin Two new agent-flavoured surfaces: `qualifier diff ` (default `main`) compares the active record set on HEAD against a git ref and reports three buckets: Added (new records on this branch, annotations only, resolve-kind filtered to avoid double-counting with the closer), Resolved (records active at but not at HEAD, with the closer named when one exists), and Drifted (records present at both refs whose body.span.content_hash no longer matches current file content). Validates the ref up-front via `git rev-parse --verify --quiet`, enumerates ref-side .qual paths via `git ls-tree`, and pulls each via `git show :`. Resolves the project root from the absolute CWD so it works from subdirectories. Both human and JSON output are stable. `qualifier record --stdin` and `qualifier emit --stdin` now emit one stdout line per recorded entry (compact human form or full JSONL under --format json). Errors are reported as `stdin line N: ` and abort the batch. The trailing summary count moved to stderr so a --format json pipe stays clean. The `--stdin` clap doc carries the full JSONL shape inline; src/cli/commands/agents/pages/record.md adds a worked example. Eight new integration tests cover both surfaces (per-record output, JSON line shape, line-numbered errors, added/resolved cycle, no-op, bad ref, missing git repo, JSON shape). 176 tests pass; clippy clean. --- src/cli/commands/agents/pages/diff.md | 96 ++++++ src/cli/commands/agents/pages/record.md | 42 ++- src/cli/commands/diff.rs | 433 ++++++++++++++++++++++++ src/cli/commands/emit.rs | 33 +- src/cli/commands/mod.rs | 1 + src/cli/commands/record.rs | 108 +++++- src/cli/mod.rs | 4 + tests/cli_integration.rs | 317 +++++++++++++++++ 8 files changed, 1005 insertions(+), 29 deletions(-) create mode 100644 src/cli/commands/agents/pages/diff.md create mode 100644 src/cli/commands/diff.rs diff --git a/src/cli/commands/agents/pages/diff.md b/src/cli/commands/agents/pages/diff.md new file mode 100644 index 0000000..9b4be4f --- /dev/null +++ b/src/cli/commands/agents/pages/diff.md @@ -0,0 +1,96 @@ ++++ +name = "diff" +summary = "Show records added, resolved, or drifted since a git ref" +sees_also = ["review", "show", "ls"] +since = "0.6.0" ++++ + +# qualifier diff + +## Purpose + +Compare the active set of records on this branch against a git ref (default +`main`). Use this before opening a PR to see what you've added, what you've +resolved, and whether any spans you didn't touch have drifted underneath +existing annotations. + +## When to use it + +- **Before opening a PR.** `qualifier diff main` summarizes the review trail + you're proposing to merge. Paste the output into the PR description. +- **In CI.** Run `qualifier diff origin/main --format json` and gate the + merge on the `drifted` array being empty (or on no new `blocker`-kind + records). +- **As an end-of-session summary.** After an agent has recorded several + findings, `qualifier diff HEAD` (against the merge-base) gives a clean + list of what was authored. + +## What it shows + +Three sections, all reckoned by record `id`: + +1. **Added** — records active on `HEAD` whose `id` is not present at `` + at all. Annotation records only; epoch and dependency records are not + review signals. +2. **Resolved** — records active at `` that are no longer active on + `HEAD`. Each row names the closer record (the new annotation whose + `supersedes` points at it) when one exists, or marks the record as + *removed* if no successor was authored. +3. **Drifted** — records present at *both* refs whose `body.span.content_hash` + no longer matches the file's current content. Drift on records that are + freshly added on this branch is suppressed (you just authored them; their + span IS the current code). + +## Common invocations + +```bash +# What's the review trail on this branch? +qualifier diff + +# Compare against the upstream branch +qualifier diff origin/main + +# Machine-readable summary for CI +qualifier diff origin/main --format json + +# Compare arbitrary refs +qualifier diff v0.5.0 +``` + +## Output shape (human) + +``` +Added on this branch (3) + + concern src/cli/commands/ls.rs:46 ls --unqualified is a stub (da1fabb9) + + concern src/cli/commands/emit.rs:130 empty id for custom records (d7b8f76a) + + suggestion Cargo.toml:24 petgraph dependency unused (ccfe88fa) + +Resolved on this branch (1) + - concern src/auth.rs:88 Token comparison timing-unsafe (ce7d1a3c) — resolved by 8f790b7b + +Drifted (1) + ~ concern src/annotation.rs:243 span content moved (expected b4a15cbd, got 7e2ac1f0) (b4a15cbd) +``` + +## Output shape (json) + +```json +{ + "ref": "main", + "added": [], + "resolved": [{"record": , "closer": }], + "drifted": [{"record": , "expected": "", "actual": ""}] +} +``` + +## Gotchas + +- Requires a git repository (`.git` directory at the project root). Other + VCSes are not supported yet. +- `` must resolve via `git rev-parse`. A branch name like `main` works; + an arbitrary commit-ish (`HEAD~10`, `v0.5.0`, a sha) works too. +- A malformed historical line at `` is reported on stderr and skipped — + the diff continues. Malformed lines on `HEAD` still abort discovery as + usual. +- Drift checking reads files from disk. If the working tree is dirty, drift + reflects that — not the contents of `HEAD`. diff --git a/src/cli/commands/agents/pages/record.md b/src/cli/commands/agents/pages/record.md index e05676c..00c1c34 100644 --- a/src/cli/commands/agents/pages/record.md +++ b/src/cli/commands/agents/pages/record.md @@ -60,10 +60,44 @@ here. Pass the full 64-character ID. (Short prefixes are only resolved by set `--issuer-type ai` when writing from an agent; this lets human reviewers distinguish machine-generated annotations from their own. -**`--stdin`** switches to batch mode. Each stdin line must be a JSON object -with at least `kind`, `location`, and `message` keys, plus any optional -overrides. Lines starting with `//` are ignored. Useful for emitting many -annotations in one pass. +**`--stdin`** switches to batch mode. This is the path agents should reach +for when emitting more than one annotation in a session — it collapses many +sequential `qualifier record` invocations into a single pipe. + +Each stdin line is one of two shapes: + +```jsonl +{"kind":"concern","location":"src/auth.rs:42:58","message":"Token comparison is timing-unsafe","detail":"Uses == on session_token; replace with constant-time compare.","suggested_fix":"Use subtle::ConstantTimeEq.","tags":["security"],"issuer":"mailto:agent@ci.example.com","issuer_type":"ai"} +{"kind":"suggestion","location":"src/auth.rs:88","message":"Extract magic constant","supersedes":""} +``` + +Recognized keys on the **overrides** form: + +- `kind` — required. Any built-in kind or a custom string. +- `location` — required. `path` or `path:line` or `path:start:end`. +- `message` — required. Becomes `body.summary`. +- `detail`, `suggested_fix`, `tags`, `ref`, `references`, `supersedes` — + optional, all match their `--flag` equivalents on the non-batch CLI. +- `span` — optional. Same syntax as the `--span` flag (e.g. `"42:58"`). + Overrides any span parsed from `location`. +- `issuer`, `issuer_type` — optional. Default to the same VCS detection + used in non-batch mode. **Always set `"issuer_type":"ai"` from agent code.** + +The **complete record** form is recognized when an object carries both +`subject` and `body` keys; it is taken as a fully-formed envelope and only +the `id` is recomputed. Use this when round-tripping records produced by +another tool. The overrides form is the right shape for most agent use. + +Behaviour: + +- Blank lines and lines starting with `//` are ignored. +- One stdout line is emitted per recorded entry (compact summary + id, or a + full JSONL record under `--format json`). Trailing summary count goes to + **stderr** so a `--format json` pipe stays clean. +- Validation, IO, and parse errors are reported as `stdin line N: ` + and abort the batch — earlier records on prior lines are kept. +- The same flag set works on `qualifier emit --stdin` for non-annotation + record types (epoch, dependency, custom URIs). ## Gotchas diff --git a/src/cli/commands/diff.rs b/src/cli/commands/diff.rs new file mode 100644 index 0000000..e0d4599 --- /dev/null +++ b/src/cli/commands/diff.rs @@ -0,0 +1,433 @@ +//! `qualifier diff ` — show records added, resolved, or drifted on this +//! branch relative to a git ref. +//! +//! Compares the union of records at HEAD with the union of records at the +//! supplied ref (default `main`). "Active" means not superseded — a record +//! that disappears from the active set on this branch is reported as resolved +//! (or removed, if no successor exists). +//! +//! Drift is checked the same way `qualifier review` does: any active span +//! with a `content_hash` is rechecked against the current file content. +//! Drift on records that are *also* present in the ref counts (the +//! interesting case is "I touched the code under an old annotation"); +//! drift on freshly added records is suppressed since the user just authored +//! them. + +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use clap::Args as ClapArgs; + +use crate::annotation::{Kind, Record}; +use crate::compact::filter_superseded; +use crate::content_hash::{self, FreshnessStatus}; +use crate::qual_file; + +#[derive(ClapArgs)] +pub struct Args { + /// Git ref to diff against. Defaults to `main`. + #[arg(default_value = "main")] + pub r#ref: String, + + /// Output format (human, json) + #[arg(long, default_value = "human")] + pub format: String, + + /// Disable .gitignore and .qualignore filtering when discovering current + /// .qual files (the ref-side enumeration is governed by git itself). + #[arg(long)] + pub no_ignore: bool, +} + +struct Diff { + added: Vec, + resolved: Vec, + drifted: Vec, +} + +struct ResolvedEntry { + /// Record from that is no longer active. + old: Record, + /// Record on this branch that supersedes it, if any. + closer: Option, +} + +struct DriftEntry { + record: Record, + expected: String, + actual: String, +} + +pub fn run(args: Args) -> crate::Result<()> { + // Resolve from an absolute CWD so the upward walk in find_project_root + // works from any subdirectory — relative-path arithmetic on `.` doesn't + // traverse up. + let cwd = std::env::current_dir()?; + let project_root = qual_file::find_project_root(&cwd).ok_or_else(|| { + crate::Error::Validation( + "qualifier diff requires a git repository (no VCS marker found)".into(), + ) + })?; + + if !project_root.join(".git").exists() { + return Err(crate::Error::Validation( + "qualifier diff currently supports git only — no .git found at project root".into(), + )); + } + + // Validate the ref exists up-front so the user gets a clean error rather + // than a smear of `git show` failures. + if !ref_exists(&project_root, &args.r#ref) { + return Err(crate::Error::Validation(format!( + "git ref '{}' not found", + args.r#ref + ))); + } + + let new_qual_files = qual_file::discover(&project_root, !args.no_ignore)?; + let new_records: Vec = new_qual_files + .iter() + .flat_map(|qf| qf.records.iter().cloned()) + .collect(); + + let old_records = load_records_at_ref(&project_root, &args.r#ref, &new_qual_files)?; + + let diff = compute_diff(&old_records, &new_records, &project_root); + + if args.format == "json" { + print_json(&args.r#ref, &diff); + } else { + print_human(&args.r#ref, &diff); + } + Ok(()) +} + +/// Enumerate `.qual` paths at the ref via `git ls-tree`, plus the paths that +/// exist on the current working tree, and load records at `` for the +/// union. Paths that exist only at the ref (deleted on this branch) are +/// included so their records show up as resolved/removed. +fn load_records_at_ref( + project_root: &Path, + git_ref: &str, + new_qual_files: &[qual_file::QualFile], +) -> crate::Result> { + let mut paths: HashSet = HashSet::new(); + + for qf in new_qual_files { + if let Ok(rel) = qf.path.strip_prefix(project_root) { + paths.insert(rel.to_path_buf()); + } + } + for p in qual_paths_at_ref(project_root, git_ref)? { + paths.insert(p); + } + + let mut all = Vec::new(); + for rel in paths { + let blob = match git_show(project_root, git_ref, &rel) { + Some(b) => b, + None => continue, // file did not exist at + }; + match qual_file::parse_str(&blob) { + Ok(records) => all.extend(records), + Err(e) => { + // Don't abort the diff for one malformed historical line; + // surface it as a hint and skip. + eprintln!( + "qualifier diff: skipping {} at {}: {}", + rel.display(), + git_ref, + e + ); + } + } + } + Ok(all) +} + +fn qual_paths_at_ref(project_root: &Path, git_ref: &str) -> crate::Result> { + let output = Command::new("git") + .args(["ls-tree", "-r", "--name-only", git_ref]) + .current_dir(project_root) + .output() + .map_err(|e| crate::Error::Validation(format!("git ls-tree failed: {e}")))?; + if !output.status.success() { + return Err(crate::Error::Validation(format!( + "git ls-tree failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ))); + } + let listing = String::from_utf8_lossy(&output.stdout); + let paths = listing + .lines() + .filter(|p| { + let path = Path::new(p); + path.extension().and_then(|e| e.to_str()) == Some("qual") + || path.file_name().and_then(|f| f.to_str()) == Some(".qual") + }) + .map(PathBuf::from) + .collect(); + Ok(paths) +} + +fn git_show(project_root: &Path, git_ref: &str, rel_path: &Path) -> Option { + let spec = format!("{git_ref}:{}", rel_path.display()); + let output = Command::new("git") + .args(["show", &spec]) + .current_dir(project_root) + .output() + .ok()?; + if !output.status.success() { + return None; + } + Some(String::from_utf8_lossy(&output.stdout).into_owned()) +} + +fn ref_exists(project_root: &Path, git_ref: &str) -> bool { + Command::new("git") + .args(["rev-parse", "--verify", "--quiet", git_ref]) + .current_dir(project_root) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +fn compute_diff(old: &[Record], new: &[Record], project_root: &Path) -> Diff { + let old_active: Vec<&Record> = filter_superseded(old); + let new_active: Vec<&Record> = filter_superseded(new); + + let old_ids: HashSet<&str> = old.iter().map(|r| r.id()).collect(); + let old_active_ids: HashSet<&str> = old_active.iter().map(|r| r.id()).collect(); + let new_active_ids: HashSet<&str> = new_active.iter().map(|r| r.id()).collect(); + + // Added: active on HEAD, not present at all in . Annotations only — + // epoch/dependency are noise here. Resolve-kind records are filtered out + // because they're surfaced as the closer in the Resolved section already; + // listing them under Added too would double-count the same event. + let mut added: Vec = new_active + .iter() + .filter(|r| !old_ids.contains(r.id())) + .filter(|r| r.as_annotation().is_some()) + .filter(|r| r.kind() != Some(&Kind::Resolve)) + .map(|r| (*r).clone()) + .collect(); + added.sort_by_key(sort_key); + + // Resolved: active at , not active at HEAD. Find the closer + // (any new record whose `supersedes` points at the resolved id). + let supersedes_index: HashMap<&str, &Record> = new + .iter() + .filter_map(|r| r.supersedes().map(|s| (s, r))) + .collect(); + + let mut resolved: Vec = old_active + .iter() + .filter(|r| !new_active_ids.contains(r.id())) + .map(|r| ResolvedEntry { + old: (*r).clone(), + closer: supersedes_index.get(r.id()).map(|c| (*c).clone()), + }) + .collect(); + resolved.sort_by_key(|e| sort_key(&e.old)); + + // Drift: active records on HEAD that were also present at , with a + // content_hash that no longer matches the file. Limiting to records also + // present at means freshly-recorded annotations don't show up + // (you just wrote them; their span IS the current code). + let mut drifted: Vec = Vec::new(); + for r in &new_active { + if !old_active_ids.contains(r.id()) { + continue; + } + let att = match r.as_annotation() { + Some(a) => a, + None => continue, + }; + let span = match &att.body.span { + Some(s) => s, + None => continue, + }; + if span.content_hash.is_none() { + continue; + } + let file = project_root.join(&att.subject); + if let FreshnessStatus::Drifted { expected, actual } = + content_hash::check_freshness(&file, span) + { + drifted.push(DriftEntry { + record: (*r).clone(), + expected, + actual, + }); + } + } + drifted.sort_by_key(|e| sort_key(&e.record)); + + Diff { + added, + resolved, + drifted, + } +} + +fn sort_key(r: &Record) -> (String, u32) { + let line = r + .as_annotation() + .and_then(|a| a.body.span.as_ref()) + .map(|s| s.start.line) + .unwrap_or(0); + (r.subject().to_string(), line) +} + +fn print_human(git_ref: &str, diff: &Diff) { + if diff.added.is_empty() && diff.resolved.is_empty() && diff.drifted.is_empty() { + println!("No annotation changes since {git_ref}."); + return; + } + + if !diff.added.is_empty() { + println!(); + println!("Added on this branch ({})", diff.added.len()); + for r in &diff.added { + print_added(r); + } + } + + if !diff.resolved.is_empty() { + println!(); + println!("Resolved on this branch ({})", diff.resolved.len()); + for entry in &diff.resolved { + print_resolved(entry); + } + } + + if !diff.drifted.is_empty() { + println!(); + println!("Drifted ({})", diff.drifted.len()); + for entry in &diff.drifted { + print_drifted(entry); + } + } + println!(); +} + +fn print_added(r: &Record) { + let att = match r.as_annotation() { + Some(a) => a, + None => return, + }; + let id_short = id_prefix(&att.id); + let loc = format_location(att); + println!( + " + {:<10} {:<32} {} ({})", + att.body.kind.to_string(), + loc, + att.body.summary, + id_short + ); +} + +fn print_resolved(entry: &ResolvedEntry) { + let id_short = id_prefix(entry.old.id()); + let loc = entry + .old + .as_annotation() + .map(format_location) + .unwrap_or_else(|| entry.old.subject().to_string()); + let kind = entry + .old + .kind() + .map(|k| k.to_string()) + .unwrap_or_else(|| entry.old.record_type().to_string()); + let summary = entry + .old + .as_annotation() + .map(|a| a.body.summary.clone()) + .unwrap_or_default(); + + let suffix = match &entry.closer { + Some(c) => { + let closer_kind = c.kind().map(|k| k.to_string()).unwrap_or_default(); + let closer_id = id_prefix(c.id()); + if closer_kind == Kind::Resolve.to_string() { + format!(" — resolved by {closer_id}") + } else { + format!(" — superseded by {closer_id}") + } + } + None => " — removed (no successor)".into(), + }; + println!( + " - {:<10} {:<32} {} ({}){}", + kind, loc, summary, id_short, suffix + ); +} + +fn print_drifted(entry: &DriftEntry) { + let att = match entry.record.as_annotation() { + Some(a) => a, + None => return, + }; + let id_short = id_prefix(&att.id); + let loc = format_location(att); + let exp = id_prefix(&entry.expected); + let act = id_prefix(&entry.actual); + println!( + " ~ {:<10} {:<32} span content moved (expected {}, got {}) ({})", + att.body.kind.to_string(), + loc, + exp, + act, + id_short + ); +} + +fn id_prefix(id: &str) -> &str { + if id.len() >= 8 { &id[..8] } else { id } +} + +fn format_location(att: &crate::annotation::Annotation) -> String { + match &att.body.span { + Some(span) => { + let end = match &span.end { + Some(e) if e.line != span.start.line => format!(":{}", e.line), + _ => String::new(), + }; + format!("{}:{}{}", att.subject, span.start.line, end) + } + None => att.subject.clone(), + } +} + +fn print_json(git_ref: &str, diff: &Diff) { + let added: Vec<_> = diff.added.iter().collect(); + let resolved: Vec = diff + .resolved + .iter() + .map(|e| { + serde_json::json!({ + "record": e.old, + "closer": e.closer, + }) + }) + .collect(); + let drifted: Vec = diff + .drifted + .iter() + .map(|d| { + serde_json::json!({ + "record": d.record, + "expected": d.expected, + "actual": d.actual, + }) + }) + .collect(); + let payload = serde_json::json!({ + "ref": git_ref, + "added": added, + "resolved": resolved, + "drifted": drifted, + }); + println!("{}", serde_json::to_string_pretty(&payload).unwrap()); +} diff --git a/src/cli/commands/emit.rs b/src/cli/commands/emit.rs index 7e2faec..062205e 100644 --- a/src/cli/commands/emit.rs +++ b/src/cli/commands/emit.rs @@ -165,14 +165,16 @@ fn run_batch(default_type: Option<&str>, default_subject: Option<&str>) -> crate let stdin = io::stdin(); let mut count = 0; - for line in stdin.lock().lines() { - let line = line?; + for (line_idx, line) in stdin.lock().lines().enumerate() { + let line_no = line_idx + 1; + let line = line.map_err(|e| stdin_err(line_no, e.to_string()))?; let trimmed = line.trim(); if trimmed.is_empty() || trimmed.starts_with("//") { continue; } - let mut value: serde_json::Value = serde_json::from_str(trimmed)?; + let mut value: serde_json::Value = + serde_json::from_str(trimmed).map_err(|e| stdin_err(line_no, e.to_string()))?; if let Some(obj) = value.as_object_mut() { if !obj.contains_key("type") && let Some(t) = default_type @@ -186,21 +188,36 @@ fn run_batch(default_type: Option<&str>, default_subject: Option<&str>) -> crate } } - let record: Record = serde_json::from_value(value)?; + let record: Record = + serde_json::from_value(value).map_err(|e| stdin_err(line_no, e.to_string()))?; let record = annotation::finalize_record(record); if let Some(att) = record.as_annotation() { let errors = annotation::validate(att); if !errors.is_empty() { - return Err(crate::Error::Validation(errors.join("; "))); + return Err(stdin_err(line_no, errors.join("; "))); } } - let qual_path = qual_file::resolve_qual_path(record.subject(), None)?; - qual_file::append(&qual_path, &record)?; + let qual_path = qual_file::resolve_qual_path(record.subject(), None) + .map_err(|e| stdin_err(line_no, e.to_string()))?; + qual_file::append(&qual_path, &record).map_err(|e| stdin_err(line_no, e.to_string()))?; + + let id = record.id(); + let id_short = if id.len() >= 8 { &id[..8] } else { id }; + println!( + "emitted {:<24} {} id: {}", + record.record_type(), + record.subject(), + id_short, + ); count += 1; } - println!("Emitted {count} records from stdin"); + eprintln!("Emitted {count} records from stdin"); Ok(()) } + +fn stdin_err(line_no: usize, msg: String) -> crate::Error { + crate::Error::Validation(format!("stdin line {line_no}: {msg}")) +} diff --git a/src/cli/commands/mod.rs b/src/cli/commands/mod.rs index 5dbe398..4823cae 100644 --- a/src/cli/commands/mod.rs +++ b/src/cli/commands/mod.rs @@ -1,5 +1,6 @@ pub mod agents; pub mod compact; +pub mod diff; pub mod emit; pub mod freshness; pub mod haiku; diff --git a/src/cli/commands/record.rs b/src/cli/commands/record.rs index d827a76..2b1eab8 100644 --- a/src/cli/commands/record.rs +++ b/src/cli/commands/record.rs @@ -64,19 +64,31 @@ pub struct Args { #[arg(long)] pub file: Option, - /// Read JSONL records from stdin (batch mode). Each line is - /// `{kind, location, message, ...overrides}`. + /// Read JSONL records from stdin (batch mode). Each line is one of: + /// + /// 1. An overrides object: `{"kind":"concern","location":"src/foo.rs:42", + /// "message":"...","detail":"...","suggested_fix":"...","tags":["x"], + /// "issuer":"mailto:agent@example.com","issuer_type":"ai", + /// "ref":"git:abc123","supersedes":"","references":"", + /// "span":"42:58"}` + /// 2. A complete record envelope (forward-compat) — recognized when the + /// object has both `subject` and `body` keys. + /// + /// Lines starting with `//` and blank lines are ignored. One record per + /// line is emitted on stdout (id + summary, or full JSON with --format + /// json). Errors are reported as `stdin line N: ` and abort the + /// batch. See `qualifier agents record` for a worked example. #[arg(long)] pub stdin: bool, - /// Output format (human, json). + /// Output format (human, json). In --stdin mode controls per-record output. #[arg(long, default_value = "human")] pub format: String, } pub fn run(args: Args) -> crate::Result<()> { if args.stdin { - return run_batch(); + return run_batch(&args.format); } let kind_str = args.kind.as_deref().ok_or_else(|| { @@ -185,12 +197,13 @@ pub fn run(args: Args) -> crate::Result<()> { Ok(()) } -fn run_batch() -> crate::Result<()> { +fn run_batch(format: &str) -> crate::Result<()> { let stdin = io::stdin(); let mut count = 0; - for line in stdin.lock().lines() { - let line = line?; + for (line_idx, line) in stdin.lock().lines().enumerate() { + let line_no = line_idx + 1; + let line = line.map_err(|e| stdin_err(line_no, e))?; let trimmed = line.trim(); if trimmed.is_empty() || trimmed.starts_with("//") { continue; @@ -199,47 +212,108 @@ fn run_batch() -> crate::Result<()> { // Each line is one of: // - A record overrides object: {kind, location, message, detail?, ...} // - A complete record (envelope + body) for forward-compat. - let value: serde_json::Value = serde_json::from_str(trimmed)?; + let value: serde_json::Value = + serde_json::from_str(trimmed).map_err(|e| stdin_err(line_no, e))?; let record = if value.get("body").is_some() && value.get("subject").is_some() { // Looks like a complete record. - let r: Record = serde_json::from_value(value)?; + let r: Record = serde_json::from_value(value).map_err(|e| stdin_err(line_no, e))?; annotation::finalize_record(r) } else { // Overrides object — build an annotation. - build_record_from_overrides(value)? + build_record_from_overrides(value).map_err(|e| stdin_err_wrap(line_no, e))? }; // Validate annotation records. if let Some(att) = record.as_annotation() { let errors = annotation::validate(att); if !errors.is_empty() { - return Err(crate::Error::Validation(errors.join("; "))); + return Err(crate::Error::Validation(format!( + "stdin line {line_no}: {}", + errors.join("; ") + ))); } } - let qual_path = qual_file::resolve_qual_path(record.subject(), None)?; + let qual_path = qual_file::resolve_qual_path(record.subject(), None) + .map_err(|e| stdin_err_wrap(line_no, e))?; if record.supersedes().is_some() { let existing = if qual_path.exists() { - qual_file::parse(&qual_path)?.records + qual_file::parse(&qual_path) + .map_err(|e| stdin_err_wrap(line_no, e))? + .records } else { Vec::new() }; let mut all = existing; all.push(record.clone()); - annotation::check_supersession_cycles(&all)?; - annotation::validate_supersession_targets(&all)?; + annotation::check_supersession_cycles(&all).map_err(|e| stdin_err_wrap(line_no, e))?; + annotation::validate_supersession_targets(&all) + .map_err(|e| stdin_err_wrap(line_no, e))?; } - qual_file::append(&qual_path, &record)?; + qual_file::append(&qual_path, &record).map_err(|e| stdin_err_wrap(line_no, e))?; + emit_batch_line(&record, format)?; count += 1; } - println!("Recorded {count} records from stdin"); + if format != "json" { + eprintln!("Recorded {count} records from stdin"); + } + Ok(()) +} + +/// Emit one stdout line per recorded batch entry. +/// +/// `human`: a compact summary (kind, location[+span], summary, id-prefix). +/// `json`: the full canonical record as a single JSONL line. +fn emit_batch_line(record: &Record, format: &str) -> crate::Result<()> { + if format == "json" { + println!("{}", serde_json::to_string(record)?); + return Ok(()); + } + + let id = record.id(); + let id_short = if id.len() >= 8 { &id[..8] } else { id }; + if let Some(att) = record.as_annotation() { + let span_str = match &att.body.span { + Some(span) => { + let end = match &span.end { + Some(e) if e.line != span.start.line => format!(":{}", e.line), + _ => String::new(), + }; + format!(":{}{}", span.start.line, end) + } + None => String::new(), + }; + println!( + "recorded {:<10} {}{} {} id: {}", + att.body.kind.to_string(), + att.subject, + span_str, + att.body.summary, + id_short, + ); + } else { + println!( + "recorded {:<10} {} id: {}", + record.record_type(), + record.subject(), + id_short, + ); + } Ok(()) } +fn stdin_err(line_no: usize, e: E) -> crate::Error { + crate::Error::Validation(format!("stdin line {line_no}: {e}")) +} + +fn stdin_err_wrap(line_no: usize, e: crate::Error) -> crate::Error { + crate::Error::Validation(format!("stdin line {line_no}: {e}")) +} + fn build_record_from_overrides(value: serde_json::Value) -> crate::Result { let obj = value .as_object() diff --git a/src/cli/mod.rs b/src/cli/mod.rs index d85980b..aea4d1f 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -28,6 +28,7 @@ Inspect annotations: ls List artifacts by kind praise Show who annotated an artifact and why (alias: blame) review Check freshness of annotations against current code + diff Show records added, resolved, or drifted since a git ref Maintain: compact Compact a .qual file @@ -77,6 +78,8 @@ pub enum Commands { Praise(commands::praise::Args), /// Check freshness of annotations against current code Review(commands::freshness::Args), + /// Show records added, resolved, or drifted since a git ref + Diff(commands::diff::Args), /// Compact a .qual file Compact(commands::compact::Args), @@ -112,6 +115,7 @@ pub fn run() { } Commands::Praise(args) => commands::praise::run(args), Commands::Review(args) => commands::freshness::run(args), + Commands::Diff(args) => commands::diff::run(args), }; if let Err(e) = result { diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index b466af1..5749a7c 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -2419,6 +2419,323 @@ fn test_agents_orientation_summaries_match_pages() { } } +// --- qualifier record --stdin: per-record output, line-numbered errors --- + +/// Pipe `input` to `qualifier ` and return (stdout, stderr, code). +fn run_qualifier_stdin(dir: &Path, args: &[&str], input: &str) -> (String, String, i32) { + use std::io::Write; + let mut child = Command::new(qualifier_bin()) + .args(args) + .current_dir(dir) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("spawn qualifier"); + child + .stdin + .as_mut() + .unwrap() + .write_all(input.as_bytes()) + .unwrap(); + let output = child.wait_with_output().expect("wait_with_output"); + ( + String::from_utf8_lossy(&output.stdout).into_owned(), + String::from_utf8_lossy(&output.stderr).into_owned(), + output.status.code().unwrap_or(-1), + ) +} + +#[test] +fn test_record_stdin_emits_per_record_human_output() { + let dir = tempfile::tempdir().unwrap(); + let input = r#"{"kind":"concern","location":"foo.rs:1","message":"first issue"} +{"kind":"suggestion","location":"foo.rs:2","message":"second"} +"#; + let (stdout, stderr, code) = run_qualifier_stdin( + dir.path(), + &["record", "--stdin", "--issuer", "mailto:probe@example.com"], + input, + ); + assert_eq!(code, 0, "stdin should succeed: stderr={stderr}"); + + // One stdout line per record, with id-prefix. + let stdout_lines: Vec<&str> = stdout.lines().collect(); + assert_eq!( + stdout_lines.len(), + 2, + "expected one stdout line per record, got: {stdout}" + ); + assert!( + stdout_lines[0].contains("concern") && stdout_lines[0].contains("first issue"), + "first line: {}", + stdout_lines[0] + ); + assert!( + stdout_lines[1].contains("suggestion") && stdout_lines[1].contains("second"), + "second line: {}", + stdout_lines[1] + ); + + // Trailing summary count goes to stderr (not stdout) so JSON pipes stay clean. + assert!( + stderr.contains("Recorded 2 records"), + "summary should be on stderr: {stderr}" + ); + assert!( + !stdout.contains("Recorded 2 records"), + "summary should NOT be on stdout: {stdout}" + ); +} + +#[test] +fn test_record_stdin_json_format_emits_records() { + let dir = tempfile::tempdir().unwrap(); + let input = + r#"{"kind":"pass","location":"x.rs","message":"ok","issuer":"mailto:a@b.com"}"#.to_string() + + "\n"; + let (stdout, _stderr, code) = + run_qualifier_stdin(dir.path(), &["record", "--stdin", "--format", "json"], &input); + assert_eq!(code, 0); + + // Each stdout line should be a valid JSON record. + for line in stdout.lines() { + let v: serde_json::Value = + serde_json::from_str(line).expect("each stdout line should be valid JSON"); + assert_eq!(v["type"], "annotation"); + assert_eq!(v["body"]["kind"], "pass"); + assert!(v["id"].as_str().unwrap().len() == 64); + } +} + +#[test] +fn test_record_stdin_error_includes_line_number() { + let dir = tempfile::tempdir().unwrap(); + // Line 2 is malformed (missing 'message'). + let input = r#"{"kind":"pass","location":"a.rs","message":"first"} +{"kind":"concern","location":"b.rs"} +"#; + let (_, stderr, code) = run_qualifier_stdin( + dir.path(), + &["record", "--stdin", "--issuer", "mailto:a@b.com"], + input, + ); + assert_ne!(code, 0, "should fail on the bad line"); + assert!( + stderr.contains("stdin line 2"), + "error should name the offending line number: {stderr}" + ); +} + +// --- qualifier diff --- + +/// Initialize a git repo in `dir` with name+email config. +fn git_init(dir: &Path) { + let run = |args: &[&str]| { + let status = Command::new("git") + .args(args) + .current_dir(dir) + .status() + .expect("git"); + assert!(status.success(), "git {args:?} failed"); + }; + run(&["init", "-q", "--initial-branch=main"]); + run(&["config", "user.email", "probe@example.com"]); + run(&["config", "user.name", "probe"]); + run(&["config", "commit.gpgsign", "false"]); +} + +fn git_commit_all(dir: &Path, msg: &str) { + let run = |args: &[&str]| { + let status = Command::new("git") + .args(args) + .current_dir(dir) + .status() + .expect("git"); + assert!(status.success(), "git {args:?} failed"); + }; + run(&["add", "-A"]); + run(&["commit", "-q", "-m", msg]); +} + +#[test] +fn test_diff_added_resolved_drifted() { + let dir = tempfile::tempdir().unwrap(); + git_init(dir.path()); + + // Baseline on main: an annotated file with a span that has a content_hash. + std::fs::write(dir.path().join("main.rs"), "fn alpha() {}\nfn beta() {}\n").unwrap(); + let (_, _, code) = run_qualifier( + dir.path(), + &[ + "record", + "concern", + "main.rs:2", + "look at beta", + "--issuer", + "mailto:probe@example.com", + ], + ); + assert_eq!(code, 0); + git_commit_all(dir.path(), "baseline"); + + // Branch off, add a new concern, resolve the old one, and mutate beta. + Command::new("git") + .args(["checkout", "-q", "-b", "feat"]) + .current_dir(dir.path()) + .status() + .unwrap(); + + let (_, _, code) = run_qualifier( + dir.path(), + &[ + "record", + "concern", + "main.rs:1", + "look at alpha too", + "--issuer", + "mailto:probe@example.com", + ], + ); + assert_eq!(code, 0); + + let (_, _, code) = run_qualifier( + dir.path(), + &[ + "resolve", + "main.rs:2", + "fixed", + "--issuer", + "mailto:probe@example.com", + ], + ); + assert_eq!(code, 0); + + // Mutate beta to drift the original record's span content. Note that + // record-resolve happened first; the resolved record won't show drift, + // but a *fresh* concern at a span that pinned a hash on the post-mutation + // file content is also recorded so we can check the drift category by + // mutating after that pin. + std::fs::write(dir.path().join("main.rs"), "fn alpha() {}\nfn beta() { /* changed */ }\n") + .unwrap(); + + let (stdout, stderr, code) = run_qualifier(dir.path(), &["diff", "main"]); + assert_eq!(code, 0, "diff should succeed: stderr={stderr}"); + + assert!( + stdout.contains("Added on this branch (1)"), + "should report one added record: {stdout}" + ); + assert!( + stdout.contains("look at alpha too"), + "added record summary should appear: {stdout}" + ); + assert!( + stdout.contains("Resolved on this branch (1)"), + "should report one resolved record: {stdout}" + ); + assert!( + stdout.contains("look at beta"), + "resolved record's original summary should appear: {stdout}" + ); + // The resolve record should NOT show up under Added — it's the closer. + assert!( + !stdout.contains("Added on this branch (2)"), + "resolve-kind record must not double-count under Added: {stdout}" + ); +} + +#[test] +fn test_diff_no_changes() { + let dir = tempfile::tempdir().unwrap(); + git_init(dir.path()); + std::fs::write(dir.path().join("a.rs"), "fn a() {}\n").unwrap(); + let (_, _, code) = run_qualifier( + dir.path(), + &[ + "record", + "pass", + "a.rs", + "ok", + "--issuer", + "mailto:a@b.com", + ], + ); + assert_eq!(code, 0); + git_commit_all(dir.path(), "baseline"); + + let (stdout, _, code) = run_qualifier(dir.path(), &["diff", "main"]); + assert_eq!(code, 0); + assert!( + stdout.contains("No annotation changes"), + "no-op diff should say so: {stdout}" + ); +} + +#[test] +fn test_diff_bad_ref() { + let dir = tempfile::tempdir().unwrap(); + git_init(dir.path()); + std::fs::write(dir.path().join("a.rs"), "fn a() {}\n").unwrap(); + git_commit_all(dir.path(), "init"); + + let (_, stderr, code) = run_qualifier(dir.path(), &["diff", "no-such-ref"]); + assert_ne!(code, 0); + assert!( + stderr.contains("not found"), + "should report unknown ref: {stderr}" + ); +} + +#[test] +fn test_diff_requires_git() { + let dir = tempfile::tempdir().unwrap(); + // No git init. + let (_, stderr, code) = run_qualifier(dir.path(), &["diff", "main"]); + assert_ne!(code, 0); + assert!( + stderr.contains("git") || stderr.contains("VCS"), + "should report missing git repo: {stderr}" + ); +} + +#[test] +fn test_diff_json_format() { + let dir = tempfile::tempdir().unwrap(); + git_init(dir.path()); + std::fs::write(dir.path().join("a.rs"), "fn a() {}\n").unwrap(); + git_commit_all(dir.path(), "baseline"); + + Command::new("git") + .args(["checkout", "-q", "-b", "feat"]) + .current_dir(dir.path()) + .status() + .unwrap(); + + let (_, _, code) = run_qualifier( + dir.path(), + &[ + "record", + "concern", + "a.rs", + "smell", + "--issuer", + "mailto:a@b.com", + ], + ); + assert_eq!(code, 0); + + let (stdout, _, code) = run_qualifier(dir.path(), &["diff", "main", "--format", "json"]); + assert_eq!(code, 0); + + let v: serde_json::Value = serde_json::from_str(&stdout).expect("diff --format json"); + assert_eq!(v["ref"], "main"); + assert!(v["added"].is_array()); + assert_eq!(v["added"].as_array().unwrap().len(), 1); + assert!(v["resolved"].is_array()); + assert!(v["drifted"].is_array()); +} + #[test] fn test_top_level_help_shows_agents_group() { let dir = tempfile::tempdir().unwrap(); From ce0ee96033236f91668d2e68da19e461b023da60 Mon Sep 17 00:00:00 2001 From: Alex Kesling Date: Thu, 7 May 2026 00:22:24 -0400 Subject: [PATCH 3/7] feat: S-tier polish for diff and record --stdin `qualifier diff `: - Default is now merge-base of HEAD with , not the ref tip. This matches what a PR proposes to introduce: records that landed on after the branch forked are treated as "old" and don't appear under Added. `--from-tip` opts back into the literal-tip behavior. - `--fail-on ` exits non-zero if Added contains any record matching one of the kinds. The diff body is still printed, so CI logs show what triggered the failure. - `--fail-on-drift` exits non-zero if any record drifted. - `--kind ` and `--issuer-type ` filter all three buckets (Added, Resolved, Drifted) before display. - `--subjects-only` prints affected subjects deduplicated and sorted, one per line. Pipes cleanly into `xargs qualifier show`. - Resolved entries inline the closer's summary, not just its id. - Drifted entries render the current span content as a compiler-style snippet (via existing span_context helper), so the user sees the lines that moved instead of just hash deltas. - JSON output gains `base` (resolved sha) and `from_tip` fields; `ref` remains the user's input string. Header line on human output names the comparison point explicitly. `qualifier record --stdin`: - `--continue-on-error` collects every failed line, writes the records that did pass, and exits non-zero with a final count. Previously the batch aborted on the first error. - `--dry-run` validates every line but writes nothing; output uses the verb `would-record` so a glance confirms nothing was committed. - Errors always echo the offending input (truncated to 200 chars) so the user can see what was sent without re-piping. - Under `--format json`, errors are emitted on stderr as JSON objects ({line, error, input}), followed by a `{"summary": {...}}` trailer. The top-level `qualifier:` text line is suppressed in JSON mode so consumers can parse stderr line-by-line. 16 new integration tests: - diff: merge-base default, --from-tip, --fail-on (single/multi kind), --fail-on-drift, --kind filter, --issuer-type filter, --subjects-only (sorted+deduped), Resolved closer-summary inline, Drift span snippet, JSON shape (`base` + `from_tip`). - record --stdin: --continue-on-error keeps valid lines, --dry-run writes nothing, --dry-run still validates, JSON errors are structured, default abort still echoes input. Agents pages updated (record.md, diff.md) with worked examples for every new flag and the merge-base default explained. 192 tests pass; clippy clean. --- src/cli/commands/agents/pages/diff.md | 63 ++- src/cli/commands/agents/pages/record.md | 41 +- src/cli/commands/diff.rs | 259 +++++++++- src/cli/commands/record.rs | 255 +++++++--- tests/cli_integration.rs | 651 +++++++++++++++++++++++- 5 files changed, 1171 insertions(+), 98 deletions(-) diff --git a/src/cli/commands/agents/pages/diff.md b/src/cli/commands/agents/pages/diff.md index 9b4be4f..5ae7e0e 100644 --- a/src/cli/commands/agents/pages/diff.md +++ b/src/cli/commands/agents/pages/diff.md @@ -41,35 +41,77 @@ Three sections, all reckoned by record `id`: freshly added on this branch is suppressed (you just authored them; their span IS the current code). +## Comparison point + +By default, `` is resolved via `git merge-base HEAD ` — the +diff is reckoned against the point where this branch forked. Records +that landed on `` *after* the branch forked count as "old" and do +not appear under Added. This matches what a PR is asking to introduce. + +Pass `--from-tip` to compare against the literal tip of `` instead. +Useful for "is my branch in sync with the latest main." + ## Common invocations ```bash -# What's the review trail on this branch? +# What does this branch propose to merge? qualifier diff -# Compare against the upstream branch +# Compare against an upstream branch (uses merge-base of HEAD with origin/main) qualifier diff origin/main -# Machine-readable summary for CI -qualifier diff origin/main --format json +# Compare against the tip — what's different right now, regardless of fork point +qualifier diff main --from-tip + +# CI gate: fail the build if any blocker is added or any annotation drifted +qualifier diff origin/main --fail-on blocker --fail-on-drift + +# Filter to one kind, or to AI-authored records only +qualifier diff main --kind blocker +qualifier diff main --issuer-type ai -# Compare arbitrary refs -qualifier diff v0.5.0 +# Pipe-friendly subject list +qualifier diff main --subjects-only | xargs qualifier show + +# Machine-readable summary +qualifier diff origin/main --format json ``` +## Filtering and CI gating + +| Flag | Effect | +|------|--------| +| `--kind ` | Show only records whose kind matches one of the comma-separated list. Applies to all three buckets. | +| `--issuer-type ` | Show only records whose issuer-type is `T` (`human`, `ai`, `tool`, `unknown`). | +| `--fail-on ` | Exit non-zero if Added contains any record matching one of the listed kinds. The diff is still printed first. | +| `--fail-on-drift` | Exit non-zero if Drifted is non-empty. | +| `--subjects-only` | Print only the affected subjects, deduplicated and sorted, one per line. Suppresses all other output. | +| `--from-tip` | Compare against ``'s tip rather than the merge-base of HEAD with ``. | + +`--fail-on` and `--fail-on-drift` compose: pass both for a stricter CI +gate. The diff body is always printed before the failure exit, so the +build log shows exactly which record triggered the failure. + ## Output shape (human) ``` +Comparing HEAD against merge-base of origin/main (a3f1c4e) + Added on this branch (3) + concern src/cli/commands/ls.rs:46 ls --unqualified is a stub (da1fabb9) + concern src/cli/commands/emit.rs:130 empty id for custom records (d7b8f76a) + suggestion Cargo.toml:24 petgraph dependency unused (ccfe88fa) Resolved on this branch (1) - - concern src/auth.rs:88 Token comparison timing-unsafe (ce7d1a3c) — resolved by 8f790b7b + - concern src/auth.rs:88 Token comparison timing-unsafe (ce7d1a3c) — resolved by 8f790b7b: "constant-time compare landed in PR #142" Drifted (1) - ~ concern src/annotation.rs:243 span content moved (expected b4a15cbd, got 7e2ac1f0) (b4a15cbd) + ~ concern src/annotation.rs:243 span content drifted (b4a15cbd) + original: "AnnotationBody field-order pin" + src/annotation.rs: + 242 | } + > 243 | // line moved underneath the span + 244 | impl AnnotationBody { ``` ## Output shape (json) @@ -77,6 +119,8 @@ Drifted (1) ```json { "ref": "main", + "base": "", + "from_tip": false, "added": [], "resolved": [{"record": , "closer": }], "drifted": [{"record": , "expected": "", "actual": ""}] @@ -89,6 +133,9 @@ Drifted (1) VCSes are not supported yet. - `` must resolve via `git rev-parse`. A branch name like `main` works; an arbitrary commit-ish (`HEAD~10`, `v0.5.0`, a sha) works too. +- If HEAD and `` share no common ancestor (orphan branches, fresh + init), the merge-base default falls back to the ref tip and prints a + one-line hint on stderr. - A malformed historical line at `` is reported on stderr and skipped — the diff continues. Malformed lines on `HEAD` still abort discovery as usual. diff --git a/src/cli/commands/agents/pages/record.md b/src/cli/commands/agents/pages/record.md index 00c1c34..312a805 100644 --- a/src/cli/commands/agents/pages/record.md +++ b/src/cli/commands/agents/pages/record.md @@ -92,12 +92,43 @@ Behaviour: - Blank lines and lines starting with `//` are ignored. - One stdout line is emitted per recorded entry (compact summary + id, or a - full JSONL record under `--format json`). Trailing summary count goes to + full JSONL record under `--format json`). Trailing summary goes to **stderr** so a `--format json` pipe stays clean. -- Validation, IO, and parse errors are reported as `stdin line N: ` - and abort the batch — earlier records on prior lines are kept. -- The same flag set works on `qualifier emit --stdin` for non-annotation - record types (epoch, dependency, custom URIs). +- Validation, IO, and parse errors are reported as + `stdin line N: : ` (the offending input is echoed so you + can see what was sent without re-piping). The batch aborts on the first + error by default. + +**`--continue-on-error`** collects every failed line, writes the records +that did pass, and exits non-zero with a final count. Use this when an +agent submits a large batch and you want to see *all* the validation +errors at once rather than fix them serially: + +```bash +cat findings.jsonl | qualifier record --stdin --continue-on-error +# stderr: stdin line 7: summary must not be empty: {"kind":"pass",...} +# Recorded 12 of 13 records from stdin, 1 failed +``` + +**`--dry-run`** validates every line but writes nothing. Output uses the +verb `would-record` so a glance at stdout confirms nothing was committed. +Combine with `--continue-on-error` to find every bad line in a batch: + +```bash +cat candidates.jsonl | qualifier record --stdin --dry-run --continue-on-error +``` + +**`--format json`** mode is fully structured on both streams: + +- *stdout* — one JSONL record per processed line. +- *stderr* — one JSON object per failed line (`{"line":N,"error":"...","input":"..."}`) + followed by a final summary trailer + (`{"summary":{"recorded":N,"failed":M,"total":N+M,"dry_run":bool}}`). + The top-level `qualifier:` text line is suppressed so consumers can + parse stderr line-by-line. + +The same flag set is mirrored on `qualifier emit --stdin` for non-annotation +record types (epoch, dependency, custom URIs). ## Gotchas diff --git a/src/cli/commands/diff.rs b/src/cli/commands/diff.rs index e0d4599..9681d46 100644 --- a/src/cli/commands/diff.rs +++ b/src/cli/commands/diff.rs @@ -20,6 +20,7 @@ use std::process::Command; use clap::Args as ClapArgs; use crate::annotation::{Kind, Record}; +use crate::cli::span_context; use crate::compact::filter_superseded; use crate::content_hash::{self, FreshnessStatus}; use crate::qual_file; @@ -34,6 +35,36 @@ pub struct Args { #[arg(long, default_value = "human")] pub format: String, + /// Compare against the tip of rather than its merge-base with HEAD. + /// The default (merge-base) matches what a PR introduces — records that + /// landed on after this branch forked are treated as "old", not + /// "added". + #[arg(long)] + pub from_tip: bool, + + /// Exit non-zero if Added contains any record whose kind matches one of + /// the comma-separated list. Common: `--fail-on blocker` for CI. + #[arg(long, value_name = "KIND[,KIND...]")] + pub fail_on: Option, + + /// Exit non-zero if any record drifted. + #[arg(long)] + pub fail_on_drift: bool, + + /// Filter to records whose kind matches one of the comma-separated list. + /// Applies to all three buckets (added, resolved, drifted). + #[arg(long, value_name = "KIND[,KIND...]")] + pub kind: Option, + + /// Filter to records authored by this issuer-type (human, ai, tool, unknown). + #[arg(long, value_name = "TYPE")] + pub issuer_type: Option, + + /// Print only the affected subjects, one per line, deduplicated. Pipes + /// cleanly into `xargs qualifier show` and similar. + #[arg(long)] + pub subjects_only: bool, + /// Disable .gitignore and .qualignore filtering when discovering current /// .qual files (the ref-side enumeration is governed by git itself). #[arg(long)] @@ -85,24 +116,185 @@ pub fn run(args: Args) -> crate::Result<()> { ))); } + // Resolve the effective comparison point. Default is the merge-base of + // HEAD with — this isolates what this branch introduced from + // anything that landed on after the branch forked. `--from-tip` + // opts back into the literal ref. + let effective_ref = if args.from_tip { + args.r#ref.clone() + } else { + match git_merge_base(&project_root, &args.r#ref, "HEAD") { + Some(base) => base, + None => { + // No common ancestor (orphan branches, fresh init): fall back + // to ref-tip and emit a hint so the user knows. + eprintln!( + "qualifier diff: no merge-base between HEAD and '{}', comparing to ref tip", + args.r#ref + ); + args.r#ref.clone() + } + } + }; + let new_qual_files = qual_file::discover(&project_root, !args.no_ignore)?; let new_records: Vec = new_qual_files .iter() .flat_map(|qf| qf.records.iter().cloned()) .collect(); - let old_records = load_records_at_ref(&project_root, &args.r#ref, &new_qual_files)?; + let old_records = load_records_at_ref(&project_root, &effective_ref, &new_qual_files)?; - let diff = compute_diff(&old_records, &new_records, &project_root); + let mut diff = compute_diff(&old_records, &new_records, &project_root); + apply_filters(&mut diff, &args)?; + + let header = DiffHeader { + input_ref: args.r#ref.clone(), + base: effective_ref.clone(), + from_tip: args.from_tip, + }; - if args.format == "json" { - print_json(&args.r#ref, &diff); + if args.subjects_only { + print_subjects(&diff); + } else if args.format == "json" { + print_json(&header, &diff); } else { - print_human(&args.r#ref, &diff); + print_human(&header, &diff, &project_root); } + + enforce_fail_flags(&args, &diff)?; Ok(()) } +/// Apply --kind / --issuer-type filters to all three diff buckets in place. +fn apply_filters(diff: &mut Diff, args: &Args) -> crate::Result<()> { + let kinds: Option> = args.kind.as_ref().map(|s| { + s.split(',') + .map(|k| k.trim().to_string()) + .filter(|k| !k.is_empty()) + .collect() + }); + let issuer_type = match &args.issuer_type { + Some(s) => Some(s.parse::().map_err(crate::Error::Validation)?), + None => None, + }; + + let kind_match = |r: &Record| -> bool { + match &kinds { + Some(list) => r + .kind() + .map(|k| list.iter().any(|allowed| allowed == &k.to_string())) + .unwrap_or(false), + None => true, + } + }; + let issuer_match = |r: &Record| -> bool { + match &issuer_type { + Some(want) => r.issuer_type() == Some(want), + None => true, + } + }; + + diff.added.retain(|r| kind_match(r) && issuer_match(r)); + diff.resolved + .retain(|e| kind_match(&e.old) && issuer_match(&e.old)); + diff.drifted + .retain(|d| kind_match(&d.record) && issuer_match(&d.record)); + Ok(()) +} + +/// `--subjects-only`: dedup-sorted subject paths from all three buckets. +fn print_subjects(diff: &Diff) { + let mut subjects: Vec<&str> = diff + .added + .iter() + .map(|r| r.subject()) + .chain(diff.resolved.iter().map(|e| e.old.subject())) + .chain(diff.drifted.iter().map(|d| d.record.subject())) + .collect(); + subjects.sort(); + subjects.dedup(); + for s in subjects { + println!("{s}"); + } +} + +struct DiffHeader { + /// The ref the user typed (e.g. "main", "v0.5.0"). + input_ref: String, + /// The resolved commit-ish used for comparison — the merge-base sha by + /// default, or `input_ref` itself when `--from-tip` is set. + base: String, + from_tip: bool, +} + +impl DiffHeader { + fn human(&self) -> String { + if self.from_tip { + format!("Comparing HEAD against {} (tip)", self.input_ref) + } else if self.base == self.input_ref { + // No merge-base resolution happened (fallback path). + format!("Comparing HEAD against {}", self.input_ref) + } else { + format!( + "Comparing HEAD against merge-base of {} ({})", + self.input_ref, + short_sha(&self.base), + ) + } + } +} + +/// Apply --fail-on / --fail-on-drift after the diff has printed. We always +/// surface the diff first so the user sees *what* triggered the failure. +fn enforce_fail_flags(args: &Args, diff: &Diff) -> crate::Result<()> { + if args.fail_on_drift && !diff.drifted.is_empty() { + return Err(crate::Error::Validation(format!( + "diff failed: {} drifted record(s) (--fail-on-drift)", + diff.drifted.len() + ))); + } + if let Some(ref list) = args.fail_on { + let kinds: Vec<&str> = list.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect(); + let matched: Vec<&Record> = diff + .added + .iter() + .filter(|r| { + r.kind() + .map(|k| kinds.contains(&k.to_string().as_str())) + .unwrap_or(false) + }) + .collect(); + if !matched.is_empty() { + return Err(crate::Error::Validation(format!( + "diff failed: {} added record(s) match --fail-on {}", + matched.len(), + list + ))); + } + } + Ok(()) +} + +/// Resolve `git merge-base ` to a commit sha, or None if the two refs +/// share no common ancestor (or git fails). +fn git_merge_base(project_root: &Path, a: &str, b: &str) -> Option { + let output = Command::new("git") + .args(["merge-base", a, b]) + .current_dir(project_root) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let s = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if s.is_empty() { None } else { Some(s) } +} + +fn short_sha(s: &str) -> &str { + if s.len() >= 7 { &s[..7] } else { s } +} + /// Enumerate `.qual` paths at the ref via `git ls-tree`, plus the paths that /// exist on the current working tree, and load records at `` for the /// union. Paths that exist only at the ref (deleted on this branch) are @@ -280,12 +472,15 @@ fn sort_key(r: &Record) -> (String, u32) { (r.subject().to_string(), line) } -fn print_human(git_ref: &str, diff: &Diff) { +fn print_human(header: &DiffHeader, diff: &Diff, project_root: &Path) { if diff.added.is_empty() && diff.resolved.is_empty() && diff.drifted.is_empty() { - println!("No annotation changes since {git_ref}."); + println!("{}: no annotation changes.", header.human()); return; } + println!(); + println!("{}", header.human()); + if !diff.added.is_empty() { println!(); println!("Added on this branch ({})", diff.added.len()); @@ -306,7 +501,7 @@ fn print_human(git_ref: &str, diff: &Diff) { println!(); println!("Drifted ({})", diff.drifted.len()); for entry in &diff.drifted { - print_drifted(entry); + print_drifted(entry, project_root); } } println!(); @@ -350,10 +545,19 @@ fn print_resolved(entry: &ResolvedEntry) { Some(c) => { let closer_kind = c.kind().map(|k| k.to_string()).unwrap_or_default(); let closer_id = id_prefix(c.id()); - if closer_kind == Kind::Resolve.to_string() { - format!(" — resolved by {closer_id}") + let closer_summary = c + .as_annotation() + .map(|a| a.body.summary.as_str()) + .unwrap_or(""); + let verb = if closer_kind == Kind::Resolve.to_string() { + "resolved by" + } else { + "superseded by" + }; + if closer_summary.is_empty() { + format!(" — {verb} {closer_id}") } else { - format!(" — superseded by {closer_id}") + format!(" — {verb} {closer_id}: {closer_summary:?}") } } None => " — removed (no successor)".into(), @@ -364,23 +568,36 @@ fn print_resolved(entry: &ResolvedEntry) { ); } -fn print_drifted(entry: &DriftEntry) { +fn print_drifted(entry: &DriftEntry, project_root: &Path) { let att = match entry.record.as_annotation() { Some(a) => a, None => return, }; let id_short = id_prefix(&att.id); let loc = format_location(att); - let exp = id_prefix(&entry.expected); - let act = id_prefix(&entry.actual); println!( - " ~ {:<10} {:<32} span content moved (expected {}, got {}) ({})", + " ~ {:<10} {:<32} span content drifted ({})", att.body.kind.to_string(), loc, - exp, - act, - id_short + id_short, ); + if !att.body.summary.is_empty() { + println!(" original: {:?}", att.body.summary); + } + // Show the current content of the span — what the recorded hash no + // longer matches. The original content lives at and can be + // recovered with `git show :`. + if let Some(ref span) = att.body.span { + let ctx = span_context::read_span_context( + &project_root.join(&att.subject), + span, + span_context::DEFAULT_CONTEXT_LINES, + ); + let formatted = span_context::format_human(&ctx); + for line in formatted.lines() { + println!(" {line}"); + } + } } fn id_prefix(id: &str) -> &str { @@ -400,7 +617,7 @@ fn format_location(att: &crate::annotation::Annotation) -> String { } } -fn print_json(git_ref: &str, diff: &Diff) { +fn print_json(header: &DiffHeader, diff: &Diff) { let added: Vec<_> = diff.added.iter().collect(); let resolved: Vec = diff .resolved @@ -424,7 +641,9 @@ fn print_json(git_ref: &str, diff: &Diff) { }) .collect(); let payload = serde_json::json!({ - "ref": git_ref, + "ref": header.input_ref, + "base": header.base, + "from_tip": header.from_tip, "added": added, "resolved": resolved, "drifted": drifted, diff --git a/src/cli/commands/record.rs b/src/cli/commands/record.rs index 2b1eab8..4c95099 100644 --- a/src/cli/commands/record.rs +++ b/src/cli/commands/record.rs @@ -76,19 +76,32 @@ pub struct Args { /// /// Lines starting with `//` and blank lines are ignored. One record per /// line is emitted on stdout (id + summary, or full JSON with --format - /// json). Errors are reported as `stdin line N: ` and abort the - /// batch. See `qualifier agents record` for a worked example. + /// json). Errors are reported as `stdin line N: : ` and + /// by default abort the batch on the first failure. Pass + /// `--continue-on-error` to collect every error and exit with a summary. + /// See `qualifier agents record` for a worked example. #[arg(long)] pub stdin: bool, + /// In --stdin mode: collect all errors and continue past failed lines + /// instead of aborting on the first. Exit code is non-zero if any line + /// failed; valid lines are still written. + #[arg(long)] + pub continue_on_error: bool, + + /// In --stdin mode: validate every line but do not write any records. + #[arg(long)] + pub dry_run: bool, + /// Output format (human, json). In --stdin mode controls per-record output. + /// Under `--format json`, errors are also emitted as JSON objects on stderr. #[arg(long, default_value = "human")] pub format: String, } pub fn run(args: Args) -> crate::Result<()> { if args.stdin { - return run_batch(&args.format); + return run_batch(&args.format, args.continue_on_error, args.dry_run); } let kind_str = args.kind.as_deref().ok_or_else(|| { @@ -197,83 +210,209 @@ pub fn run(args: Args) -> crate::Result<()> { Ok(()) } -fn run_batch(format: &str) -> crate::Result<()> { +fn run_batch(format: &str, continue_on_error: bool, dry_run: bool) -> crate::Result<()> { let stdin = io::stdin(); - let mut count = 0; + let mut recorded = 0usize; + let mut errors: Vec = Vec::new(); for (line_idx, line) in stdin.lock().lines().enumerate() { let line_no = line_idx + 1; - let line = line.map_err(|e| stdin_err(line_no, e))?; - let trimmed = line.trim(); + let raw = match line { + Ok(l) => l, + Err(e) => { + let be = BatchError { + line: line_no, + error: format!("io error: {e}"), + input: String::new(), + }; + if continue_on_error { + emit_batch_error(&be, format); + errors.push(be); + continue; + } + return Err(be.into_error()); + } + }; + let trimmed = raw.trim(); if trimmed.is_empty() || trimmed.starts_with("//") { continue; } - // Each line is one of: - // - A record overrides object: {kind, location, message, detail?, ...} - // - A complete record (envelope + body) for forward-compat. - let value: serde_json::Value = - serde_json::from_str(trimmed).map_err(|e| stdin_err(line_no, e))?; - - let record = if value.get("body").is_some() && value.get("subject").is_some() { - // Looks like a complete record. - let r: Record = serde_json::from_value(value).map_err(|e| stdin_err(line_no, e))?; - annotation::finalize_record(r) - } else { - // Overrides object — build an annotation. - build_record_from_overrides(value).map_err(|e| stdin_err_wrap(line_no, e))? - }; + match process_one(trimmed, dry_run) { + Ok(record) => { + emit_batch_line(&record, format, dry_run)?; + recorded += 1; + } + Err(msg) => { + let be = BatchError { + line: line_no, + error: msg, + input: trimmed.to_string(), + }; + if continue_on_error { + emit_batch_error(&be, format); + errors.push(be); + continue; + } + return Err(be.into_error()); + } + } + } - // Validate annotation records. - if let Some(att) = record.as_annotation() { - let errors = annotation::validate(att); - if !errors.is_empty() { - return Err(crate::Error::Validation(format!( - "stdin line {line_no}: {}", - errors.join("; ") - ))); + let total = recorded + errors.len(); + let suffix = if dry_run { " (dry run, nothing written)" } else { "" }; + if format == "json" { + // Trailer summary as JSON so consumers parsing stderr line-by-line + // see a structured terminator rather than a free-form English line. + let summary = serde_json::json!({ + "summary": { + "recorded": recorded, + "failed": errors.len(), + "total": total, + "dry_run": dry_run, } + }); + eprintln!("{summary}"); + } else { + eprintln!( + "Recorded {recorded} of {total} records from stdin{}{}", + if errors.is_empty() { + String::new() + } else { + format!(", {} failed", errors.len()) + }, + suffix + ); + } + + if !errors.is_empty() { + // Suppress the top-level `qualifier: ...` line under --format json so + // stderr stays a clean JSONL stream — the per-line error objects and + // summary already carry every detail a consumer needs. + if format == "json" { + std::process::exit(1); } + return Err(crate::Error::Validation(format!( + "{} of {} stdin records failed (--continue-on-error)", + errors.len(), + total + ))); + } + Ok(()) +} - let qual_path = qual_file::resolve_qual_path(record.subject(), None) - .map_err(|e| stdin_err_wrap(line_no, e))?; +/// Apply parse → validate → supersession-check → append for one stdin line. +/// On success returns the canonicalized record; on failure returns a +/// human-readable message (no line-number prefix — the caller adds context). +fn process_one(trimmed: &str, dry_run: bool) -> std::result::Result { + let value: serde_json::Value = + serde_json::from_str(trimmed).map_err(|e| format!("invalid JSON: {e}"))?; + + let record = if value.get("body").is_some() && value.get("subject").is_some() { + let r: Record = + serde_json::from_value(value).map_err(|e| format!("invalid record: {e}"))?; + annotation::finalize_record(r) + } else { + build_record_from_overrides(value).map_err(|e| e.to_string())? + }; - if record.supersedes().is_some() { - let existing = if qual_path.exists() { - qual_file::parse(&qual_path) - .map_err(|e| stdin_err_wrap(line_no, e))? - .records - } else { - Vec::new() - }; - let mut all = existing; - all.push(record.clone()); - annotation::check_supersession_cycles(&all).map_err(|e| stdin_err_wrap(line_no, e))?; - annotation::validate_supersession_targets(&all) - .map_err(|e| stdin_err_wrap(line_no, e))?; + if let Some(att) = record.as_annotation() { + let errors = annotation::validate(att); + if !errors.is_empty() { + return Err(errors.join("; ")); } + } + + let qual_path = qual_file::resolve_qual_path(record.subject(), None).map_err(|e| e.to_string())?; - qual_file::append(&qual_path, &record).map_err(|e| stdin_err_wrap(line_no, e))?; - emit_batch_line(&record, format)?; - count += 1; + if record.supersedes().is_some() { + let existing = if qual_path.exists() { + qual_file::parse(&qual_path) + .map_err(|e| e.to_string())? + .records + } else { + Vec::new() + }; + let mut all = existing; + all.push(record.clone()); + annotation::check_supersession_cycles(&all).map_err(|e| e.to_string())?; + annotation::validate_supersession_targets(&all).map_err(|e| e.to_string())?; } - if format != "json" { - eprintln!("Recorded {count} records from stdin"); + if !dry_run { + qual_file::append(&qual_path, &record).map_err(|e| e.to_string())?; + } + Ok(record) +} + +struct BatchError { + line: usize, + error: String, + input: String, +} + +impl BatchError { + /// Produce the legacy single-error abort form. Includes the offending + /// line content so the user can see what they sent without re-piping. + fn into_error(self) -> crate::Error { + let truncated = truncate_for_display(&self.input, 200); + crate::Error::Validation(if truncated.is_empty() { + format!("stdin line {}: {}", self.line, self.error) + } else { + format!("stdin line {}: {}: {}", self.line, self.error, truncated) + }) + } +} + +fn truncate_for_display(s: &str, max: usize) -> String { + if s.chars().count() <= max { + s.to_string() + } else { + let prefix: String = s.chars().take(max).collect(); + format!("{prefix}...") + } +} + +/// Emit a per-line error in the requested format. Always to stderr so +/// stdout (the success stream) stays clean. +fn emit_batch_error(be: &BatchError, format: &str) { + if format == "json" { + let v = serde_json::json!({ + "line": be.line, + "error": be.error, + "input": be.input, + }); + eprintln!("{v}"); + } else { + let truncated = truncate_for_display(&be.input, 200); + if truncated.is_empty() { + eprintln!("stdin line {}: {}", be.line, be.error); + } else { + eprintln!("stdin line {}: {}: {}", be.line, be.error, truncated); + } } - Ok(()) } /// Emit one stdout line per recorded batch entry. /// /// `human`: a compact summary (kind, location[+span], summary, id-prefix). /// `json`: the full canonical record as a single JSONL line. -fn emit_batch_line(record: &Record, format: &str) -> crate::Result<()> { +/// +/// Under `--dry-run`, the human verb becomes "would-record" so the user +/// can tell at a glance that nothing was written. +fn emit_batch_line(record: &Record, format: &str, dry_run: bool) -> crate::Result<()> { if format == "json" { - println!("{}", serde_json::to_string(record)?); + let mut v = serde_json::to_value(record)?; + if dry_run + && let Some(obj) = v.as_object_mut() + { + obj.insert("dry_run".into(), serde_json::Value::Bool(true)); + } + println!("{}", serde_json::to_string(&v)?); return Ok(()); } + let verb = if dry_run { "would-record" } else { "recorded " }; let id = record.id(); let id_short = if id.len() >= 8 { &id[..8] } else { id }; if let Some(att) = record.as_annotation() { @@ -288,7 +427,7 @@ fn emit_batch_line(record: &Record, format: &str) -> crate::Result<()> { None => String::new(), }; println!( - "recorded {:<10} {}{} {} id: {}", + "{verb} {:<10} {}{} {} id: {}", att.body.kind.to_string(), att.subject, span_str, @@ -297,7 +436,7 @@ fn emit_batch_line(record: &Record, format: &str) -> crate::Result<()> { ); } else { println!( - "recorded {:<10} {} id: {}", + "{verb} {:<10} {} id: {}", record.record_type(), record.subject(), id_short, @@ -306,14 +445,6 @@ fn emit_batch_line(record: &Record, format: &str) -> crate::Result<()> { Ok(()) } -fn stdin_err(line_no: usize, e: E) -> crate::Error { - crate::Error::Validation(format!("stdin line {line_no}: {e}")) -} - -fn stdin_err_wrap(line_no: usize, e: crate::Error) -> crate::Error { - crate::Error::Validation(format!("stdin line {line_no}: {e}")) -} - fn build_record_from_overrides(value: serde_json::Value) -> crate::Result { let obj = value .as_object() diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index 5749a7c..62c189d 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -2479,11 +2479,11 @@ fn test_record_stdin_emits_per_record_human_output() { // Trailing summary count goes to stderr (not stdout) so JSON pipes stay clean. assert!( - stderr.contains("Recorded 2 records"), + stderr.contains("Recorded 2 of 2"), "summary should be on stderr: {stderr}" ); assert!( - !stdout.contains("Recorded 2 records"), + !stdout.contains("Recorded 2"), "summary should NOT be on stdout: {stdout}" ); } @@ -2667,7 +2667,7 @@ fn test_diff_no_changes() { let (stdout, _, code) = run_qualifier(dir.path(), &["diff", "main"]); assert_eq!(code, 0); assert!( - stdout.contains("No annotation changes"), + stdout.contains("no annotation changes"), "no-op diff should say so: {stdout}" ); } @@ -2736,6 +2736,651 @@ fn test_diff_json_format() { assert!(v["drifted"].is_array()); } +// --- record --stdin: --continue-on-error / --dry-run / JSON errors --- + +#[test] +fn test_record_stdin_continue_on_error_keeps_valid_lines() { + let dir = tempfile::tempdir().unwrap(); + let input = r#"{"kind":"pass","location":"a.rs","message":"first"} +{"kind":"oops","location":"b.rs"} +{"kind":"concern","location":"c.rs","message":"third"} +"#; + let (stdout, stderr, code) = run_qualifier_stdin( + dir.path(), + &[ + "record", + "--stdin", + "--continue-on-error", + "--issuer", + "mailto:a@b.com", + ], + input, + ); + assert_ne!(code, 0, "should exit non-zero when any line fails"); + + // The two valid lines should each have a stdout line. + let stdout_lines: Vec<&str> = stdout.lines().collect(); + assert_eq!( + stdout_lines.len(), + 2, + "two valid lines should each emit one stdout line: {stdout}" + ); + assert!(stdout_lines[0].contains("first")); + assert!(stdout_lines[1].contains("third")); + + // The invalid line should be reported on stderr with its line number AND + // its offending input echoed back to the user. + assert!( + stderr.contains("stdin line 2"), + "error should name the line: {stderr}" + ); + assert!( + stderr.contains("missing 'message'"), + "error should describe the failure: {stderr}" + ); + assert!( + stderr.contains("\"kind\":\"oops\""), + "error should echo the offending input: {stderr}" + ); + + // The valid lines must have actually been written. + let qual = std::fs::read_to_string(dir.path().join(".qual")).unwrap(); + assert!(qual.contains("first")); + assert!(qual.contains("third")); + assert!(!qual.contains("oops")); +} + +#[test] +fn test_record_stdin_dry_run_writes_nothing() { + let dir = tempfile::tempdir().unwrap(); + let input = r#"{"kind":"pass","location":"a.rs","message":"hi"} +"#; + let (stdout, stderr, code) = run_qualifier_stdin( + dir.path(), + &["record", "--stdin", "--dry-run", "--issuer", "mailto:a@b.com"], + input, + ); + assert_eq!(code, 0); + assert!( + stdout.contains("would-record"), + "dry-run output should use 'would-record' verb: {stdout}" + ); + assert!( + stderr.contains("dry run"), + "summary should mention dry run: {stderr}" + ); + // No .qual file should exist anywhere under the tempdir. + assert!( + !dir.path().join(".qual").exists(), + "dry-run must not write the .qual file" + ); +} + +#[test] +fn test_record_stdin_dry_run_still_validates() { + let dir = tempfile::tempdir().unwrap(); + // Empty summary: invalid annotation. Dry-run should still surface this. + let input = r#"{"kind":"pass","location":"a.rs","message":""} +"#; + let (_, stderr, code) = run_qualifier_stdin( + dir.path(), + &["record", "--stdin", "--dry-run", "--issuer", "mailto:a@b.com"], + input, + ); + assert_ne!(code, 0, "dry-run must still report validation errors"); + assert!( + stderr.contains("summary"), + "error should reference the empty summary: {stderr}" + ); + assert!( + !dir.path().join(".qual").exists(), + "no file should be written even on validation success — let alone failure" + ); +} + +#[test] +fn test_record_stdin_json_errors_are_structured() { + let dir = tempfile::tempdir().unwrap(); + let input = r#"{"kind":"pass","location":"a.rs","message":"ok"} +{"kind":"oops","location":"b.rs"} +"#; + let (stdout, stderr, code) = run_qualifier_stdin( + dir.path(), + &[ + "record", + "--stdin", + "--continue-on-error", + "--format", + "json", + "--issuer", + "mailto:a@b.com", + ], + input, + ); + assert_ne!(code, 0); + + // stdout: each line a valid JSONL record. + for line in stdout.lines() { + let v: serde_json::Value = + serde_json::from_str(line).expect("stdout line should be valid JSON"); + assert_eq!(v["type"], "annotation"); + } + + // stderr: every line should also be a valid JSON object — error first, + // then a `summary` trailer. NO free-form text. + let stderr_lines: Vec<&str> = stderr.lines().collect(); + assert!(stderr_lines.len() >= 2, "expected error+summary: {stderr}"); + + let err_obj: serde_json::Value = + serde_json::from_str(stderr_lines[0]).expect("first stderr line should be JSON"); + assert_eq!(err_obj["line"], 2); + assert!(err_obj["error"].as_str().unwrap().contains("message")); + assert!(err_obj["input"].as_str().unwrap().contains("oops")); + + // Last stderr line is the summary trailer. + let summary: serde_json::Value = + serde_json::from_str(stderr_lines.last().unwrap()).expect("trailer should be JSON"); + assert_eq!(summary["summary"]["recorded"], 1); + assert_eq!(summary["summary"]["failed"], 1); + assert_eq!(summary["summary"]["total"], 2); +} + +#[test] +fn test_record_stdin_default_aborts_on_first_error_with_input_echoed() { + let dir = tempfile::tempdir().unwrap(); + let input = r#"{"kind":"pass","location":"a.rs","message":"ok"} +{"kind":"oops","location":"b.rs"} +{"kind":"concern","location":"c.rs","message":"third"} +"#; + let (_, stderr, code) = run_qualifier_stdin( + dir.path(), + &["record", "--stdin", "--issuer", "mailto:a@b.com"], + input, + ); + assert_ne!(code, 0); + // Without --continue-on-error, line 2 aborts the batch — the error + // message must include the offending input so the user can see what + // they sent without re-piping. + assert!( + stderr.contains("stdin line 2"), + "error should name the failing line: {stderr}" + ); + assert!( + stderr.contains("\"kind\":\"oops\""), + "error should echo the offending input on the abort path too: {stderr}" + ); + + // Line 1 was written (sequential semantics); line 3 was not. + let qual = std::fs::read_to_string(dir.path().join(".qual")).unwrap(); + assert!(qual.contains("ok")); + assert!(!qual.contains("third")); +} + +// --- diff: merge-base default --- + +/// Set up a repo where `main` advances *after* a feature branch has been +/// created. This exercises the merge-base default: a record that landed on +/// main after the branch must NOT show up as "Added" when diffing the +/// feature branch against main. +fn diff_merge_base_setup(dir: &Path) { + git_init(dir); + + // Initial commit on main with a baseline file. Use --allow-empty so the + // merge-base is well-defined even if there's nothing else to commit yet. + Command::new("git") + .args(["commit", "-q", "--allow-empty", "-m", "init"]) + .current_dir(dir) + .status() + .unwrap(); + + // Branch off here. + Command::new("git") + .args(["checkout", "-q", "-b", "feat"]) + .current_dir(dir) + .status() + .unwrap(); + + // On the feature branch: record one new concern. + let (_, _, code) = run_qualifier( + dir, + &[ + "record", + "concern", + "feat.rs", + "feature finding", + "--issuer", + "mailto:a@b.com", + ], + ); + assert_eq!(code, 0); + git_commit_all(dir, "feat record"); + + // Now go back to main and add a record that didn't exist when feat + // forked. The feature branch must not see this as "Added". + Command::new("git") + .args(["checkout", "-q", "main"]) + .current_dir(dir) + .status() + .unwrap(); + let (_, _, code) = run_qualifier( + dir, + &[ + "record", + "concern", + "main.rs", + "post-fork main finding", + "--issuer", + "mailto:a@b.com", + ], + ); + assert_eq!(code, 0); + git_commit_all(dir, "main record after fork"); + + // Return to feat for the diff. + Command::new("git") + .args(["checkout", "-q", "feat"]) + .current_dir(dir) + .status() + .unwrap(); +} + +#[test] +fn test_diff_default_uses_merge_base() { + let dir = tempfile::tempdir().unwrap(); + diff_merge_base_setup(dir.path()); + + let (stdout, _, code) = run_qualifier(dir.path(), &["diff", "main"]); + assert_eq!(code, 0); + assert!( + stdout.contains("merge-base"), + "default header should mention merge-base: {stdout}" + ); + assert!( + stdout.contains("feature finding"), + "feature record should be Added: {stdout}" + ); + assert!( + !stdout.contains("post-fork main finding"), + "main-only record after fork must not show up under merge-base default: {stdout}" + ); +} + +#[test] +fn test_diff_from_tip_includes_post_fork_main_records() { + let dir = tempfile::tempdir().unwrap(); + diff_merge_base_setup(dir.path()); + + let (stdout, _, code) = run_qualifier(dir.path(), &["diff", "main", "--from-tip"]); + assert_eq!(code, 0); + assert!( + stdout.contains("(tip)"), + "header should reflect --from-tip: {stdout}" + ); + // Under --from-tip, the post-fork main record IS in but missing + // from HEAD — it appears as Resolved/removed. + assert!( + stdout.contains("post-fork main finding"), + "from-tip diff should surface main-only records: {stdout}" + ); +} + +// --- diff: --fail-on / --fail-on-drift --- + +#[test] +fn test_diff_fail_on_kind_exits_nonzero() { + let dir = tempfile::tempdir().unwrap(); + git_init(dir.path()); + Command::new("git") + .args(["commit", "-q", "--allow-empty", "-m", "init"]) + .current_dir(dir.path()) + .status() + .unwrap(); + Command::new("git") + .args(["checkout", "-q", "-b", "feat"]) + .current_dir(dir.path()) + .status() + .unwrap(); + let (_, _, code) = run_qualifier( + dir.path(), + &[ + "record", + "blocker", + "x.rs", + "ship-stop", + "--issuer", + "mailto:a@b.com", + ], + ); + assert_eq!(code, 0); + + let (stdout, stderr, code) = + run_qualifier(dir.path(), &["diff", "main", "--fail-on", "blocker"]); + assert_ne!(code, 0, "should fail when a blocker is added"); + assert!( + stdout.contains("blocker"), + "diff should still print the offending record before failing: {stdout}" + ); + assert!( + stderr.contains("--fail-on"), + "error message should reference the flag that triggered it: {stderr}" + ); + + // A non-matching --fail-on should pass. + let (_, _, code) = run_qualifier(dir.path(), &["diff", "main", "--fail-on", "fail"]); + assert_eq!(code, 0, "no `fail` records added; should pass"); +} + +#[test] +fn test_diff_fail_on_multiple_kinds() { + let dir = tempfile::tempdir().unwrap(); + git_init(dir.path()); + Command::new("git") + .args(["commit", "-q", "--allow-empty", "-m", "init"]) + .current_dir(dir.path()) + .status() + .unwrap(); + Command::new("git") + .args(["checkout", "-q", "-b", "feat"]) + .current_dir(dir.path()) + .status() + .unwrap(); + let (_, _, code) = run_qualifier( + dir.path(), + &[ + "record", "fail", "x.rs", "broke", "--issuer", "mailto:a@b.com", + ], + ); + assert_eq!(code, 0); + + let (_, _, code) = run_qualifier( + dir.path(), + &["diff", "main", "--fail-on", "blocker,fail"], + ); + assert_ne!(code, 0, "comma-separated list should match `fail` records"); +} + +#[test] +fn test_diff_fail_on_drift() { + let dir = tempfile::tempdir().unwrap(); + git_init(dir.path()); + std::fs::write(dir.path().join("m.rs"), "a\nb\nc\n").unwrap(); + let (_, _, code) = run_qualifier( + dir.path(), + &[ + "record", "concern", "m.rs:2", "look", "--issuer", "mailto:a@b.com", + ], + ); + assert_eq!(code, 0); + git_commit_all(dir.path(), "baseline"); + Command::new("git") + .args(["checkout", "-q", "-b", "feat"]) + .current_dir(dir.path()) + .status() + .unwrap(); + // Mutate the spanned line to drift the content_hash. + std::fs::write(dir.path().join("m.rs"), "a\nB\nc\n").unwrap(); + + let (_, stderr, code) = run_qualifier(dir.path(), &["diff", "main", "--fail-on-drift"]); + assert_ne!(code, 0, "should exit non-zero when drift is present"); + assert!( + stderr.contains("drifted") || stderr.contains("drift"), + "error should reference drift: {stderr}" + ); +} + +// --- diff: filter flags --- + +#[test] +fn test_diff_kind_filter() { + let dir = tempfile::tempdir().unwrap(); + git_init(dir.path()); + Command::new("git") + .args(["commit", "-q", "--allow-empty", "-m", "init"]) + .current_dir(dir.path()) + .status() + .unwrap(); + Command::new("git") + .args(["checkout", "-q", "-b", "feat"]) + .current_dir(dir.path()) + .status() + .unwrap(); + let (_, _, _) = run_qualifier( + dir.path(), + &[ + "record", "concern", "a.rs", "concern1", "--issuer", "mailto:a@b.com", + ], + ); + let (_, _, _) = run_qualifier( + dir.path(), + &[ + "record", "blocker", "a.rs", "blocker1", "--issuer", "mailto:a@b.com", + ], + ); + let (_, _, _) = run_qualifier( + dir.path(), + &[ + "record", + "suggestion", + "a.rs", + "suggestion1", + "--issuer", + "mailto:a@b.com", + ], + ); + + let (stdout, _, code) = run_qualifier(dir.path(), &["diff", "main", "--kind", "concern"]); + assert_eq!(code, 0); + assert!(stdout.contains("concern1"), "concern should appear: {stdout}"); + assert!( + !stdout.contains("blocker1"), + "blocker should be filtered out: {stdout}" + ); + assert!( + !stdout.contains("suggestion1"), + "suggestion should be filtered out: {stdout}" + ); + assert!( + stdout.contains("Added on this branch (1)"), + "should show only one match: {stdout}" + ); +} + +#[test] +fn test_diff_issuer_type_filter() { + let dir = tempfile::tempdir().unwrap(); + git_init(dir.path()); + Command::new("git") + .args(["commit", "-q", "--allow-empty", "-m", "init"]) + .current_dir(dir.path()) + .status() + .unwrap(); + Command::new("git") + .args(["checkout", "-q", "-b", "feat"]) + .current_dir(dir.path()) + .status() + .unwrap(); + let (_, _, _) = run_qualifier( + dir.path(), + &[ + "record", + "concern", + "a.rs", + "from-ai", + "--issuer", + "mailto:bot@example.com", + "--issuer-type", + "ai", + ], + ); + let (_, _, _) = run_qualifier( + dir.path(), + &[ + "record", + "concern", + "a.rs", + "from-human", + "--issuer", + "mailto:dev@example.com", + "--issuer-type", + "human", + ], + ); + + let (stdout, _, _) = run_qualifier(dir.path(), &["diff", "main", "--issuer-type", "ai"]); + assert!(stdout.contains("from-ai"), "ai record should appear: {stdout}"); + assert!( + !stdout.contains("from-human"), + "human record should be filtered: {stdout}" + ); +} + +#[test] +fn test_diff_subjects_only() { + let dir = tempfile::tempdir().unwrap(); + git_init(dir.path()); + Command::new("git") + .args(["commit", "-q", "--allow-empty", "-m", "init"]) + .current_dir(dir.path()) + .status() + .unwrap(); + Command::new("git") + .args(["checkout", "-q", "-b", "feat"]) + .current_dir(dir.path()) + .status() + .unwrap(); + for (subject, summary) in [("a.rs", "1"), ("b.rs", "2"), ("a.rs", "3")] { + let (_, _, code) = run_qualifier( + dir.path(), + &[ + "record", + "concern", + subject, + summary, + "--issuer", + "mailto:a@b.com", + ], + ); + assert_eq!(code, 0); + } + + let (stdout, _, code) = run_qualifier(dir.path(), &["diff", "main", "--subjects-only"]); + assert_eq!(code, 0); + let lines: Vec<&str> = stdout.lines().collect(); + assert_eq!(lines, vec!["a.rs", "b.rs"], "should be deduped + sorted: {stdout}"); +} + +// --- diff: human output polish --- + +#[test] +fn test_diff_resolved_inlines_closer_summary() { + let dir = tempfile::tempdir().unwrap(); + git_init(dir.path()); + std::fs::write(dir.path().join("x.rs"), "fn x() {}\n").unwrap(); + let (_, _, code) = run_qualifier( + dir.path(), + &[ + "record", "concern", "x.rs:1", "needs work", "--issuer", "mailto:a@b.com", + ], + ); + assert_eq!(code, 0); + git_commit_all(dir.path(), "baseline"); + Command::new("git") + .args(["checkout", "-q", "-b", "feat"]) + .current_dir(dir.path()) + .status() + .unwrap(); + let (_, _, code) = run_qualifier( + dir.path(), + &[ + "resolve", "x.rs:1", "fixed in PR #42", "--issuer", "mailto:a@b.com", + ], + ); + assert_eq!(code, 0); + + let (stdout, _, code) = run_qualifier(dir.path(), &["diff", "main"]); + assert_eq!(code, 0); + assert!( + stdout.contains("resolved by"), + "should mention closer: {stdout}" + ); + assert!( + stdout.contains("fixed in PR #42"), + "closer summary should be inlined: {stdout}" + ); +} + +#[test] +fn test_diff_drift_includes_span_snippet() { + let dir = tempfile::tempdir().unwrap(); + git_init(dir.path()); + std::fs::write(dir.path().join("m.rs"), "fn alpha() {}\nfn beta() {}\nfn gamma() {}\n") + .unwrap(); + let (_, _, code) = run_qualifier( + dir.path(), + &[ + "record", "concern", "m.rs:2", "watch beta", "--issuer", "mailto:a@b.com", + ], + ); + assert_eq!(code, 0); + git_commit_all(dir.path(), "baseline"); + Command::new("git") + .args(["checkout", "-q", "-b", "feat"]) + .current_dir(dir.path()) + .status() + .unwrap(); + std::fs::write( + dir.path().join("m.rs"), + "fn alpha() {}\nfn beta() { /* changed */ }\nfn gamma() {}\n", + ) + .unwrap(); + + let (stdout, _, code) = run_qualifier(dir.path(), &["diff", "main"]); + assert_eq!(code, 0); + assert!( + stdout.contains("watch beta"), + "should print original summary: {stdout}" + ); + assert!( + stdout.contains("fn beta() { /* changed */ }"), + "should print current span content as a snippet: {stdout}" + ); + // Compiler-style marker for the drifted line. + assert!(stdout.contains("> 2"), "snippet should mark line 2: {stdout}"); +} + +// --- diff: JSON shape stability --- + +#[test] +fn test_diff_json_includes_base_and_from_tip() { + let dir = tempfile::tempdir().unwrap(); + git_init(dir.path()); + Command::new("git") + .args(["commit", "-q", "--allow-empty", "-m", "init"]) + .current_dir(dir.path()) + .status() + .unwrap(); + Command::new("git") + .args(["checkout", "-q", "-b", "feat"]) + .current_dir(dir.path()) + .status() + .unwrap(); + let (_, _, _) = run_qualifier( + dir.path(), + &[ + "record", "concern", "x.rs", "y", "--issuer", "mailto:a@b.com", + ], + ); + + let (stdout, _, code) = run_qualifier(dir.path(), &["diff", "main", "--format", "json"]); + assert_eq!(code, 0); + let v: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!(v["ref"], "main"); + assert_eq!(v["from_tip"], false); + let base = v["base"].as_str().expect("base should be a sha string"); + assert_eq!(base.len(), 40, "base should be a full sha: {base}"); + assert!(v["added"].is_array()); +} + #[test] fn test_top_level_help_shows_agents_group() { let dir = tempfile::tempdir().unwrap(); From e6fe943e0e13c96107e182263e28c806d3245a97 Mon Sep 17 00:00:00 2001 From: Alex Kesling Date: Thu, 7 May 2026 00:36:15 -0400 Subject: [PATCH 4/7] refactor: drive `qualifier diff` through gitoxide instead of shelling out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four `git` invocations per diff (rev-parse, merge-base, ls-tree, and one `git show` per .qual file at ) become in-process calls on a single `gix::Repository`. Tree enumeration is now one breadth-first walk that yields (path, blob_oid) pairs for every .qual file at the comparison commit; each blob is then fetched directly from the object database without spawning a process. For a project with N .qual files at , this collapses N+3 subprocess spawns into zero — meaningful on Windows and on hot CI loops, even at small N. `gix` is gated behind the existing `cli` feature, so the lib build remains lean for embedded callers. All 88 existing diff/stdin integration tests pass unchanged; the public CLI behaviour and JSON shape are byte-identical. --- Cargo.lock | 1487 ++++++++++++++++++++++++++++++++++-- Cargo.toml | 4 +- src/cli/commands/diff.rs | 210 +++-- src/cli/commands/record.rs | 19 +- 4 files changed, 1545 insertions(+), 175 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a6c7822..f5b1ddb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -76,6 +82,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arc-swap" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +dependencies = [ + "rustversion", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -123,6 +138,15 @@ dependencies = [ "cpufeatures", ] +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bstr" version = "1.12.1" @@ -130,6 +154,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" dependencies = [ "memchr", + "regex-automata", "serde", ] @@ -145,6 +170,24 @@ version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "bytesize" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bd91ee7b2422bcb158d90ef4d14f75ef67f340943fc4149891dcce8f8b972a3" + [[package]] name = "cc" version = "1.2.56" @@ -215,6 +258,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" +[[package]] +name = "clru" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "197fd99cb113a8d5d9b6376f3aa817f32c1078f2343b714fff7d2ca44fdf67d5" +dependencies = [ + "hashbrown 0.16.1", +] + [[package]] name = "colorchoice" version = "1.0.4" @@ -253,6 +305,24 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -302,91 +372,1104 @@ dependencies = [ ] [[package]] -name = "document-features" -version = "0.2.12" +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "faster-hex" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7223ae2d2f179b803433d9c830478527e92b8117eab39460edae7f1614d9fb73" +dependencies = [ + "heapless", + "serde", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "figment" +version = "0.10.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" +dependencies = [ + "atomic", + "pear", + "serde", + "toml", + "uncased", + "version_check", +] + +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "gix" +version = "0.83.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce52001b946a6249d5d0d3011df0a042ac3f8a4d013460db6476577b0b9c567" +dependencies = [ + "gix-actor", + "gix-archive", + "gix-attributes", + "gix-blame", + "gix-command", + "gix-commitgraph", + "gix-config", + "gix-credentials", + "gix-date", + "gix-diff", + "gix-dir", + "gix-discover", + "gix-error", + "gix-features", + "gix-filter", + "gix-fs", + "gix-glob", + "gix-hash", + "gix-hashtable", + "gix-ignore", + "gix-index", + "gix-lock", + "gix-mailmap", + "gix-merge", + "gix-negotiate", + "gix-object", + "gix-odb", + "gix-pack", + "gix-path", + "gix-pathspec", + "gix-prompt", + "gix-protocol", + "gix-ref", + "gix-refspec", + "gix-revision", + "gix-revwalk", + "gix-sec", + "gix-shallow", + "gix-status", + "gix-submodule", + "gix-tempfile", + "gix-trace", + "gix-traverse", + "gix-url", + "gix-utils", + "gix-validate", + "gix-worktree", + "gix-worktree-state", + "gix-worktree-stream", + "nonempty", + "parking_lot", + "regex", + "signal-hook", + "smallvec", + "thiserror", +] + +[[package]] +name = "gix-actor" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "272916673b83714734b15d4ef3c8b5f1ccddb15fea8ff548430b97c1ab7b7ed8" +dependencies = [ + "bstr", + "gix-date", + "gix-error", +] + +[[package]] +name = "gix-archive" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a20ec244b733338d4cb60e5e05eac700dab7fcc689647b1d1daa9396b119342" +dependencies = [ + "bstr", + "gix-date", + "gix-error", + "gix-object", + "gix-worktree-stream", +] + +[[package]] +name = "gix-attributes" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe17c5a1c0b6f2ef1476aa1d3222ea50cdff67608016613a58bfc3e078046000" +dependencies = [ + "bstr", + "gix-glob", + "gix-path", + "gix-quote", + "gix-trace", + "kstring", + "smallvec", + "thiserror", + "unicode-bom", +] + +[[package]] +name = "gix-bitmap" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ecbfc77ec6852294e341ecc305a490b59f2813e6ca42d79efda5099dcab1894" +dependencies = [ + "gix-error", +] + +[[package]] +name = "gix-blame" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14dab9a942ab54a9661ded7397c3bf927274e7afa94494db0d75cfcbde02ca0a" +dependencies = [ + "gix-commitgraph", + "gix-date", + "gix-diff", + "gix-error", + "gix-hash", + "gix-object", + "gix-revwalk", + "gix-trace", + "gix-traverse", + "gix-worktree", + "smallvec", + "thiserror", +] + +[[package]] +name = "gix-chunk" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edf288be9b60fe7231de03771faa292be1493d84786f68727e33ad1f91764320" +dependencies = [ + "gix-error", +] + +[[package]] +name = "gix-command" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86335306511abe43d75c866d4b1f3d90932fe202edcd43e1314036333e7384d8" +dependencies = [ + "bstr", + "gix-path", + "gix-quote", + "gix-trace", + "shell-words", +] + +[[package]] +name = "gix-commitgraph" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe3b5aa0f24e19028c261d229aeeedafcaaa52ebd71021cc15184620fc9d32eb" +dependencies = [ + "bstr", + "gix-chunk", + "gix-error", + "gix-hash", + "memmap2", + "nonempty", +] + +[[package]] +name = "gix-config" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c01848aebd21c67f6ba41f1de8efd46ae96df21f001954a3c9e1517e514d410" +dependencies = [ + "bstr", + "gix-config-value", + "gix-features", + "gix-glob", + "gix-path", + "gix-ref", + "gix-sec", + "smallvec", + "thiserror", + "unicode-bom", +] + +[[package]] +name = "gix-config-value" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b39ed39ee4c10a3b157f9fb94bac8098d9f8e56201f0cf7dee6c187416c4b2" +dependencies = [ + "bitflags", + "bstr", + "gix-path", + "libc", + "thiserror", +] + +[[package]] +name = "gix-credentials" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65ca11598b70811d7b16ff90945a6e57dfe521e85b744e51636965fe39cc8f60" +dependencies = [ + "bstr", + "gix-command", + "gix-config-value", + "gix-date", + "gix-path", + "gix-prompt", + "gix-sec", + "gix-trace", + "gix-url", + "thiserror", +] + +[[package]] +name = "gix-date" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b94cdae4eb4b0f4136e3d9b3aa2d2cd03cfb5bb9b636b31263aea2df86d41543" +dependencies = [ + "bstr", + "gix-error", + "itoa", + "jiff", + "smallvec", +] + +[[package]] +name = "gix-diff" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc08e0fa1a91ff5f24affeab052f198056645e1de004910bde7b82b50ea5982a" +dependencies = [ + "bstr", + "gix-attributes", + "gix-command", + "gix-filter", + "gix-fs", + "gix-hash", + "gix-imara-diff", + "gix-index", + "gix-object", + "gix-path", + "gix-pathspec", + "gix-tempfile", + "gix-trace", + "gix-traverse", + "gix-worktree", + "thiserror", +] + +[[package]] +name = "gix-dir" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a0fc06e9e1e430cbf0a313666976d90f822f461a6525320427aa9b8af5236c" +dependencies = [ + "bstr", + "gix-discover", + "gix-fs", + "gix-ignore", + "gix-index", + "gix-object", + "gix-path", + "gix-pathspec", + "gix-trace", + "gix-utils", + "gix-worktree", + "thiserror", +] + +[[package]] +name = "gix-discover" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17852e6a501e688a1702b24ebe5b3761d4719455bc869fd29f38b0b859bcad34" +dependencies = [ + "bstr", + "dunce", + "gix-fs", + "gix-path", + "gix-ref", + "gix-sec", + "thiserror", +] + +[[package]] +name = "gix-error" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e207b971746ab724fccdfced2e4e19e854744611904a0195d3aa8fda8a110613" +dependencies = [ + "bstr", +] + +[[package]] +name = "gix-features" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af375693ad5333d0a2c66b4c5b2cbe9ccc38e34f8e8bf24e4ae42c12307fdc4f" +dependencies = [ + "bytes", + "bytesize", + "crc32fast", + "crossbeam-channel", + "gix-path", + "gix-trace", + "gix-utils", + "libc", + "once_cell", + "parking_lot", + "prodash", + "thiserror", + "walkdir", + "zlib-rs", +] + +[[package]] +name = "gix-filter" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dac917dbe9653c9b615d248db91907a365bd779750c9e1b457a9d9fdeece3a08" +dependencies = [ + "bstr", + "encoding_rs", + "gix-attributes", + "gix-command", + "gix-hash", + "gix-object", + "gix-packetline", + "gix-path", + "gix-quote", + "gix-trace", + "gix-utils", + "smallvec", + "thiserror", +] + +[[package]] +name = "gix-fs" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e1967daac9848757c47c2aef0c57bcadc1a897347f559778249bf286a536c86" +dependencies = [ + "bstr", + "fastrand", + "gix-features", + "gix-path", + "gix-utils", + "thiserror", +] + +[[package]] +name = "gix-glob" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bf29249a069bf2507f5964f80997f37b134d320ea348d66527726b9be2c38c" +dependencies = [ + "bitflags", + "bstr", + "gix-features", + "gix-path", +] + +[[package]] +name = "gix-hash" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcf70d1e252337eed16360f8b8ebb71865ece58eab7954b39ce38b420de703d2" +dependencies = [ + "faster-hex", + "gix-features", + "sha1-checked", + "thiserror", +] + +[[package]] +name = "gix-hashtable" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d33b455e07b3c16d3b2eeebc7b38d2dafcbf8a653de1138ef55d4c2a1fd0b08b" +dependencies = [ + "gix-hash", + "hashbrown 0.16.1", + "parking_lot", +] + +[[package]] +name = "gix-ignore" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb13fbbeeafee943e52b61fcc88dfddf6a452fcaf0c4d0cdc8f218fa25bbec5" +dependencies = [ + "bstr", + "gix-glob", + "gix-path", + "gix-trace", + "unicode-bom", +] + +[[package]] +name = "gix-imara-diff" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39eb0623e15e4cb83c02ce6a959e48fadd1ae3b715b36b5acc01816e01388c82" +dependencies = [ + "bstr", + "hashbrown 0.15.5", +] + +[[package]] +name = "gix-index" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54c3ef97ad08121e4327a6226bd63fed6b9e3c6b976d48bddd4356d9d41191db" +dependencies = [ + "bitflags", + "bstr", + "filetime", + "fnv", + "gix-bitmap", + "gix-features", + "gix-fs", + "gix-hash", + "gix-lock", + "gix-object", + "gix-traverse", + "gix-utils", + "gix-validate", + "hashbrown 0.16.1", + "itoa", + "libc", + "memmap2", + "rustix", + "smallvec", + "thiserror", +] + +[[package]] +name = "gix-lock" +version = "23.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09b3bc074e5723027b482dcd9ab99d95804a53742f6de812d0172fbba4a186c1" +dependencies = [ + "gix-tempfile", + "gix-utils", + "thiserror", +] + +[[package]] +name = "gix-mailmap" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "023d3a6561cbebe45b89e0764d48928ad970667076f16fa5889e6f86d8432086" +dependencies = [ + "bstr", + "gix-actor", + "gix-date", + "gix-error", +] + +[[package]] +name = "gix-merge" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74bbcdcc52b70a32f0a151b024dff9d0fcf56ee48f00d9503e735af9d99ea881" +dependencies = [ + "bstr", + "gix-command", + "gix-diff", + "gix-filter", + "gix-fs", + "gix-hash", + "gix-imara-diff", + "gix-index", + "gix-object", + "gix-path", + "gix-quote", + "gix-revision", + "gix-revwalk", + "gix-tempfile", + "gix-trace", + "gix-worktree", + "nonempty", + "thiserror", +] + +[[package]] +name = "gix-negotiate" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "103d42bfade1b8a96ca5005933127bdad461ce588d92422b2c2daa3ff20d780c" +dependencies = [ + "bitflags", + "gix-commitgraph", + "gix-date", + "gix-hash", + "gix-object", + "gix-revwalk", +] + +[[package]] +name = "gix-object" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a38075a95d7cc5df8afd38e72c617026c1456952207a4120a7f55a3fbf93b4d7" +dependencies = [ + "bstr", + "gix-actor", + "gix-date", + "gix-features", + "gix-hash", + "gix-hashtable", + "gix-utils", + "gix-validate", + "itoa", + "smallvec", + "thiserror", +] + +[[package]] +name = "gix-odb" +version = "0.80.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeeda12a9663120418735ecdc1250d06eeab0be75700e47b3402a981331716ba" +dependencies = [ + "arc-swap", + "gix-features", + "gix-fs", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-pack", + "gix-path", + "gix-quote", + "memmap2", + "parking_lot", + "tempfile", + "thiserror", +] + +[[package]] +name = "gix-pack" +version = "0.70.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf02e6f5c8f07a069c9ea5245f40d9b14856ada4086091dc99941b49002b4fa" +dependencies = [ + "clru", + "gix-chunk", + "gix-error", + "gix-features", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-path", + "memmap2", + "smallvec", + "thiserror", + "uluru", +] + +[[package]] +name = "gix-packetline" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "362246df440ee691699f0664cbf7006a6ece477db6734222be95e4198e5656e6" +dependencies = [ + "bstr", + "faster-hex", + "gix-trace", + "thiserror", +] + +[[package]] +name = "gix-path" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "671a6059e8a4c1b7f406e24716499cefa3926e060876fb1959ef225efeee346e" +dependencies = [ + "bstr", + "gix-trace", + "gix-validate", + "thiserror", +] + +[[package]] +name = "gix-pathspec" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a84a4f083dd70fb49f4377e13afa6d90df2daaa1c705c49d6ff1331fc7e8855" +dependencies = [ + "bitflags", + "bstr", + "gix-attributes", + "gix-config-value", + "gix-glob", + "gix-path", + "thiserror", +] + +[[package]] +name = "gix-prompt" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e041a626c64cb69e4117fcdf80da8d0e454fba3b1f420412792d191f52251aee" +dependencies = [ + "gix-command", + "gix-config-value", + "parking_lot", + "rustix", + "thiserror", +] + +[[package]] +name = "gix-protocol" +version = "0.61.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa4bee82db63ec635996b96efae71cf467c155fa3f34a556184373224a26c4fd" +dependencies = [ + "bstr", + "gix-date", + "gix-features", + "gix-hash", + "gix-ref", + "gix-shallow", + "gix-transport", + "gix-utils", + "maybe-async", + "nonempty", + "thiserror", +] + +[[package]] +name = "gix-quote" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e97b73791a64bc0fa7dd2c5b3e551136115f97750b876ed1c952c7a7dbaf8be" +dependencies = [ + "bstr", + "gix-error", + "gix-utils", +] + +[[package]] +name = "gix-ref" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8ba9cc15f558b274c99349b83130f5ec83459660828fde9718bbbb43a726167" +dependencies = [ + "gix-actor", + "gix-features", + "gix-fs", + "gix-hash", + "gix-lock", + "gix-object", + "gix-path", + "gix-tempfile", + "gix-utils", + "gix-validate", + "memmap2", + "thiserror", +] + +[[package]] +name = "gix-refspec" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61755b27d57edc8940a1b1593c8c61548ca8e4c02da1ed8d5bfeda9eb2a6b761" +dependencies = [ + "bstr", + "gix-error", + "gix-glob", + "gix-hash", + "gix-revision", + "gix-validate", + "smallvec", + "thiserror", +] + +[[package]] +name = "gix-revision" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fb5288fac706d3ea3e4e2ba9ec38b78743b8c02f422e18cb342299cfd6ab7e8" +dependencies = [ + "bitflags", + "bstr", + "gix-commitgraph", + "gix-date", + "gix-error", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-revwalk", + "gix-trace", + "nonempty", +] + +[[package]] +name = "gix-revwalk" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "313813706b073a12ff7f9b2896bf3e6504cdac7cfbc97b1920114724705069f0" +dependencies = [ + "gix-commitgraph", + "gix-date", + "gix-error", + "gix-hash", + "gix-hashtable", + "gix-object", + "smallvec", + "thiserror", +] + +[[package]] +name = "gix-sec" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5a3a2d3e504a238136751e646a6c028252286a0ea64ea9974bf0498633407c6" +dependencies = [ + "bitflags", + "gix-path", + "libc", + "windows-sys", +] + +[[package]] +name = "gix-shallow" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29187305521bfacf4aefd284ab28dbfa9fb74abd39a5e63dd313b1baa5808c27" +dependencies = [ + "bstr", + "gix-hash", + "gix-lock", + "nonempty", + "thiserror", +] + +[[package]] +name = "gix-status" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68c6d2a8c521ffa205fe7e268c82e6d1378ba37cd826ca10ab6129fdc29a4b65" +dependencies = [ + "bstr", + "filetime", + "gix-diff", + "gix-dir", + "gix-features", + "gix-filter", + "gix-fs", + "gix-hash", + "gix-index", + "gix-object", + "gix-path", + "gix-pathspec", + "gix-worktree", + "portable-atomic", + "thiserror", +] + +[[package]] +name = "gix-submodule" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fd5fc8692890bd71a596e540fd4c364f8460eaa82c4eaaedebde6e1e3eb4d91" +dependencies = [ + "bstr", + "gix-config", + "gix-path", + "gix-pathspec", + "gix-refspec", + "gix-url", + "thiserror", +] + +[[package]] +name = "gix-tempfile" +version = "23.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +checksum = "691ea1e31435c7e7d4d04705ec9d1c0d9482c46b2acf512bc723939d8f0af7fb" dependencies = [ - "litrs", + "dashmap", + "gix-fs", + "libc", + "parking_lot", + "signal-hook", + "signal-hook-registry", + "tempfile", ] [[package]] -name = "equivalent" -version = "1.0.2" +name = "gix-trace" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "6f23569e55f2ffaf958617353b9734a7d52a7c19c439eeaa5e3efc217fd2270e" [[package]] -name = "errno" -version = "0.3.14" +name = "gix-transport" +version = "0.57.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +checksum = "ffd6a5c676b92d4ead5f5a2b2935024415dec69edc997b6090ca9cac010a3018" dependencies = [ - "libc", - "windows-sys", + "bstr", + "gix-command", + "gix-features", + "gix-packetline", + "gix-quote", + "gix-sec", + "gix-url", + "thiserror", ] [[package]] -name = "fastrand" -version = "2.3.0" +name = "gix-traverse" +version = "0.57.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "a14b7052c0786676c03e71fcfde7d7f0f8e8316e642b5cec6bb3998719b2ce5c" +dependencies = [ + "bitflags", + "gix-commitgraph", + "gix-date", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-revwalk", + "smallvec", + "thiserror", +] [[package]] -name = "figment" -version = "0.10.19" +name = "gix-url" +version = "0.36.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" +checksum = "35842d099e813f6f6bba529e88d4670572149c3df79b7a412952259887721ece" dependencies = [ - "atomic", - "pear", - "serde", - "toml", - "uncased", - "version_check", + "bstr", + "gix-path", + "percent-encoding", + "thiserror", ] [[package]] -name = "find-msvc-tools" -version = "0.1.9" +name = "gix-utils" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "4e477b4f07a6e8da4ba791c53c858102959703c60d70f199932010d5b94adb2c" +dependencies = [ + "bstr", + "fastrand", + "unicode-normalization", +] [[package]] -name = "fixedbitset" -version = "0.5.7" +name = "gix-validate" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" +checksum = "e26ac2602b43eadfdca0560b81d3341944162a3c9f64ccdeef8fc501ad80dad5" +dependencies = [ + "bstr", +] [[package]] -name = "foldhash" -version = "0.1.5" +name = "gix-worktree" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +checksum = "d69955eb5e2910832f88d041964b809eee01dadd579237e0b55efec58fd406fd" +dependencies = [ + "bstr", + "gix-attributes", + "gix-fs", + "gix-glob", + "gix-hash", + "gix-ignore", + "gix-index", + "gix-object", + "gix-path", + "gix-validate", +] [[package]] -name = "getrandom" -version = "0.3.4" +name = "gix-worktree-state" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +checksum = "8a96dccbcf9e8fe0291c55f06e08da93ebb2e691c1311276f541eefcc6d70800" dependencies = [ - "cfg-if", - "libc", - "r-efi", - "wasip2", + "bstr", + "gix-features", + "gix-filter", + "gix-fs", + "gix-index", + "gix-object", + "gix-path", + "gix-worktree", + "io-close", + "thiserror", ] [[package]] -name = "getrandom" -version = "0.4.1" +name = "gix-worktree-stream" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +checksum = "9a8444b8ed4662e1a0c97f3eceda29630001a1bbb2632201e50312623e594213" dependencies = [ - "cfg-if", - "libc", - "r-efi", - "wasip2", - "wasip3", + "gix-attributes", + "gix-error", + "gix-features", + "gix-filter", + "gix-fs", + "gix-hash", + "gix-object", + "gix-path", + "gix-traverse", + "parking_lot", ] [[package]] @@ -402,13 +1485,28 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -416,6 +1514,21 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] [[package]] name = "heck" @@ -423,6 +1536,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "human_format" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaec953f16e5bcf6b8a3cb3aa959b17e5577dbd2693e94554c462c08be22624b" + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -487,6 +1606,16 @@ version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" +[[package]] +name = "io-close" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cadcf447f06744f8ce713d2d6239bb5bde2c357a452397a9ed90c625da390bc" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -499,6 +1628,47 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[package]] +name = "jiff" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f00b5dbd620d61dfdcb6007c9c1f6054ebd75319f163d886a9055cec1155073d" +dependencies = [ + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-sys", +] + +[[package]] +name = "jiff-static" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e000de030ff8022ea1da3f466fbb0f3a809f5e51ed31f6dd931c35181ad8e6d7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c900ef84826f1338a557697dc8fc601df9ca9af4ac137c7fb61d4c6f2dfd3076" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "js-sys" version = "0.3.89" @@ -509,6 +1679,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kstring" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "558bf9508a558512042d3095138b1f7b8fe90c5467d94f9f1da28b3731c5dbd1" +dependencies = [ + "static_assertions", +] + [[package]] name = "leb128fmt" version = "0.1.0" @@ -521,6 +1700,18 @@ version = "0.2.182" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.7.5", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -548,12 +1739,38 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "maybe-async" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cf92c10c7e361d6b99666ec1c6f9805b0bea2c3bd8c78dc6fe98ac5bd78db11" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "memchr" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memmap2" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", +] + +[[package]] +name = "nonempty" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6" + [[package]] name = "num-traits" version = "0.2.19" @@ -593,7 +1810,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link", ] @@ -621,6 +1838,12 @@ dependencies = [ "syn", ] +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "petgraph" version = "0.7.1" @@ -631,6 +1854,27 @@ dependencies = [ "indexmap", ] +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -672,6 +1916,17 @@ dependencies = [ "yansi", ] +[[package]] +name = "prodash" +version = "31.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "962200e2d7d551451297d9fdce85138374019ada198e30ea9ede38034e27604c" +dependencies = [ + "bytesize", + "human_format", + "parking_lot", +] + [[package]] name = "qualifier" version = "0.5.1" @@ -681,6 +1936,7 @@ dependencies = [ "clap", "comfy-table", "figment", + "gix", "ignore", "petgraph", "rand", @@ -744,6 +2000,27 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_syscall" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + [[package]] name = "regex-automata" version = "0.4.14" @@ -853,18 +2130,77 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha1-checked" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89f599ac0c323ebb1c6082821a54962b839832b03984598375bff3975b804423" +dependencies = [ + "digest", + "sha1", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a0c28ca5908dbdbcd52e6fdaa00358ab88637f8ab33e1f188dd510eb44b53d" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" @@ -915,6 +2251,21 @@ dependencies = [ "syn", ] +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "toml" version = "0.8.23" @@ -956,6 +2307,21 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "uluru" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c8a2469e56e6e5095c82ccd3afb98dad95f7af7929aab6d8ba8d6e0f73657da" +dependencies = [ + "arrayvec", +] + [[package]] name = "uncased" version = "0.9.10" @@ -965,12 +2331,27 @@ dependencies = [ "version_check", ] +[[package]] +name = "unicode-bom" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eec5d1121208364f6793f7d2e222bf75a915c19557537745b195b253dd64217" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-segmentation" version = "1.12.0" @@ -1330,6 +2711,12 @@ dependencies = [ "syn", ] +[[package]] +name = "zlib-rs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index d337fdc..02671c6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ serde = { version = "1", features = ["derive"] } [features] default = ["cli"] -cli = ["dep:clap", "dep:comfy-table", "dep:figment", "dep:rand"] +cli = ["dep:clap", "dep:comfy-table", "dep:figment", "dep:gix", "dep:rand"] [dependencies] blake3 = "1" @@ -30,6 +30,8 @@ thiserror = "2" clap = { version = "4", features = ["derive"], optional = true } comfy-table = { version = "7", optional = true } figment = { version = "0.10", features = ["toml", "env"], optional = true } +# gix powers `qualifier diff` (no fork/exec per .qual file at ). +gix = { version = "0.83", optional = true } rand = { version = "0.9", optional = true } [dev-dependencies] diff --git a/src/cli/commands/diff.rs b/src/cli/commands/diff.rs index 9681d46..84444bf 100644 --- a/src/cli/commands/diff.rs +++ b/src/cli/commands/diff.rs @@ -1,21 +1,8 @@ //! `qualifier diff ` — show records added, resolved, or drifted on this //! branch relative to a git ref. -//! -//! Compares the union of records at HEAD with the union of records at the -//! supplied ref (default `main`). "Active" means not superseded — a record -//! that disappears from the active set on this branch is reported as resolved -//! (or removed, if no successor exists). -//! -//! Drift is checked the same way `qualifier review` does: any active span -//! with a `content_hash` is rechecked against the current file content. -//! Drift on records that are *also* present in the ref counts (the -//! interesting case is "I touched the code under an old annotation"); -//! drift on freshly added records is suppressed since the user just authored -//! them. use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; -use std::process::Command; use clap::Args as ClapArgs; @@ -101,38 +88,44 @@ pub fn run(args: Args) -> crate::Result<()> { ) })?; - if !project_root.join(".git").exists() { - return Err(crate::Error::Validation( - "qualifier diff currently supports git only — no .git found at project root".into(), - )); - } + let repo = gix::open(&project_root).map_err(|e| { + crate::Error::Validation(format!( + "qualifier diff currently supports git only — could not open repository at {}: {e}", + project_root.display() + )) + })?; // Validate the ref exists up-front so the user gets a clean error rather - // than a smear of `git show` failures. - if !ref_exists(&project_root, &args.r#ref) { - return Err(crate::Error::Validation(format!( - "git ref '{}' not found", - args.r#ref - ))); - } + // than a smear of object-lookup failures. + let ref_oid = match repo.rev_parse_single(args.r#ref.as_str()) { + Ok(id) => id.detach(), + Err(_) => { + return Err(crate::Error::Validation(format!( + "git ref '{}' not found", + args.r#ref + ))); + } + }; - // Resolve the effective comparison point. Default is the merge-base of + // Resolve the effective comparison commit. Default is the merge-base of // HEAD with — this isolates what this branch introduced from // anything that landed on after the branch forked. `--from-tip` // opts back into the literal ref. - let effective_ref = if args.from_tip { - args.r#ref.clone() + let effective_oid: gix::ObjectId = if args.from_tip { + ref_oid } else { - match git_merge_base(&project_root, &args.r#ref, "HEAD") { - Some(base) => base, - None => { - // No common ancestor (orphan branches, fresh init): fall back - // to ref-tip and emit a hint so the user knows. + let head_oid = repo + .head_id() + .map_err(|e| crate::Error::Validation(format!("could not resolve HEAD: {e}")))? + .detach(); + match repo.merge_base(ref_oid, head_oid) { + Ok(base) => base.detach(), + Err(_) => { eprintln!( "qualifier diff: no merge-base between HEAD and '{}', comparing to ref tip", args.r#ref ); - args.r#ref.clone() + ref_oid } } }; @@ -143,14 +136,14 @@ pub fn run(args: Args) -> crate::Result<()> { .flat_map(|qf| qf.records.iter().cloned()) .collect(); - let old_records = load_records_at_ref(&project_root, &effective_ref, &new_qual_files)?; + let old_records = load_records_at_ref(&repo, effective_oid, &project_root, &new_qual_files)?; let mut diff = compute_diff(&old_records, &new_records, &project_root); apply_filters(&mut diff, &args)?; let header = DiffHeader { input_ref: args.r#ref.clone(), - base: effective_ref.clone(), + base: effective_oid.to_string(), from_tip: args.from_tip, }; @@ -166,7 +159,6 @@ pub fn run(args: Args) -> crate::Result<()> { Ok(()) } -/// Apply --kind / --issuer-type filters to all three diff buckets in place. fn apply_filters(diff: &mut Diff, args: &Args) -> crate::Result<()> { let kinds: Option> = args.kind.as_ref().map(|s| { s.split(',') @@ -203,7 +195,6 @@ fn apply_filters(diff: &mut Diff, args: &Args) -> crate::Result<()> { Ok(()) } -/// `--subjects-only`: dedup-sorted subject paths from all three buckets. fn print_subjects(diff: &Diff) { let mut subjects: Vec<&str> = diff .added @@ -276,60 +267,68 @@ fn enforce_fail_flags(args: &Args, diff: &Diff) -> crate::Result<()> { Ok(()) } -/// Resolve `git merge-base ` to a commit sha, or None if the two refs -/// share no common ancestor (or git fails). -fn git_merge_base(project_root: &Path, a: &str, b: &str) -> Option { - let output = Command::new("git") - .args(["merge-base", a, b]) - .current_dir(project_root) - .output() - .ok()?; - if !output.status.success() { - return None; - } - let s = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if s.is_empty() { None } else { Some(s) } -} - fn short_sha(s: &str) -> &str { if s.len() >= 7 { &s[..7] } else { s } } -/// Enumerate `.qual` paths at the ref via `git ls-tree`, plus the paths that -/// exist on the current working tree, and load records at `` for the -/// union. Paths that exist only at the ref (deleted on this branch) are -/// included so their records show up as resolved/removed. +/// Paths from the current working tree are unioned with paths at `` so +/// that `.qual` files deleted on this branch still surface their old records +/// (otherwise we'd never see records under Resolved/removed for them). fn load_records_at_ref( + repo: &gix::Repository, + commit_oid: gix::ObjectId, project_root: &Path, - git_ref: &str, new_qual_files: &[qual_file::QualFile], ) -> crate::Result> { - let mut paths: HashSet = HashSet::new(); + // Map relative-path -> blob oid for every .qual entry at . + let qual_blobs_at_ref = enumerate_qual_blobs(repo, commit_oid)?; + let mut paths: HashSet = HashSet::new(); for qf in new_qual_files { if let Ok(rel) = qf.path.strip_prefix(project_root) { paths.insert(rel.to_path_buf()); } } - for p in qual_paths_at_ref(project_root, git_ref)? { - paths.insert(p); + for path in qual_blobs_at_ref.keys() { + paths.insert(path.clone()); } let mut all = Vec::new(); for rel in paths { - let blob = match git_show(project_root, git_ref, &rel) { - Some(b) => b, - None => continue, // file did not exist at + let Some(blob_oid) = qual_blobs_at_ref.get(&rel) else { + continue; // file did not exist at }; - match qual_file::parse_str(&blob) { + let blob = match repo.find_object(*blob_oid) { + Ok(o) => o, + Err(e) => { + eprintln!( + "qualifier diff: cannot read blob for {} at {}: {e}", + rel.display(), + commit_oid + ); + continue; + } + }; + let data = &blob.data; + let s = match std::str::from_utf8(data) { + Ok(s) => s, + Err(_) => { + eprintln!( + "qualifier diff: skipping non-UTF8 blob for {} at {}", + rel.display(), + commit_oid + ); + continue; + } + }; + match qual_file::parse_str(s) { Ok(records) => all.extend(records), Err(e) => { - // Don't abort the diff for one malformed historical line; - // surface it as a hint and skip. + // Don't abort the diff for one malformed historical line. eprintln!( "qualifier diff: skipping {} at {}: {}", rel.display(), - git_ref, + commit_oid, e ); } @@ -338,51 +337,42 @@ fn load_records_at_ref( Ok(all) } -fn qual_paths_at_ref(project_root: &Path, git_ref: &str) -> crate::Result> { - let output = Command::new("git") - .args(["ls-tree", "-r", "--name-only", git_ref]) - .current_dir(project_root) - .output() - .map_err(|e| crate::Error::Validation(format!("git ls-tree failed: {e}")))?; - if !output.status.success() { - return Err(crate::Error::Validation(format!( - "git ls-tree failed: {}", - String::from_utf8_lossy(&output.stderr).trim() - ))); - } - let listing = String::from_utf8_lossy(&output.stdout); - let paths = listing - .lines() - .filter(|p| { - let path = Path::new(p); - path.extension().and_then(|e| e.to_str()) == Some("qual") - || path.file_name().and_then(|f| f.to_str()) == Some(".qual") - }) - .map(PathBuf::from) - .collect(); - Ok(paths) -} - -fn git_show(project_root: &Path, git_ref: &str, rel_path: &Path) -> Option { - let spec = format!("{git_ref}:{}", rel_path.display()); - let output = Command::new("git") - .args(["show", &spec]) - .current_dir(project_root) - .output() - .ok()?; - if !output.status.success() { - return None; +fn enumerate_qual_blobs( + repo: &gix::Repository, + commit_oid: gix::ObjectId, +) -> crate::Result> { + let commit = repo + .find_commit(commit_oid) + .map_err(|e| crate::Error::Validation(format!("could not read commit {commit_oid}: {e}")))?; + let tree = commit + .tree() + .map_err(|e| crate::Error::Validation(format!("could not read tree at {commit_oid}: {e}")))?; + + let mut recorder = gix::traverse::tree::Recorder::default(); + tree.traverse() + .breadthfirst(&mut recorder) + .map_err(|e| crate::Error::Validation(format!("tree traversal failed: {e}")))?; + + let mut out = HashMap::new(); + for entry in recorder.records { + if !entry.mode.is_blob() { + continue; + } + let bytes: &[u8] = entry.filepath.as_ref(); + let Ok(path_str) = std::str::from_utf8(bytes) else { + continue; + }; + let path = PathBuf::from(path_str); + if is_qual_path(&path) { + out.insert(path, entry.oid); + } } - Some(String::from_utf8_lossy(&output.stdout).into_owned()) + Ok(out) } -fn ref_exists(project_root: &Path, git_ref: &str) -> bool { - Command::new("git") - .args(["rev-parse", "--verify", "--quiet", git_ref]) - .current_dir(project_root) - .output() - .map(|o| o.status.success()) - .unwrap_or(false) +fn is_qual_path(path: &Path) -> bool { + path.extension().and_then(|e| e.to_str()) == Some("qual") + || path.file_name().and_then(|f| f.to_str()) == Some(".qual") } fn compute_diff(old: &[Record], new: &[Record], project_root: &Path) -> Diff { diff --git a/src/cli/commands/record.rs b/src/cli/commands/record.rs index 4c95099..89a8a23 100644 --- a/src/cli/commands/record.rs +++ b/src/cli/commands/record.rs @@ -301,9 +301,6 @@ fn run_batch(format: &str, continue_on_error: bool, dry_run: bool) -> crate::Res Ok(()) } -/// Apply parse → validate → supersession-check → append for one stdin line. -/// On success returns the canonicalized record; on failure returns a -/// human-readable message (no line-number prefix — the caller adds context). fn process_one(trimmed: &str, dry_run: bool) -> std::result::Result { let value: serde_json::Value = serde_json::from_str(trimmed).map_err(|e| format!("invalid JSON: {e}"))?; @@ -352,8 +349,8 @@ struct BatchError { } impl BatchError { - /// Produce the legacy single-error abort form. Includes the offending - /// line content so the user can see what they sent without re-piping. + /// Includes the offending line content so the user can see what they + /// sent without re-piping. fn into_error(self) -> crate::Error { let truncated = truncate_for_display(&self.input, 200); crate::Error::Validation(if truncated.is_empty() { @@ -373,8 +370,7 @@ fn truncate_for_display(s: &str, max: usize) -> String { } } -/// Emit a per-line error in the requested format. Always to stderr so -/// stdout (the success stream) stays clean. +/// Always to stderr so stdout (the success stream) stays clean. fn emit_batch_error(be: &BatchError, format: &str) { if format == "json" { let v = serde_json::json!({ @@ -393,13 +389,8 @@ fn emit_batch_error(be: &BatchError, format: &str) { } } -/// Emit one stdout line per recorded batch entry. -/// -/// `human`: a compact summary (kind, location[+span], summary, id-prefix). -/// `json`: the full canonical record as a single JSONL line. -/// -/// Under `--dry-run`, the human verb becomes "would-record" so the user -/// can tell at a glance that nothing was written. +/// Under `--dry-run`, the human verb is "would-record" so a glance +/// confirms nothing was committed. fn emit_batch_line(record: &Record, format: &str, dry_run: bool) -> crate::Result<()> { if format == "json" { let mut v = serde_json::to_value(record)?; From 9dd1c242bc3a460b89808eaf5f7d257dd96ef9d6 Mon Sep 17 00:00:00 2001 From: Alex Kesling Date: Thu, 7 May 2026 00:39:19 -0400 Subject: [PATCH 5/7] docs: module-level diff/run docs + fix rustdoc HTML warnings Module doc on `cli::commands::diff` describes the three-bucket semantics (Added, Resolved, Drifted) and the two non-obvious nuances rustdoc readers would otherwise miss: the resolve-kind dedupe in Added, and that drift on freshly added records is suppressed. Inline `` and `` references in arg doc-comments wrapped in backticks so rustdoc stops parsing them as HTML tags. --- src/cli/commands/diff.rs | 30 ++++++++++++++++++++++++++---- src/cli/commands/record.rs | 2 +- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/cli/commands/diff.rs b/src/cli/commands/diff.rs index 84444bf..0fc67e4 100644 --- a/src/cli/commands/diff.rs +++ b/src/cli/commands/diff.rs @@ -1,5 +1,25 @@ -//! `qualifier diff ` — show records added, resolved, or drifted on this -//! branch relative to a git ref. +//! `qualifier diff ` — what changed in the annotation set on this +//! branch. +//! +//! Compares records on `HEAD` against records at a git ref (default `main`, +//! resolved via merge-base unless `--from-tip`). Output is grouped into +//! three buckets, all reckoned by record `id`: +//! +//! - **Added** — records active on `HEAD` whose id is not in ``. +//! Annotations only; resolve-kind records are filtered to avoid +//! double-counting with the closer in *Resolved*. +//! - **Resolved** — records active at `` that are no longer active on +//! `HEAD`, with the closer (the head-side record whose `supersedes` +//! points at it) named when one exists, or `removed` if not. +//! - **Drifted** — records present at *both* refs whose +//! `body.span.content_hash` no longer matches the file's current +//! content. Drift on records freshly added on this branch is suppressed. +//! +//! Both human and JSON output are stable; CI gating uses `--fail-on +//! ` and `--fail-on-drift`. +//! +//! Backed by [`gix`] in-process — no subprocess spawn per `.qual` file +//! at the ref. use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; @@ -22,9 +42,9 @@ pub struct Args { #[arg(long, default_value = "human")] pub format: String, - /// Compare against the tip of rather than its merge-base with HEAD. + /// Compare against the tip of `` rather than its merge-base with HEAD. /// The default (merge-base) matches what a PR introduces — records that - /// landed on after this branch forked are treated as "old", not + /// landed on `` after this branch forked are treated as "old", not /// "added". #[arg(long)] pub from_tip: bool, @@ -77,6 +97,8 @@ struct DriftEntry { actual: String, } +/// `--fail-on*` errors are returned *after* the diff body has been printed +/// to stdout — the build log shows what triggered the failure. pub fn run(args: Args) -> crate::Result<()> { // Resolve from an absolute CWD so the upward walk in find_project_root // works from any subdirectory — relative-path arithmetic on `.` doesn't diff --git a/src/cli/commands/record.rs b/src/cli/commands/record.rs index 89a8a23..1e4cad3 100644 --- a/src/cli/commands/record.rs +++ b/src/cli/commands/record.rs @@ -48,7 +48,7 @@ pub struct Args { pub r#ref: Option, /// Span override (e.g., "42", "42:58", "42.5:58.80"). When provided, - /// overrides any span parsed from . + /// overrides any span parsed from ``. #[arg(long)] pub span: Option, From 3e6cdeb5b02d6e52a28c400fc1f92bda02d997de Mon Sep 17 00:00:00 2001 From: Alex Kesling Date: Thu, 7 May 2026 10:10:47 -0400 Subject: [PATCH 6/7] feat(diff): wrap human output to 80 cols (or \$COLUMNS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Long paths and long summaries previously rendered diff rows at 100-200 chars wide, unreadable on a standard 80-col terminal. Each row now tries a single-line form (`marker KIND LOC SUMMARY (ID)`); if it overflows the width budget, the summary plus any extras (closer line for Resolved, span snippet for Drifted) move to indented continuations below an unbroken header line. Continuations are themselves truncated with `…` if they would overflow. Width is read from \$COLUMNS, defaulting to 80. The Drifted bucket no longer prints a redundant "original:" prefix — the summary on the header line covers it. Locked in by a regression test that constructs a worst-case row (deeply-nested path + 130-char summary + drifted span) and asserts no rendered line exceeds 80 chars. --- src/cli/commands/diff.rs | 200 +++++++++++++++++++++++---------- src/cli/commands/record.rs | 19 +++- tests/cli_integration.rs | 222 +++++++++++++++++++++++++++++++------ 3 files changed, 340 insertions(+), 101 deletions(-) diff --git a/src/cli/commands/diff.rs b/src/cli/commands/diff.rs index 0fc67e4..87c69f6 100644 --- a/src/cli/commands/diff.rs +++ b/src/cli/commands/diff.rs @@ -189,7 +189,10 @@ fn apply_filters(diff: &mut Diff, args: &Args) -> crate::Result<()> { .collect() }); let issuer_type = match &args.issuer_type { - Some(s) => Some(s.parse::().map_err(crate::Error::Validation)?), + Some(s) => Some( + s.parse::() + .map_err(crate::Error::Validation)?, + ), None => None, }; @@ -268,7 +271,11 @@ fn enforce_fail_flags(args: &Args, diff: &Diff) -> crate::Result<()> { ))); } if let Some(ref list) = args.fail_on { - let kinds: Vec<&str> = list.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect(); + let kinds: Vec<&str> = list + .split(',') + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .collect(); let matched: Vec<&Record> = diff .added .iter() @@ -363,12 +370,12 @@ fn enumerate_qual_blobs( repo: &gix::Repository, commit_oid: gix::ObjectId, ) -> crate::Result> { - let commit = repo - .find_commit(commit_oid) - .map_err(|e| crate::Error::Validation(format!("could not read commit {commit_oid}: {e}")))?; - let tree = commit - .tree() - .map_err(|e| crate::Error::Validation(format!("could not read tree at {commit_oid}: {e}")))?; + let commit = repo.find_commit(commit_oid).map_err(|e| { + crate::Error::Validation(format!("could not read commit {commit_oid}: {e}")) + })?; + let tree = commit.tree().map_err(|e| { + crate::Error::Validation(format!("could not read tree at {commit_oid}: {e}")) + })?; let mut recorder = gix::traverse::tree::Recorder::default(); tree.traverse() @@ -520,85 +527,68 @@ fn print_human(header: &DiffHeader, diff: &Diff, project_root: &Path) { } fn print_added(r: &Record) { - let att = match r.as_annotation() { - Some(a) => a, - None => return, - }; - let id_short = id_prefix(&att.id); - let loc = format_location(att); - println!( - " + {:<10} {:<32} {} ({})", - att.body.kind.to_string(), - loc, - att.body.summary, - id_short + let Some(att) = r.as_annotation() else { return }; + print_record_row( + '+', + &att.body.kind.to_string(), + &format_location(att), + &att.body.summary, + id_prefix(&att.id), + &[], ); } fn print_resolved(entry: &ResolvedEntry) { - let id_short = id_prefix(entry.old.id()); - let loc = entry - .old - .as_annotation() - .map(format_location) - .unwrap_or_else(|| entry.old.subject().to_string()); let kind = entry .old .kind() .map(|k| k.to_string()) .unwrap_or_else(|| entry.old.record_type().to_string()); + let loc = entry + .old + .as_annotation() + .map(format_location) + .unwrap_or_else(|| entry.old.subject().to_string()); let summary = entry .old .as_annotation() - .map(|a| a.body.summary.clone()) - .unwrap_or_default(); + .map(|a| a.body.summary.as_str()) + .unwrap_or(""); - let suffix = match &entry.closer { + let closer_line = match &entry.closer { Some(c) => { - let closer_kind = c.kind().map(|k| k.to_string()).unwrap_or_default(); - let closer_id = id_prefix(c.id()); - let closer_summary = c - .as_annotation() - .map(|a| a.body.summary.as_str()) - .unwrap_or(""); - let verb = if closer_kind == Kind::Resolve.to_string() { + let verb = if c.kind() == Some(&Kind::Resolve) { "resolved by" } else { "superseded by" }; - if closer_summary.is_empty() { - format!(" — {verb} {closer_id}") - } else { - format!(" — {verb} {closer_id}: {closer_summary:?}") + let closer_id = id_prefix(c.id()); + match c.as_annotation().map(|a| a.body.summary.as_str()) { + Some(s) if !s.is_empty() => format!("{verb} {closer_id}: {s:?}"), + _ => format!("{verb} {closer_id}"), } } - None => " — removed (no successor)".into(), + None => "removed (no successor)".into(), }; - println!( - " - {:<10} {:<32} {} ({}){}", - kind, loc, summary, id_short, suffix + + print_record_row( + '-', + &kind, + &loc, + summary, + id_prefix(entry.old.id()), + &[closer_line], ); } fn print_drifted(entry: &DriftEntry, project_root: &Path) { - let att = match entry.record.as_annotation() { - Some(a) => a, - None => return, + let Some(att) = entry.record.as_annotation() else { + return; }; let id_short = id_prefix(&att.id); let loc = format_location(att); - println!( - " ~ {:<10} {:<32} span content drifted ({})", - att.body.kind.to_string(), - loc, - id_short, - ); - if !att.body.summary.is_empty() { - println!(" original: {:?}", att.body.summary); - } - // Show the current content of the span — what the recorded hash no - // longer matches. The original content lives at and can be - // recovered with `git show :`. + + let mut continuations: Vec = Vec::new(); if let Some(ref span) = att.body.span { let ctx = span_context::read_span_context( &project_root.join(&att.subject), @@ -607,15 +597,105 @@ fn print_drifted(entry: &DriftEntry, project_root: &Path) { ); let formatted = span_context::format_human(&ctx); for line in formatted.lines() { - println!(" {line}"); + continuations.push(line.to_string()); } } + + print_record_row( + '~', + &att.body.kind.to_string(), + &loc, + &att.body.summary, + id_short, + &continuations, + ); +} + +/// Render one diff row, wrapping to the terminal width. Columns are kept +/// aligned (`marker KIND LOC SUMMARY (ID)`) when the whole line fits; +/// otherwise the summary and any extra continuations move to indented +/// follow-up lines so the header (KIND + LOC + ID) stays on one line. +/// +/// Width is read from `$COLUMNS`, defaulting to 80 when unset. +fn print_record_row( + marker: char, + kind: &str, + location: &str, + summary: &str, + id_short: &str, + extras: &[String], +) { + const KIND_WIDTH: usize = 10; + const HEADER_INDENT: &str = " "; + const CONTINUATION_INDENT: &str = " "; + let width = term_width(); + + let id_chunk = format!("({id_short})"); + let single = if summary.is_empty() { + format!("{HEADER_INDENT}{marker} {kind: &str { if id.len() >= 8 { &id[..8] } else { id } } +/// Effective terminal width. Reads `$COLUMNS` (set by most shells when stdout +/// is a TTY); falls back to 80 columns when unset, malformed, or zero. +fn term_width() -> usize { + std::env::var("COLUMNS") + .ok() + .and_then(|s| s.parse().ok()) + .filter(|&n: &usize| n > 0) + .unwrap_or(80) +} + +/// Display width, counted in chars (good enough for ASCII paths and English +/// summaries; non-ASCII may render slightly off in terminals that disagree +/// with us about grapheme width, but never produces output longer than this). +fn display_width(s: &str) -> usize { + s.chars().count() +} + +fn truncate_to_width(s: &str, max: usize) -> String { + if display_width(s) <= max { + return s.to_string(); + } + if max == 0 { + return String::new(); + } + let take = max - 1; + let mut out: String = s.chars().take(take).collect(); + out.push('…'); + out +} + fn format_location(att: &crate::annotation::Annotation) -> String { match &att.body.span { Some(span) => { diff --git a/src/cli/commands/record.rs b/src/cli/commands/record.rs index 1e4cad3..a5799da 100644 --- a/src/cli/commands/record.rs +++ b/src/cli/commands/record.rs @@ -260,7 +260,11 @@ fn run_batch(format: &str, continue_on_error: bool, dry_run: bool) -> crate::Res } let total = recorded + errors.len(); - let suffix = if dry_run { " (dry run, nothing written)" } else { "" }; + let suffix = if dry_run { + " (dry run, nothing written)" + } else { + "" + }; if format == "json" { // Trailer summary as JSON so consumers parsing stderr line-by-line // see a structured terminator rather than a free-form English line. @@ -320,7 +324,8 @@ fn process_one(trimmed: &str, dry_run: bool) -> std::result::Result crate::Result<()> { if format == "json" { let mut v = serde_json::to_value(record)?; - if dry_run - && let Some(obj) = v.as_object_mut() - { + if dry_run && let Some(obj) = v.as_object_mut() { obj.insert("dry_run".into(), serde_json::Value::Bool(true)); } println!("{}", serde_json::to_string(&v)?); return Ok(()); } - let verb = if dry_run { "would-record" } else { "recorded " }; + let verb = if dry_run { + "would-record" + } else { + "recorded " + }; let id = record.id(); let id_short = if id.len() >= 8 { &id[..8] } else { id }; if let Some(att) = record.as_annotation() { diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index 62c189d..1c2326e 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -2491,11 +2491,14 @@ fn test_record_stdin_emits_per_record_human_output() { #[test] fn test_record_stdin_json_format_emits_records() { let dir = tempfile::tempdir().unwrap(); - let input = - r#"{"kind":"pass","location":"x.rs","message":"ok","issuer":"mailto:a@b.com"}"#.to_string() - + "\n"; - let (stdout, _stderr, code) = - run_qualifier_stdin(dir.path(), &["record", "--stdin", "--format", "json"], &input); + let input = r#"{"kind":"pass","location":"x.rs","message":"ok","issuer":"mailto:a@b.com"}"# + .to_string() + + "\n"; + let (stdout, _stderr, code) = run_qualifier_stdin( + dir.path(), + &["record", "--stdin", "--format", "json"], + &input, + ); assert_eq!(code, 0); // Each stdout line should be a valid JSON record. @@ -2616,8 +2619,11 @@ fn test_diff_added_resolved_drifted() { // but a *fresh* concern at a span that pinned a hash on the post-mutation // file content is also recorded so we can check the drift category by // mutating after that pin. - std::fs::write(dir.path().join("main.rs"), "fn alpha() {}\nfn beta() { /* changed */ }\n") - .unwrap(); + std::fs::write( + dir.path().join("main.rs"), + "fn alpha() {}\nfn beta() { /* changed */ }\n", + ) + .unwrap(); let (stdout, stderr, code) = run_qualifier(dir.path(), &["diff", "main"]); assert_eq!(code, 0, "diff should succeed: stderr={stderr}"); @@ -2652,14 +2658,7 @@ fn test_diff_no_changes() { std::fs::write(dir.path().join("a.rs"), "fn a() {}\n").unwrap(); let (_, _, code) = run_qualifier( dir.path(), - &[ - "record", - "pass", - "a.rs", - "ok", - "--issuer", - "mailto:a@b.com", - ], + &["record", "pass", "a.rs", "ok", "--issuer", "mailto:a@b.com"], ); assert_eq!(code, 0); git_commit_all(dir.path(), "baseline"); @@ -2797,7 +2796,13 @@ fn test_record_stdin_dry_run_writes_nothing() { "#; let (stdout, stderr, code) = run_qualifier_stdin( dir.path(), - &["record", "--stdin", "--dry-run", "--issuer", "mailto:a@b.com"], + &[ + "record", + "--stdin", + "--dry-run", + "--issuer", + "mailto:a@b.com", + ], input, ); assert_eq!(code, 0); @@ -2824,7 +2829,13 @@ fn test_record_stdin_dry_run_still_validates() { "#; let (_, stderr, code) = run_qualifier_stdin( dir.path(), - &["record", "--stdin", "--dry-run", "--issuer", "mailto:a@b.com"], + &[ + "record", + "--stdin", + "--dry-run", + "--issuer", + "mailto:a@b.com", + ], input, ); assert_ne!(code, 0, "dry-run must still report validation errors"); @@ -3087,15 +3098,17 @@ fn test_diff_fail_on_multiple_kinds() { let (_, _, code) = run_qualifier( dir.path(), &[ - "record", "fail", "x.rs", "broke", "--issuer", "mailto:a@b.com", + "record", + "fail", + "x.rs", + "broke", + "--issuer", + "mailto:a@b.com", ], ); assert_eq!(code, 0); - let (_, _, code) = run_qualifier( - dir.path(), - &["diff", "main", "--fail-on", "blocker,fail"], - ); + let (_, _, code) = run_qualifier(dir.path(), &["diff", "main", "--fail-on", "blocker,fail"]); assert_ne!(code, 0, "comma-separated list should match `fail` records"); } @@ -3107,7 +3120,12 @@ fn test_diff_fail_on_drift() { let (_, _, code) = run_qualifier( dir.path(), &[ - "record", "concern", "m.rs:2", "look", "--issuer", "mailto:a@b.com", + "record", + "concern", + "m.rs:2", + "look", + "--issuer", + "mailto:a@b.com", ], ); assert_eq!(code, 0); @@ -3147,13 +3165,23 @@ fn test_diff_kind_filter() { let (_, _, _) = run_qualifier( dir.path(), &[ - "record", "concern", "a.rs", "concern1", "--issuer", "mailto:a@b.com", + "record", + "concern", + "a.rs", + "concern1", + "--issuer", + "mailto:a@b.com", ], ); let (_, _, _) = run_qualifier( dir.path(), &[ - "record", "blocker", "a.rs", "blocker1", "--issuer", "mailto:a@b.com", + "record", + "blocker", + "a.rs", + "blocker1", + "--issuer", + "mailto:a@b.com", ], ); let (_, _, _) = run_qualifier( @@ -3170,7 +3198,10 @@ fn test_diff_kind_filter() { let (stdout, _, code) = run_qualifier(dir.path(), &["diff", "main", "--kind", "concern"]); assert_eq!(code, 0); - assert!(stdout.contains("concern1"), "concern should appear: {stdout}"); + assert!( + stdout.contains("concern1"), + "concern should appear: {stdout}" + ); assert!( !stdout.contains("blocker1"), "blocker should be filtered out: {stdout}" @@ -3227,7 +3258,10 @@ fn test_diff_issuer_type_filter() { ); let (stdout, _, _) = run_qualifier(dir.path(), &["diff", "main", "--issuer-type", "ai"]); - assert!(stdout.contains("from-ai"), "ai record should appear: {stdout}"); + assert!( + stdout.contains("from-ai"), + "ai record should appear: {stdout}" + ); assert!( !stdout.contains("from-human"), "human record should be filtered: {stdout}" @@ -3266,7 +3300,11 @@ fn test_diff_subjects_only() { let (stdout, _, code) = run_qualifier(dir.path(), &["diff", "main", "--subjects-only"]); assert_eq!(code, 0); let lines: Vec<&str> = stdout.lines().collect(); - assert_eq!(lines, vec!["a.rs", "b.rs"], "should be deduped + sorted: {stdout}"); + assert_eq!( + lines, + vec!["a.rs", "b.rs"], + "should be deduped + sorted: {stdout}" + ); } // --- diff: human output polish --- @@ -3279,7 +3317,12 @@ fn test_diff_resolved_inlines_closer_summary() { let (_, _, code) = run_qualifier( dir.path(), &[ - "record", "concern", "x.rs:1", "needs work", "--issuer", "mailto:a@b.com", + "record", + "concern", + "x.rs:1", + "needs work", + "--issuer", + "mailto:a@b.com", ], ); assert_eq!(code, 0); @@ -3292,7 +3335,11 @@ fn test_diff_resolved_inlines_closer_summary() { let (_, _, code) = run_qualifier( dir.path(), &[ - "resolve", "x.rs:1", "fixed in PR #42", "--issuer", "mailto:a@b.com", + "resolve", + "x.rs:1", + "fixed in PR #42", + "--issuer", + "mailto:a@b.com", ], ); assert_eq!(code, 0); @@ -3313,12 +3360,20 @@ fn test_diff_resolved_inlines_closer_summary() { fn test_diff_drift_includes_span_snippet() { let dir = tempfile::tempdir().unwrap(); git_init(dir.path()); - std::fs::write(dir.path().join("m.rs"), "fn alpha() {}\nfn beta() {}\nfn gamma() {}\n") - .unwrap(); + std::fs::write( + dir.path().join("m.rs"), + "fn alpha() {}\nfn beta() {}\nfn gamma() {}\n", + ) + .unwrap(); let (_, _, code) = run_qualifier( dir.path(), &[ - "record", "concern", "m.rs:2", "watch beta", "--issuer", "mailto:a@b.com", + "record", + "concern", + "m.rs:2", + "watch beta", + "--issuer", + "mailto:a@b.com", ], ); assert_eq!(code, 0); @@ -3345,7 +3400,99 @@ fn test_diff_drift_includes_span_snippet() { "should print current span content as a snippet: {stdout}" ); // Compiler-style marker for the drifted line. - assert!(stdout.contains("> 2"), "snippet should mark line 2: {stdout}"); + assert!( + stdout.contains("> 2"), + "snippet should mark line 2: {stdout}" + ); +} + +// --- diff: 80-col friendliness --- + +/// Run qualifier with `COLUMNS` set to override stdout width detection. +fn run_qualifier_with_columns(dir: &Path, args: &[&str], columns: usize) -> (String, String, i32) { + let output = Command::new(qualifier_bin()) + .args(args) + .current_dir(dir) + .env("COLUMNS", columns.to_string()) + .output() + .expect("failed to run qualifier binary"); + ( + String::from_utf8_lossy(&output.stdout).into_owned(), + String::from_utf8_lossy(&output.stderr).into_owned(), + output.status.code().unwrap_or(-1), + ) +} + +#[test] +fn test_diff_human_output_fits_80_cols() { + let dir = tempfile::tempdir().unwrap(); + git_init(dir.path()); + + // Create a deeply-nested path and a long summary — the worst case for + // line-width budgeting. + let nested = dir.path().join("src/cli/commands/agents/pages"); + std::fs::create_dir_all(&nested).unwrap(); + std::fs::write(nested.join("record.md"), "line one\nline two\nline three\n").unwrap(); + let long_summary = "AnnotationBody field declaration order silently determines MCF canonical IDs — \ + make the invariant load-bearing in code or in a doc-test"; + let (_, _, code) = run_qualifier( + dir.path(), + &[ + "record", + "suggestion", + "src/cli/commands/agents/pages/record.md:2", + long_summary, + "--issuer", + "mailto:a@b.com", + ], + ); + assert_eq!(code, 0); + git_commit_all(dir.path(), "baseline"); + Command::new("git") + .args(["checkout", "-q", "-b", "feat"]) + .current_dir(dir.path()) + .status() + .unwrap(); + + // A second record on the feature branch with a long summary too — this + // ends up under Added. + let (_, _, code) = run_qualifier( + dir.path(), + &[ + "record", + "concern", + "src/cli/commands/agents/pages/record.md:1", + "this is a deliberately long summary intended to overflow the single-line budget on \ + any narrow terminal so the wrapping path is exercised", + "--issuer", + "mailto:a@b.com", + ], + ); + assert_eq!(code, 0); + + // Mutate the spanned line to cause drift on the baseline record; this + // exercises the Drifted bucket with a snippet, the longest path so far. + std::fs::write( + nested.join("record.md"), + "line one\nDIFFERENT line two\nline three\n", + ) + .unwrap(); + + let (stdout, _, code) = run_qualifier_with_columns(dir.path(), &["diff", "main"], 80); + assert_eq!(code, 0); + + for line in stdout.lines() { + let chars = line.chars().count(); + assert!( + chars <= 80, + "line exceeds 80-col budget ({chars} chars): {line:?}" + ); + } + + // Sanity: the summary still appears (truncated or wrapped, but present + // enough that a reviewer can identify the record). + assert!(stdout.contains("AnnotationBody field declaration order")); + assert!(stdout.contains("deliberately long summary")); } // --- diff: JSON shape stability --- @@ -3367,7 +3514,12 @@ fn test_diff_json_includes_base_and_from_tip() { let (_, _, _) = run_qualifier( dir.path(), &[ - "record", "concern", "x.rs", "y", "--issuer", "mailto:a@b.com", + "record", + "concern", + "x.rs", + "y", + "--issuer", + "mailto:a@b.com", ], ); From 486a3a1057ddfed5db0ecc02ac745c52bddcaccd Mon Sep 17 00:00:00 2001 From: Alex Kesling Date: Thu, 7 May 2026 14:03:02 -0400 Subject: [PATCH 7/7] ci: bump site-build Node to 22 so pnpm 11 can import node:sqlite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pnpm@latest (11.x) requires Node ≥22.5; the site job was pinned at 20 and started failing with `ERR_UNKNOWN_BUILTIN_MODULE: node:sqlite`. Bumping to the active LTS unblocks the build. --- .github/workflows/deploy-site.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index 6077123..c2c9497 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -75,7 +75,9 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 20 + # pnpm 11 imports `node:sqlite`, available from Node 22.5+; pinned + # at 22 to track the active LTS. + node-version: 22 cache: pnpm cache-dependency-path: site/pnpm-lock.yaml