From d7e08311589363e62c772cbad0d8d312b2c90ce0 Mon Sep 17 00:00:00 2001 From: Peter Krenesky Date: Sat, 7 Nov 2026 21:47:12 -0800 Subject: [PATCH 1/2] fix(trace,coverage): share engine trace-id fold; scoped module discovery in coverage - PLAT-898: adopt quire-rs 0.53.0 and use its exported trace_id_segments; delete the CLI's duplicate id_segments fold. - PLAT-908: document the --prefix / --id separator-count divergence, pin it with a known-divergence test, and make matched_ids cover every id the payload returns. - PLAT-179: coverage (and trace/symbols via load_registry_for) now use the same scoped discovery as validate, including /.ix/modules. - validate: handle the new ValidationReason::Regex in validation_remedy. Co-Authored-By: Claude Sonnet 5.5 --- Cargo.lock | 266 ++++++++++++++++++++++++++++++++++++--- Cargo.toml | 2 +- src/commands/coverage.rs | 7 +- src/commands/trace.rs | 112 ++++++++--------- src/commands/validate.rs | 11 ++ tests/cli_coverage.rs | 74 +++++++++++ tests/cli_trace.rs | 99 +++++++++++---- 7 files changed, 461 insertions(+), 110 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a6b3273..62245c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5,7 +5,7 @@ version = 4 [[package]] name = "agent-ix-semantic-schema" version = "0.3.0" -source = "git+https://github.com/agent-ix/filament-core-data?branch=main#df5e55796db57a62e5b5984c8ec60d70049cebe0" +source = "git+https://github.com/agent-ix/filament-core-data?branch=main#45a927b258c297159567e4be6a40b6f466a42516" [[package]] name = "ahash" @@ -30,6 +30,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 = "anstream" version = "1.0.0" @@ -119,7 +125,16 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" dependencies = [ - "bit-vec", + "bit-vec 0.6.3", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec 0.8.0", ] [[package]] @@ -128,12 +143,24 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + [[package]] name = "bstr" version = "1.12.1" @@ -244,6 +271,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + [[package]] name = "deranged" version = "0.5.8" @@ -276,6 +309,15 @@ version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -298,7 +340,18 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "531e46835a22af56d1e3b66f04844bed63158bc094a628bec1d321d9b4c44bf2" dependencies = [ - "bit-set", + "bit-set 0.5.3", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "fancy-regex" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d301f5bf187b3c295fce6468d3875037a0bccc5f6b151c63cac2f85babf21912" +dependencies = [ + "bit-set 0.8.0", "regex-automata", "regex-syntax", ] @@ -324,12 +377,29 @@ dependencies = [ "num-traits", ] +[[package]] +name = "fluent-uri" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + [[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 = "form_urlencoded" version = "1.2.2" @@ -349,6 +419,16 @@ dependencies = [ "num", ] +[[package]] +name = "fraction" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e246562084dde8ebbcc943b261c406ce4f68e5032ec28029a251a47d6a295500" +dependencies = [ + "num", + "num-bigint", +] + [[package]] name = "futures-core" version = "0.3.32" @@ -408,9 +488,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -451,7 +533,7 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -459,6 +541,11 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "heck" @@ -661,8 +748,8 @@ dependencies = [ "anyhow", "base64", "bytecount", - "fancy-regex", - "fraction", + "fancy-regex 0.13.0", + "fraction 0.15.4", "getrandom 0.2.17", "iso8601", "itoa", @@ -679,6 +766,59 @@ dependencies = [ "uuid", ] +[[package]] +name = "jsonschema" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6a806f80c1f5560431009ce5ec29b59d38f950e0cd7db5f1b8925e6d8104e21" +dependencies = [ + "ahash", + "bytecount", + "data-encoding", + "email_address", + "fancy-regex 0.19.2", + "fraction 0.17.0", + "getrandom 0.3.4", + "itoa", + "jsonschema-regex", + "jsonschema-value", + "num-cmp", + "num-traits", + "percent-encoding", + "referencing", + "regex", + "serde", + "serde_json", + "strum", + "unicode-general-category", + "uuid-simd", +] + +[[package]] +name = "jsonschema-regex" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb862addfa7782108933abcf842fe25334523a9df5b89ea4d9620e3dd7b42181" +dependencies = [ + "regex-syntax", +] + +[[package]] +name = "jsonschema-value" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a05cd404c5ff6e2731dbbf7e290a27417750acfb59e329560a1d0af384c93fb0" +dependencies = [ + "ahash", + "bytecount", + "fraction 0.17.0", + "getrandom 0.3.4", + "num-cmp", + "num-traits", + "serde_json", + "zmij", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -758,6 +898,12 @@ version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +[[package]] +name = "micromap" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74" + [[package]] name = "nom" version = "8.0.0" @@ -798,9 +944,9 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", @@ -879,6 +1025,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "parking_lot" version = "0.12.5" @@ -988,7 +1140,7 @@ dependencies = [ "glob", "ix-cli-kit", "ix-trace-rs", - "jsonschema", + "jsonschema 0.18.3", "predicates", "quire-qualify", "quire-rs", @@ -1041,14 +1193,14 @@ dependencies = [ [[package]] name = "quire-rs" -version = "0.52.1" -source = "git+https://github.com/agent-ix/quire-rs?rev=12371baca61edd6aef71a38464b7933ce6d6cebe#12371baca61edd6aef71a38464b7933ce6d6cebe" +version = "0.53.0" +source = "git+https://github.com/agent-ix/quire-rs?rev=ce172a7ddcec21815f852c06fa5a0e340b79e57b#ce172a7ddcec21815f852c06fa5a0e340b79e57b" dependencies = [ "agent-ix-semantic-schema", "globset", "ignore", "indexmap", - "jsonschema", + "jsonschema 0.56.0", "loom", "quire-rust-extraction", "rayon", @@ -1063,7 +1215,7 @@ dependencies = [ [[package]] name = "quire-rust-extraction" version = "0.1.0" -source = "git+https://github.com/agent-ix/quire-rs?rev=12371baca61edd6aef71a38464b7933ce6d6cebe#12371baca61edd6aef71a38464b7933ce6d6cebe" +source = "git+https://github.com/agent-ix/quire-rs?rev=ce172a7ddcec21815f852c06fa5a0e340b79e57b#ce172a7ddcec21815f852c06fa5a0e340b79e57b" dependencies = [ "quire-code-parse", ] @@ -1118,6 +1270,43 @@ dependencies = [ "bitflags", ] +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "referencing" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3b4a92fac7e28c27de3ad26df2ecb9e652f227b258e845af034052e5b22c96c" +dependencies = [ + "ahash", + "fluent-uri", + "getrandom 0.3.4", + "hashbrown 0.17.1", + "itoa", + "micromap", + "parking_lot", + "percent-encoding", + "serde_json", +] + [[package]] name = "regex" version = "1.12.3" @@ -1132,9 +1321,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -1308,6 +1497,27 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "syn" version = "2.0.117" @@ -1580,6 +1790,12 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1628,6 +1844,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "valuable" version = "0.1.1" @@ -1640,6 +1866,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "wait-timeout" version = "0.2.1" @@ -2016,6 +2248,6 @@ dependencies = [ [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index f770c4b..ce72db0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ path = "src/main.rs" [dependencies] ix-cli-kit = { git = "https://github.com/agent-ix/ix-cli-kit", branch = "main", features = ["self-update"] } -quire-rs = { version = "=0.52.1", git = "https://github.com/agent-ix/quire-rs", rev = "12371baca61edd6aef71a38464b7933ce6d6cebe" } +quire-rs = { version = "=0.53.0", git = "https://github.com/agent-ix/quire-rs", rev = "ce172a7ddcec21815f852c06fa5a0e340b79e57b" } clap = { version = "4", features = ["derive"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/src/commands/coverage.rs b/src/commands/coverage.rs index 88cb292..bf1d12e 100644 --- a/src/commands/coverage.rs +++ b/src/commands/coverage.rs @@ -15,7 +15,6 @@ use std::path::{Path, PathBuf}; use anyhow::Context; use clap::Parser; -use quire_cli::io; use quire_cli::safety; use quire_rs::grammar::{GrammarSeverityLevel, GrammarSeverityMap}; use quire_rs::{compute_coverage, Registry, Spec}; @@ -789,11 +788,7 @@ pub(super) fn load_registry_for( if scope.join("manifest.yaml").is_file() { return super::load_module_registry(ctx, scope); } - let registry = Registry::from_env() - .map_err(super::failure::invalid) - .context("loading modules")?; - io::emit_quire_diagnostics(ctx.diagnostics, registry.diagnostics()); - Ok(registry) + super::validate::load_scoped_with_defaults(ctx, scope) } #[cfg(test)] diff --git a/src/commands/trace.rs b/src/commands/trace.rs index 0dd2b8f..92de8e0 100644 --- a/src/commands/trace.rs +++ b/src/commands/trace.rs @@ -56,7 +56,9 @@ use clap::Parser; use serde::Serialize; use quire_cli::safety; -use quire_rs::symbols::trace::{ImplementsRelation, Mention, SymbolGraph, VerifiesRelation}; +use quire_rs::symbols::trace::{ + trace_id_segments, ImplementsRelation, Mention, SymbolGraph, VerifiesRelation, +}; use quire_rs::symbols::trace_search::{self, language_confidence, symbol_language, Query}; use quire_rs::symbols::SymbolExtraction; @@ -140,9 +142,12 @@ pub enum OutputFormat { } /// What was asked, echoed back so a JSON consumer never has to re-derive it -/// from argv. `matched_ids` is populated only under `--prefix`: it is the set -/// of concrete ids the prefix actually matched, present so a caller can see -/// exactly what got unioned into one result. +/// from argv. `matched_ids` is populated only under `--prefix`: every concrete +/// id spelling present in the result rows, so a caller can see exactly what +/// got unioned into one result. It is the ids the prefix matched by segment +/// boundary plus any other spelling the engine's exact `Query::Id` search +/// folded onto them (`FR047` folds onto `FR-047`), so it always covers the +/// payload it describes. #[derive(Debug, Clone, Serialize)] #[serde(tag = "kind", rename_all = "snake_case")] enum QueryDescription { @@ -417,7 +422,7 @@ fn describe_query(query: &Query) -> QueryDescription { } /// `--id --prefix`: union every distinct id in the graph whose -/// [`id_segments`] start with `prefix`'s segments, running the engine's own +/// [`trace_id_segments`] start with `prefix`'s segments, running the engine's own /// exact `Query::Id` search once per **normalized-equivalence class** of /// matched id and concatenating. /// @@ -438,7 +443,7 @@ fn search_by_prefix( ids.extend(graph.verifies.iter().map(|v| v.trace_id.as_str())); ids.extend(graph.implements.iter().map(|i| i.trace_id.as_str())); ids.extend(graph.mentions.iter().map(|m| m.trace_id.as_str())); - let matched_ids: Vec = ids + let boundary_matched: Vec = ids .into_iter() .filter(|id| prefix_matches(id, prefix)) .map(String::from) @@ -448,7 +453,7 @@ fn search_by_prefix( let mut implements = Vec::new(); let mut citations = Vec::new(); let mut searched_normalized: BTreeSet = BTreeSet::new(); - for id in &matched_ids { + for id in &boundary_matched { // The one representative per normalized-equivalence class. A second // raw spelling that normalizes the same is skipped here, not // searched-then-deduplicated after the fact — the engine's search @@ -462,6 +467,19 @@ fn search_by_prefix( implements.extend(r.implements); citations.extend(r.citations); } + // `Query::Id` matches by the engine's fold, which discards separators, so + // it can return rows spelled differently from every boundary match. + // Report those spellings too: `matched_ids` must cover its payload. + let matched_ids: Vec = boundary_matched + .iter() + .map(String::as_str) + .chain(verifies.iter().map(|v| v.trace_id.as_str())) + .chain(implements.iter().map(|i| i.trace_id.as_str())) + .chain(citations.iter().map(|m| m.trace_id.as_str())) + .collect::>() + .into_iter() + .map(String::from) + .collect(); let resolved = !(verifies.is_empty() && implements.is_empty() && citations.is_empty()); let result = trace_search::SearchResult { query: Query::Id(prefix.to_string()), @@ -481,59 +499,24 @@ fn search_by_prefix( ) } -/// Split `value` on runs of non-alphanumeric characters and uppercase each -/// resulting piece, dropping empty pieces (so a leading/trailing separator — -/// `FR-047-` — contributes nothing, and consecutive separators collapse). -/// -/// This is character-for-character the same fold quire-rs's own -/// `normalized_trace_id` applies (`src/symbols/trace.rs:871`: filter -/// `is_ascii_alphanumeric`, uppercase) — that function is `pub(crate)` and -/// unreachable from here, so this duplicates its per-character rule rather -/// than sharing it (asked for during review; requesting the export be made -/// `pub` upstream is the preferred long-term fix and does not, by itself, -/// replace this function even if granted — see below). It is deliberately -/// NOT a drop-in reimplementation of `normalized_trace_id` itself: that -/// function concatenates every alphanumeric character into one string, -/// discarding exactly the segment boundaries `--prefix` needs to tell -/// `FR-047` (must match `FR-047-AC-1`) apart from `FR-0470` (must not). No -/// function in the engine currently preserves those boundaries — boundary- -/// aware prefix matching is CLI-only logic by design (quire-rs -/// `traceability.rs`: the engine knows nothing of FR/AC/TC hierarchy). -/// -/// This duplication is a liability by itself: if quire-rs ever changes what -/// `normalized_trace_id` folds (a new character class, different case -/// handling), this copy keeps the OLD rule and `--prefix` silently diverges -/// from exact `--id` again, with every test in this file staying green -/// because they all check this copy against itself. The integration test -/// `prefix_fold_tracks_the_engines_own_normalization_across_spellings` in -/// `tests/cli_trace.rs` is the guard against that: it measures the engine's -/// fold behaviorally (through exact `--id`, since `normalized_trace_id` -/// itself is `pub(crate)` and unreachable) and this crate's fold -/// behaviorally (through `--prefix`) over the same spelling table, so a -/// drift between the two shows up as a named, failing assertion instead of -/// silence. Do not replace that test with a doc comment restating this -/// property — an assertion the code does not enforce is what this note -/// itself would become. -fn id_segments(value: &str) -> Vec { - value - .split(|c: char| !c.is_ascii_alphanumeric()) - .filter(|s| !s.is_empty()) - .map(str::to_ascii_uppercase) - .collect() -} - -/// The engine's own normalized id, reproduced by concatenating -/// [`id_segments`] — used only to tell whether two RAW spellings are the +/// The engine's normalized id: the concatenation of its own +/// [`trace_id_segments`]. Used only to tell whether two RAW spellings are the /// same id as far as `Query::Id`'s own match is concerned (H2's dedup key), -/// never to decide `--prefix` boundaries (which need the segments, not the -/// concatenation — see [`id_segments`]). +/// never to decide `--prefix` boundaries, which need the segments. fn normalized_id(value: &str) -> String { - id_segments(value).concat() + trace_id_segments(value).concat() } -/// Segment-boundary prefix match, fold-consistent with the engine's own -/// `normalized_trace_id` equality: `prefix` matches `candidate` when -/// `candidate`'s [`id_segments`] start with `prefix`'s. +/// Segment-boundary prefix match: `prefix` matches `candidate` when +/// `candidate`'s [`trace_id_segments`] (the engine's own fold, shared rather +/// than copied) start with `prefix`'s. +/// +/// Known divergence from the engine's exact `--id`, which compares the +/// CONCATENATED fold: this match is sensitive to where alphanumeric runs +/// break. `FR047` is one segment (`["FR047"]`), so the engine treats it as +/// `FR-047` for `--id` but `--prefix FR-047` does not match `FR047-AC-1`. +/// Closing that gap would re-admit the `FR-0470` decoy (`"FR0470"` starts +/// with `"FR047"`), so the boundary rule wins. /// /// `FR-047` (segments `["FR","047"]`) matches `FR-047-AC-1` /// (`["FR","047","AC","1"]`, a genuine extra segment) and `FR_047_AC_9` @@ -544,11 +527,11 @@ fn normalized_id(value: &str) -> String { /// segments, not merely as a string-prefix check within one segment) — the /// property this flag exists to guarantee. fn prefix_matches(candidate: &str, prefix: &str) -> bool { - let prefix_segments = id_segments(prefix); + let prefix_segments = trace_id_segments(prefix); if prefix_segments.is_empty() { return false; } - let candidate_segments = id_segments(candidate); + let candidate_segments = trace_id_segments(candidate); candidate_segments.len() >= prefix_segments.len() && candidate_segments[..prefix_segments.len()] == prefix_segments[..] } @@ -920,7 +903,7 @@ mod tests { // Review finding H1: the previous raw, case-sensitive `strip_prefix` // diverged from the engine's own `normalized_trace_id` fold in three - // reproduced ways. Each of these failed before the `id_segments` rewrite. + // reproduced ways. Each of these failed before the former `id_segments` rewrite. #[test] fn tc_prefix_matches_folds_case_like_the_engine() { assert!(prefix_matches("FR-047", "fr-047")); @@ -946,10 +929,13 @@ mod tests { #[test] fn tc_id_segments_splits_on_non_alphanumeric_runs_and_uppercases() { - assert_eq!(id_segments("FR-047-AC-1"), vec!["FR", "047", "AC", "1"]); - assert_eq!(id_segments("fr_047"), vec!["FR", "047"]); - assert_eq!(id_segments("FR-047-"), vec!["FR", "047"]); - assert_eq!(id_segments(""), Vec::::new()); + assert_eq!( + trace_id_segments("FR-047-AC-1"), + vec!["FR", "047", "AC", "1"] + ); + assert_eq!(trace_id_segments("fr_047"), vec!["FR", "047"]); + assert_eq!(trace_id_segments("FR-047-"), vec!["FR", "047"]); + assert_eq!(trace_id_segments(""), Vec::::new()); } #[test] diff --git a/src/commands/validate.rs b/src/commands/validate.rs index 7f7e411..eb8e248 100644 --- a/src/commands/validate.rs +++ b/src/commands/validate.rs @@ -419,6 +419,14 @@ pub(crate) fn load_registry(ctx: &Ctx, args: &Args, scope: &Path) -> anyhow::Res return super::load_module_registry(ctx, scope); } + load_scoped_with_defaults(ctx, scope) +} + +/// Scoped module discovery shared by every command whose `--module` is +/// omitted (`validate`, `coverage`, `symbols`): ``, `/.ix/modules`, +/// the module env vars and the default install root, with a one-shot lazy +/// install of the default set when nothing is found. +pub(crate) fn load_scoped_with_defaults(ctx: &Ctx, scope: &Path) -> anyhow::Result { // Scoped discovery. Load once; if nothing is found, lazy-install the // default module set via quoin and reload exactly once before failing — // so a fresh machine validates without any manual `quoin` step or env var. @@ -737,6 +745,9 @@ fn validation_remedy(reason: ValidationReason) -> &'static str { ValidationReason::Assert => { "change the cited value or structure so it satisfies the named archetype assertion" } + ValidationReason::Regex => { + "change the cited value so it matches the archetype's declared pattern" + } ValidationReason::Frontmatter => { "correct the cited frontmatter field so it satisfies the archetype schema" } diff --git a/tests/cli_coverage.rs b/tests/cli_coverage.rs index 5ccaf77..f8725df 100644 --- a/tests/cli_coverage.rs +++ b/tests/cli_coverage.rs @@ -1313,6 +1313,80 @@ fn it108_a_declared_source_exclude_scopes_the_code_walk() { ); } +// PLAT-179 (quire-cli#80): with `--module` omitted, `coverage --scope ` +// discovers modules exactly as `validate` does, which includes +// `/.ix/modules`. It used to read only the ambient env, so a repo-local +// module's `source_exclude` never reached symbol extraction. Ambient discovery +// is emptied here (no env paths, an empty HOME), so the only place the module +// can come from is the repository itself. +#[test] +fn plat179_scoped_coverage_discovers_repo_local_ix_modules() { + let dir = TempDir::new().expect("tempdir"); + let home = TempDir::new().expect("home"); + let local = dir.path().join(".ix/modules/local"); + fs::create_dir_all(&local).expect("mkdir module"); + fs::write( + local.join("manifest.yaml"), + "name: local\nmanifest_version: 1.0.0\nversion: 0.0.1\nartifact_types:\n\ + - name: TestMatrix\n\ + traceability:\n source_exclude: ['vendor/**']\n trace_targets:\n - name: test-case\n\ + \x20 archetype: TestMatrix\n section: Test Case Summary\n\ + \x20 id_column: Test ID\n status:\n column: Status\n\ + \x20 complete: [\"\u{2705}\"]\n pending: [\"\u{1F6A7}\"]\n\ + \x20 failed: [\"\u{274C}\"]\n retired: [\"\u{26D4}\"]\n\ + \x20 trace_tags:\n legacy:\n - name: comment-id\n\ + \x20 pattern: '(?://|#)\\s*((?:TC|FR)-\\d+)'\n", + ) + .expect("write manifest"); + fs::create_dir_all(dir.path().join("spec")).expect("mkdir spec"); + fs::create_dir_all(dir.path().join("vendor")).expect("mkdir vendor"); + fs::create_dir_all(dir.path().join("tests")).expect("mkdir tests"); + fs::write( + dir.path().join("spec/tests.md"), + matrix_doc("TM-001", "TC-001"), + ) + .expect("write matrix"); + fs::write( + dir.path().join("vendor/dep.rs"), + "// TC-999\n#[test]\nfn tc999_vendored() {}\n", + ) + .expect("write vendored"); + fs::write( + dir.path().join("tests/real.rs"), + "// TC-998\n#[test]\nfn tc998_real() {}\n", + ) + .expect("write source"); + + let scope = dir.path().to_string_lossy().into_owned(); + let out = quire() + .args(["coverage", "--scope", &scope, "--json"]) + .env("HOME", home.path()) + .env_remove("IX_FILAMENT_MODULES_PATH") + .env_remove("IX_SCHEMA_PATH") + .output() + .expect("run"); + assert!( + out.status.success(), + "repo-local module was not discovered: {}", + String::from_utf8_lossy(&out.stderr) + ); + let report: serde_json::Value = serde_json::from_slice(&out.stdout).expect("json"); + let ids: Vec = report["untracked_symbols"] + .as_array() + .expect("array") + .iter() + .filter_map(|s| s["trace_id"].as_str().map(str::to_string)) + .collect(); + assert!( + !ids.contains(&"TC-999".to_string()), + "the repo-local source_exclude did not reach the walk: {ids:?}" + ); + assert!( + ids.contains(&"TC-998".to_string()), + "real source lost: {ids:?}" + ); +} + /// The it107 manifest (self-referencing TC ids, declared status vocabulary, /// legacy comment trace tags), reusable by the #51-batch tests below. fn self_ref_manifest() -> &'static str { diff --git a/tests/cli_trace.rs b/tests/cli_trace.rs index 885dda6..02ff003 100644 --- a/tests/cli_trace.rs +++ b/tests/cli_trace.rs @@ -397,29 +397,12 @@ fn prefix_does_not_double_count_ids_that_normalize_the_same() { ); } -// Coordinator follow-up on H1/H2: `id_segments` duplicates the engine's -// `normalized_trace_id` fold rather than sharing it (that function is -// `pub(crate)`, and boundary-aware `--prefix` needs the segment structure it -// discards anyway). A duplicated invariant with nothing comparing the two -// copies is exactly the defect class this campaign exists to kill — if -// quire-rs ever changes its fold, this crate's copy keeps the old rule and -// `--prefix` silently diverges from exact `--id` again, with every other -// test in this file staying green because they all check this crate's fold -// against itself. -// -// This test measures BOTH folds independently and behaviorally, never by -// calling the unreachable `normalized_trace_id`: -// - the ENGINE's fold, observed through exact `--id` (which already -// case/separator-folds internally, per the HIGH 1 repro: `--id fr-047` -// found `FR-047`); -// - THIS CRATE's fold, observed through `--prefix` (`id_segments` / -// `prefix_matches`). -// Over a table of five spellings of `FR-047` — including one that mixes -// separator characters within a single spelling (`FR_047-`: underscore -// internally, dash trailing) — plus the `FR-0470` decoy, which must never -// enter either set. If the engine's rule ever moves, the exact-`--id` -// assertions below change while the `--prefix` assertions do not, and this -// test fails naming the diverging spelling. +// PLAT-898: `--prefix` no longer carries its own copy of the engine's trace-id +// fold; it calls quire-rs's exported `trace_id_segments`, the single +// implementation `normalized_trace_id` is built from. This test still pins the +// observable agreement: exact `--id` (the engine's concatenated fold) and +// `--prefix` (its segment fold) must resolve every separator/case spelling of +// `FR-047` to the same family, and the `FR-0470` decoy must stay out of both. #[test] fn prefix_fold_tracks_the_engines_own_normalization_across_spellings() { let dir = TempDir::new().expect("tempdir"); @@ -483,6 +466,76 @@ fn prefix_fold_tracks_the_engines_own_normalization_across_spellings() { } } +// PLAT-908: the one known place `--prefix` and exact `--id` disagree. The +// engine folds by concatenation, so `FR047` is `FR-047` to `--id`; `--prefix` +// compares alphanumeric-run segments (the only way to keep `FR-0470` out), so +// `FR047` is the single segment `FR047` and `FR047-AC-1` is not under the +// prefix `FR-047`. This pins that documented behaviour so a change to either +// fold shows up here, and checks `matched_ids` still covers every id the +// payload returns (it used to omit `FR047`, which the engine folded in). +#[test] +fn prefix_known_divergence_for_ids_without_a_separator() { + let dir = TempDir::new().expect("tempdir"); + let src = dir.path().join("src"); + fs::create_dir_all(&src).expect("mkdir src"); + fs::write( + src.join("lib.rs"), + "#[trace(\"FR-047\")]\n#[test]\nfn tc_dash() {}\n\n\ + #[trace(\"FR047\")]\n#[test]\nfn tc_no_separator() {}\n\n\ + #[trace(\"FR047-AC-1\")]\n#[test]\nfn tc_no_separator_child() {}\n", + ) + .expect("write lib.rs"); + let scope = dir.path().to_string_lossy().into_owned(); + let m = module(&dir); + let ids_of = |payload: &serde_json::Value| -> Vec { + let mut v: Vec = payload["claims"]["verifies"] + .as_array() + .expect("verifies") + .iter() + .map(|r| r["trace_id"].as_str().unwrap().to_string()) + .collect(); + v.sort(); + v + }; + let run_json = |extra: &[&str]| -> serde_json::Value { + let mut args = vec!["trace", "--scope", &scope, "--module", &m, "--json"]; + args.extend_from_slice(extra); + let out = run(&args); + assert_eq!(out.code, Some(0), "stderr: {}", out.stderr); + serde_json::from_str(&out.stdout).expect("json") + }; + + // Engine fold: `FR047-AC-1` IS `FR-047-AC-1`, and FR047 IS FR-047. + let child = run_json(&["--id", "FR-047-AC-1"]); + assert_eq!(ids_of(&child), vec!["FR047-AC-1".to_string()], "{child}"); + let exact = run_json(&["--id", "FR-047"]); + assert_eq!( + ids_of(&exact), + vec!["FR-047".to_string(), "FR047".to_string()], + "{exact}" + ); + + // Prefix fold: the boundary-differing child is NOT in the family. + let prefixed = run_json(&["--id", "FR-047", "--prefix"]); + assert_eq!( + ids_of(&prefixed), + vec!["FR-047".to_string(), "FR047".to_string()], + "known divergence changed: {prefixed}" + ); + let matched: Vec = prefixed["query"]["matched_ids"] + .as_array() + .expect("matched_ids") + .iter() + .map(|v| v.as_str().unwrap().to_string()) + .collect(); + for id in ids_of(&prefixed) { + assert!( + matched.contains(&id), + "matched_ids {matched:?} omits {id} which the payload returned" + ); + } +} + #[test] fn ambiguous_bare_symbol_name_lists_every_candidate() { // Upstream trace-search criterion 4: never a silent pick. From 574424ce5cc274ce5c47145ae451fddf080267b5 Mon Sep 17 00:00:00 2001 From: Peter Krenesky Date: Sat, 7 Nov 2026 21:48:38 -0800 Subject: [PATCH 2/2] fix(validate): drop duplicate Regex remedy arm after merging main Co-Authored-By: Claude Sonnet 5.5 --- src/commands/validate.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/commands/validate.rs b/src/commands/validate.rs index 614dbdd..6199641 100644 --- a/src/commands/validate.rs +++ b/src/commands/validate.rs @@ -786,9 +786,6 @@ fn validation_remedy(reason: ValidationReason) -> &'static str { ValidationReason::Assert => { "change the cited value or structure so it satisfies the named archetype assertion" } - ValidationReason::Regex => { - "change the cited value so it matches the archetype's declared pattern" - } ValidationReason::Frontmatter => { "correct the cited frontmatter field so it satisfies the archetype schema" }