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 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/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/.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"]}} diff --git a/src/cli/commands/agents/pages/diff.md b/src/cli/commands/agents/pages/diff.md new file mode 100644 index 0000000..5ae7e0e --- /dev/null +++ b/src/cli/commands/agents/pages/diff.md @@ -0,0 +1,143 @@ ++++ +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). + +## 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 does this branch propose to merge? +qualifier diff + +# Compare against an upstream branch (uses merge-base of HEAD with origin/main) +qualifier diff origin/main + +# 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 + +# 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: "constant-time compare landed in PR #142" + +Drifted (1) + ~ 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) + +```json +{ + "ref": "main", + "base": "", + "from_tip": false, + "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. +- 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. +- 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..312a805 100644 --- a/src/cli/commands/agents/pages/record.md +++ b/src/cli/commands/agents/pages/record.md @@ -60,10 +60,75 @@ 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 goes to + **stderr** so a `--format json` pipe stays clean. +- 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 new file mode 100644 index 0000000..87c69f6 --- /dev/null +++ b/src/cli/commands/diff.rs @@ -0,0 +1,744 @@ +//! `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}; + +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; + +#[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, + + /// 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)] + 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, +} + +/// `--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 + // 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(), + ) + })?; + + 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 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 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_oid: gix::ObjectId = if args.from_tip { + ref_oid + } else { + 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 + ); + ref_oid + } + } + }; + + 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(&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_oid.to_string(), + from_tip: args.from_tip, + }; + + if args.subjects_only { + print_subjects(&diff); + } else if args.format == "json" { + print_json(&header, &diff); + } else { + print_human(&header, &diff, &project_root); + } + + enforce_fail_flags(&args, &diff)?; + Ok(()) +} + +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(()) +} + +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(()) +} + +fn short_sha(s: &str) -> &str { + if s.len() >= 7 { &s[..7] } else { s } +} + +/// 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, + new_qual_files: &[qual_file::QualFile], +) -> crate::Result> { + // 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 path in qual_blobs_at_ref.keys() { + paths.insert(path.clone()); + } + + let mut all = Vec::new(); + for rel in paths { + let Some(blob_oid) = qual_blobs_at_ref.get(&rel) else { + continue; // file did not exist at + }; + 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. + eprintln!( + "qualifier diff: skipping {} at {}: {}", + rel.display(), + commit_oid, + e + ); + } + } + } + Ok(all) +} + +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); + } + } + Ok(out) +} + +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 { + 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(header: &DiffHeader, diff: &Diff, project_root: &Path) { + if diff.added.is_empty() && diff.resolved.is_empty() && diff.drifted.is_empty() { + 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()); + 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, project_root); + } + } + println!(); +} + +fn print_added(r: &Record) { + 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 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.as_str()) + .unwrap_or(""); + + let closer_line = match &entry.closer { + Some(c) => { + let verb = if c.kind() == Some(&Kind::Resolve) { + "resolved by" + } else { + "superseded by" + }; + 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(), + }; + + print_record_row( + '-', + &kind, + &loc, + summary, + id_prefix(entry.old.id()), + &[closer_line], + ); +} + +fn print_drifted(entry: &DriftEntry, project_root: &Path) { + let Some(att) = entry.record.as_annotation() else { + return; + }; + let id_short = id_prefix(&att.id); + let loc = format_location(att); + + 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), + span, + span_context::DEFAULT_CONTEXT_LINES, + ); + let formatted = span_context::format_human(&ctx); + for line in formatted.lines() { + 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) => { + 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(header: &DiffHeader, 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": header.input_ref, + "base": header.base, + "from_tip": header.from_tip, + "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..a5799da 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, @@ -64,19 +64,44 @@ 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 + /// 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, - /// Output format (human, json). + /// 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(); + return run_batch(&args.format, args.continue_on_error, args.dry_run); } let kind_str = args.kind.as_deref().ok_or_else(|| { @@ -185,58 +210,236 @@ pub fn run(args: Args) -> crate::Result<()> { Ok(()) } -fn run_batch() -> crate::Result<()> { +fn run_batch(format: &str, continue_on_error: bool, dry_run: bool) -> crate::Result<()> { let stdin = io::stdin(); - let mut count = 0; - - for line in stdin.lock().lines() { - let line = line?; - let trimmed = line.trim(); + 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 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)?; + 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()); + } + } + } - 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)?; - annotation::finalize_record(r) + 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(()) +} + +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 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())?; + + if record.supersedes().is_some() { + let existing = if qual_path.exists() { + qual_file::parse(&qual_path) + .map_err(|e| e.to_string())? + .records } else { - // Overrides object — build an annotation. - build_record_from_overrides(value)? + 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())?; + } - // 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("; "))); - } - } + if !dry_run { + qual_file::append(&qual_path, &record).map_err(|e| e.to_string())?; + } + Ok(record) +} - let qual_path = qual_file::resolve_qual_path(record.subject(), None)?; +struct BatchError { + line: usize, + error: String, + input: String, +} - if record.supersedes().is_some() { - let existing = if qual_path.exists() { - qual_file::parse(&qual_path)?.records - } else { - Vec::new() - }; - let mut all = existing; - all.push(record.clone()); - annotation::check_supersession_cycles(&all)?; - annotation::validate_supersession_targets(&all)?; +impl BatchError { + /// 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}...") + } +} + +/// 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); } + } +} - qual_file::append(&qual_path, &record)?; - count += 1; +/// 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)?; + 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(()); } - println!("Recorded {count} records from stdin"); + 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() { + 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!( + "{verb} {:<10} {}{} {} id: {}", + att.body.kind.to_string(), + att.subject, + span_str, + att.body.summary, + id_short, + ); + } else { + println!( + "{verb} {:<10} {} id: {}", + record.record_type(), + record.subject(), + id_short, + ); + } Ok(()) } 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..1c2326e 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -2419,6 +2419,1120 @@ 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 of 2"), + "summary should be on stderr: {stderr}" + ); + assert!( + !stdout.contains("Recorded 2"), + "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()); +} + +// --- 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: 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 --- + +#[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();