From 4c2e7e1363c3a14a6b31658b2e6595ee2b6fea36 Mon Sep 17 00:00:00 2001 From: Peter Krenesky Date: Mon, 5 Oct 2026 12:00:28 -0700 Subject: [PATCH 1/3] fix: surface refused trace tags in matrix diagnostics (PLAT-1150) --- spec/functional/FR-026-matrix-subcommand.md | 8 +++ src/commands/matrix.rs | 41 ++++++++++++++- tests/cli_matrix.rs | 55 +++++++++++++++++++++ 3 files changed, 103 insertions(+), 1 deletion(-) diff --git a/spec/functional/FR-026-matrix-subcommand.md b/spec/functional/FR-026-matrix-subcommand.md index 4ee3785..28082c8 100644 --- a/spec/functional/FR-026-matrix-subcommand.md +++ b/spec/functional/FR-026-matrix-subcommand.md @@ -244,6 +244,14 @@ non-strict zero-population and fully-tagged cases), 1 for every refusal above | FR-026-AC-13 | `matrix` uses only the FR-007 exit codes — 0, 1, 2, 3, 4 — across every case above, including an unrecognized `--severity` flag (exit 3); the shared taxonomy is authoritative | Test | | FR-026-AC-14 | `matrix` never opens a file for writing: no flag names an output path, and running it in every format against a fixture repository leaves every file anywhere under `` — not only under `/spec` — byte-identical before and after the run | Test | +| FR-026-AC-15 | In every output format, `matrix` SHALL emit engine coverage diagnostics and unmatched annotation IDs on stderr with the authored file and line when available. A forbidden source-tag range retains its `range-in-trace-tag` explanation; an ID not read by the module's declared trace grammar is named with the symbol and guidance to use the declared form (comma-separated lists for `Trace:`). These advisories do not alter the engine's bindings, stdout matrix payload, or strict verdict. Correct comma-list controls emit neither range nor unmatched-tag warnings. | Test | + +> **PLAT-1150 change note (2026-10-05):** At quire-driver `e7f6e89`, the +> engine already records source ranges and IDs dropped after `and`, but matrix +> selected `coverage_matrix` and discarded those explanations. Surface the +> existing facts rather than expanding the declared grammar: source ranges +> remain refused under quire-rs FR-050-AC-50 and FR-051-AC-28. + ## Dependencies - **Upstream**: [FR-017](./FR-017-coverage-subcommand.md) `coverage` (shared scope/module resolution, shared `CoverageReport` computation, shared help-text resolution-order requirement); [FR-008](./FR-008-json-output-encoding.md) JSON provenance envelope (`engine::attach`); [StR-004](../stakeholder/StR-004-thin-boundary-over-quire-rs.md) thin boundary; quire-rs [FR-050](ix://agent-ix/quire-rs/FR-050)-AC-47..51 (`coverage_matrix` computation, CR-187, `statement` field per PLAT-1077), [FR-051](ix://agent-ix/quire-rs/FR-051)-AC-27/28 (binder `ignored` marking, range-in-trace-tag). diff --git a/src/commands/matrix.rs b/src/commands/matrix.rs index 743c25e..56803b6 100644 --- a/src/commands/matrix.rs +++ b/src/commands/matrix.rs @@ -15,6 +15,7 @@ use serde::Serialize; use crate::commands::coverage::{compute_report, tsv_cell, Target}; use crate::commands::Ctx; +use quire_cli::io; #[derive(Debug, Parser)] pub struct Args { @@ -64,7 +65,45 @@ struct Payload { pub fn run(ctx: &Ctx, args: Args) -> anyhow::Result<()> { let (scope, registry) = args.target.load(ctx)?; - let matrix = compute_report(ctx, &scope, ®istry)?.coverage_matrix; + let report = compute_report(ctx, &scope, ®istry)?; + // FR-026-AC-15 / PLAT-1150: selecting one view must not discard the + // engine's explanation of tags it refused or could only partly read. + for diagnostic in &report.diagnostics { + let locus = match (diagnostic.path.as_deref(), diagnostic.line) { + (Some(path), Some(line)) => format!("{path}:{line}"), + (Some(path), None) => path.to_string(), + (None, _) => "scope".to_string(), + }; + io::emit_warning_with_fields( + ctx.diagnostics, + &format!("[{}] {} ({locus})", diagnostic.reason, diagnostic.message), + &io::DiagnosticFields { + reason: Some(&diagnostic.reason), + path: diagnostic.path.as_deref(), + line: diagnostic.line, + declaration: Some(&diagnostic.declaration), + ..Default::default() + }, + ); + } + for tag in &report.unmatched_tags { + io::emit_warning_with_fields( + ctx.diagnostics, + &format!( + "{}:{}: `{}` on `{}` was not read by the declared trace grammar; \ + use the module's declared tag form (comma-separated IDs for Trace: lists)", + tag.path, tag.line, tag.trace_id, tag.symbol, + ), + &io::DiagnosticFields { + reason: Some("unmatched-trace-tag"), + path: Some(&tag.path), + line: Some(tag.line), + subject: Some(&tag.trace_id), + ..Default::default() + }, + ); + } + let matrix = report.coverage_matrix; let verdict = strict_verdict(&matrix); match args.format { diff --git a/tests/cli_matrix.rs b/tests/cli_matrix.rs index 0b3c67e..446a308 100644 --- a/tests/cli_matrix.rs +++ b/tests/cli_matrix.rs @@ -972,3 +972,58 @@ fn tc841_every_matrix_exit_code_is_in_the_fr007_taxonomy() { } assert_eq!(seen.into_iter().collect::>(), [0, 1, 3]); } + +/// Trace: FR-026-AC-15 +/// Provenance: PLAT-1150 +#[test] +fn matrix_explains_ranges_and_partially_read_trace_lists_in_every_format() { + for (language, path, source) in [ + ("rust", "src/lib.rs", "/// Trace: FR-001-AC-1 and FR-001-AC-2\n#[test]\nfn partial() {}\n/// Trace: FR-001-AC-3..FR-001-AC-4\n#[test]\nfn range() {}\n"), + ("python", "src/test_cases.py", "# Trace: FR-001-AC-1 and FR-001-AC-2\ndef test_partial():\n pass\n# Trace: FR-001-AC-3..FR-001-AC-4\ndef test_range():\n pass\n"), + ("typescript", "src/cases.test.ts", "import { it } from 'vitest';\n// Trace: FR-001-AC-1 and FR-001-AC-2\nit('partial', () => {});\n// Trace: FR-001-AC-3..FR-001-AC-4\nit('range', () => {});\n"), + ] { + let legacy = format!(" trace_tags:\n legacy:\n - name: trace-line\n language: {language}\n pattern: 'Trace:\\s*(FR-\\d+-AC-\\d+(?:\\s*,\\s*FR-\\d+-AC-\\d+)*)'\n"); + let f = Fixture::new(&manifest(true, "", "").replace(TRACE_TAGS, &legacy)); + f.write("spec/FR-001.md", &fr_doc("FR-001", &[ + ("FR-001-AC-1", "Accept input.", "Test"), + ("FR-001-AC-2", "Reject input.", "Test"), + ("FR-001-AC-3", "Accept a boundary.", "Test"), + ("FR-001-AC-4", "Reject beyond a boundary.", "Test"), + ])); + f.write(path, source); + for format in ["markdown", "json", "tsv"] { + let out = f.matrix(&["--format", format]); + assert_eq!(code(&out), 0, "{language}/{format}: {}", stderr(&out)); + let warnings = stderr(&out); + assert!(warnings.contains("range-in-trace-tag"), "{language}/{format}: {warnings}"); + assert!(warnings.contains("FR-001-AC-3..FR-001-AC-4")); + assert!(warnings.contains("FR-001-AC-2"), "{language}/{format}: {warnings}"); + assert!(warnings.contains(path)); + assert!(!stdout(&out).contains("was not read by the declared trace grammar")); + assert!(warnings.contains("was not read by the declared trace grammar")); + if format == "json" { + let report = json(&out); + let criteria = report["coverage_matrix"][0]["criteria"].as_array().unwrap(); + assert_eq!(criteria[0]["status"], "tagged"); + for row in &criteria[1..] { assert_eq!(row["status"], "untagged"); } + } + } + let machine = f.matrix(&["--format", "json", "--diagnostics-format", "json"]); + assert_eq!(code(&machine), 0); + let diagnostics: Vec = stderr(&machine).lines() + .map(|line| serde_json::from_str(line).expect("typed diagnostic JSON")) + .collect(); + for reason in ["range-in-trace-tag", "unmatched-trace-tag"] { + let diagnostic = diagnostics.iter().find(|d| d["reason"] == reason) + .unwrap_or_else(|| panic!("missing {reason}: {diagnostics:?}")); + assert_eq!(diagnostic["severity"], "warning"); + assert_eq!(diagnostic["path"], path); + assert!(diagnostic["line"].as_u64().is_some_and(|line| line > 0)); + } + f.write(path, &source.replace(" and ", ", ").replace("FR-001-AC-3..FR-001-AC-4", "FR-001-AC-3, FR-001-AC-4")); + let repaired = f.matrix(&["--format", "json", "--strict"]); + assert_eq!(code(&repaired), 0, "{language}: {}", stderr(&repaired)); + assert!(!stderr(&repaired).contains("range-in-trace-tag")); + assert!(!stderr(&repaired).contains("was not read by the declared trace grammar")); + } +} From 6e77d6a47c178161d7df1ea44730ea91489fe4f7 Mon Sep 17 00:00:00 2001 From: Peter Krenesky Date: Mon, 5 Oct 2026 11:56:56 -0700 Subject: [PATCH 2/3] fix: remove Linux-only unused-mut lint blocker --- tests/audit_no_network.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/audit_no_network.rs b/tests/audit_no_network.rs index 9cd525f..246fc94 100644 --- a/tests/audit_no_network.rs +++ b/tests/audit_no_network.rs @@ -75,7 +75,7 @@ fn clause_module(dir: &TempDir) -> PathBuf { "name: synthetic-clause-module\nclause_sets:\n - clauses/widget.json\n", ) .expect("write manifest"); - let mut set = ClauseSet { + let set = ClauseSet { schema_version: "clause-set-v1".into(), authority: "example.test".into(), id: "widget-assurance".into(), From 802255206261cb9cdc749a4c06d9c58fc6d7049c Mon Sep 17 00:00:00 2001 From: Peter Krenesky Date: Sun, 8 Nov 2026 01:36:29 -0800 Subject: [PATCH 3/3] Merge fixes: renumber PLAT-1150 criterion to FR-026-AC-16; port matrix diagnostics to ix-cli-kit Record API Co-Authored-By: Claude Sonnet 5.5 --- spec/functional/FR-026-matrix-subcommand.md | 3 +- src/commands/matrix.rs | 35 ++++++++++----------- tests/cli_matrix.rs | 2 +- 3 files changed, 18 insertions(+), 22 deletions(-) diff --git a/spec/functional/FR-026-matrix-subcommand.md b/spec/functional/FR-026-matrix-subcommand.md index f2d126b..ee8ac5f 100644 --- a/spec/functional/FR-026-matrix-subcommand.md +++ b/spec/functional/FR-026-matrix-subcommand.md @@ -266,8 +266,7 @@ non-strict zero-population and fully-tagged cases), 1 for every refusal above | FR-026-AC-13 | `matrix` uses only the FR-007 exit codes — 0, 1, 2, 3, 4 — across every case above, including an unrecognized `--severity` flag (exit 3); the shared taxonomy is authoritative | Test | | FR-026-AC-14 | `matrix` never opens a file for writing: no flag names an output path, and running it in every format against a fixture repository leaves every file anywhere under `` — not only under `/spec` — byte-identical before and after the run | Test | | FR-026-AC-15 | An engine-supplied `qualified-record` criterion renders that exact status in Markdown, JSON and TSV without fabricating a source binder; its nonempty population passes `--strict` by itself, while a mixed population containing an `untagged` or `tagged-by-ignored-test` criterion still exits 1. The CLI does not construct qualification records or add an evidence-input surface. | Test | - -| FR-026-AC-15 | In every output format, `matrix` SHALL emit engine coverage diagnostics and unmatched annotation IDs on stderr with the authored file and line when available. A forbidden source-tag range retains its `range-in-trace-tag` explanation; an ID not read by the module's declared trace grammar is named with the symbol and guidance to use the declared form (comma-separated lists for `Trace:`). These advisories do not alter the engine's bindings, stdout matrix payload, or strict verdict. Correct comma-list controls emit neither range nor unmatched-tag warnings. | Test | +| FR-026-AC-16 | In every output format, `matrix` SHALL emit engine coverage diagnostics and unmatched annotation IDs on stderr with the authored file and line when available. A forbidden source-tag range retains its `range-in-trace-tag` explanation; an ID not read by the module's declared trace grammar is named with the symbol and guidance to use the declared form (comma-separated lists for `Trace:`). These advisories do not alter the engine's bindings, stdout matrix payload, or strict verdict. Correct comma-list controls emit neither range nor unmatched-tag warnings. | Test | > **PLAT-1150 change note (2026-10-05):** At quire-driver `e7f6e89`, the > engine already records source ranges and IDs dropped after `and`, but matrix diff --git a/src/commands/matrix.rs b/src/commands/matrix.rs index 967c03d..e14315c 100644 --- a/src/commands/matrix.rs +++ b/src/commands/matrix.rs @@ -15,7 +15,6 @@ use serde::Serialize; use crate::commands::coverage::{compute_report, tsv_cell, Target}; use crate::commands::Ctx; -use quire_cli::io; #[derive(Debug, Parser)] pub struct Args { @@ -66,7 +65,7 @@ struct Payload { pub fn run(ctx: &Ctx, args: Args) -> anyhow::Result<()> { let (scope, registry) = args.target.load(ctx)?; let report = compute_report(ctx, &scope, ®istry)?; - // FR-026-AC-15 / PLAT-1150: selecting one view must not discard the + // FR-026-AC-16 / PLAT-1150: selecting one view must not discard the // engine's explanation of tags it refused or could only partly read. for diagnostic in &report.diagnostics { let locus = match (diagnostic.path.as_deref(), diagnostic.line) { @@ -74,34 +73,32 @@ pub fn run(ctx: &Ctx, args: Args) -> anyhow::Result<()> { (Some(path), None) => path.to_string(), (None, _) => "scope".to_string(), }; - io::emit_warning_with_fields( - ctx.diagnostics, - &format!("[{}] {} ({locus})", diagnostic.reason, diagnostic.message), - &io::DiagnosticFields { + let message = format!("[{}] {} ({locus})", diagnostic.reason, diagnostic.message); + ix_cli_kit::streams::Record::warning("MatrixDiagnostic", &message) + .with_fields(ix_cli_kit::streams::DiagnosticFields { reason: Some(&diagnostic.reason), path: diagnostic.path.as_deref(), line: diagnostic.line, - declaration: Some(&diagnostic.declaration), + change_target: Some(&diagnostic.declaration), ..Default::default() - }, - ); + }) + .emit(ctx.diagnostics); } for tag in &report.unmatched_tags { - io::emit_warning_with_fields( - ctx.diagnostics, - &format!( - "{}:{}: `{}` on `{}` was not read by the declared trace grammar; \ - use the module's declared tag form (comma-separated IDs for Trace: lists)", - tag.path, tag.line, tag.trace_id, tag.symbol, - ), - &io::DiagnosticFields { + let message = format!( + "{}:{}: `{}` on `{}` was not read by the declared trace grammar; \ + use the module's declared tag form (comma-separated IDs for Trace: lists)", + tag.path, tag.line, tag.trace_id, tag.symbol, + ); + ix_cli_kit::streams::Record::warning("UnmatchedTraceTag", &message) + .with_fields(ix_cli_kit::streams::DiagnosticFields { reason: Some("unmatched-trace-tag"), path: Some(&tag.path), line: Some(tag.line), subject: Some(&tag.trace_id), ..Default::default() - }, - ); + }) + .emit(ctx.diagnostics); } let matrix = report.coverage_matrix; let verdict = strict_verdict(&matrix); diff --git a/tests/cli_matrix.rs b/tests/cli_matrix.rs index 446a308..8ead02b 100644 --- a/tests/cli_matrix.rs +++ b/tests/cli_matrix.rs @@ -973,7 +973,7 @@ fn tc841_every_matrix_exit_code_is_in_the_fr007_taxonomy() { assert_eq!(seen.into_iter().collect::>(), [0, 1, 3]); } -/// Trace: FR-026-AC-15 +/// Trace: FR-026-AC-16 /// Provenance: PLAT-1150 #[test] fn matrix_explains_ranges_and_partially_read_trace_lists_in_every_format() {