diff --git a/spec/functional/FR-026-matrix-subcommand.md b/spec/functional/FR-026-matrix-subcommand.md index 0413268..ee8ac5f 100644 --- a/spec/functional/FR-026-matrix-subcommand.md +++ b/spec/functional/FR-026-matrix-subcommand.md @@ -266,6 +266,13 @@ 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-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 +> 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 diff --git a/src/commands/matrix.rs b/src/commands/matrix.rs index b8ac961..e14315c 100644 --- a/src/commands/matrix.rs +++ b/src/commands/matrix.rs @@ -64,7 +64,43 @@ 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-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) { + (Some(path), Some(line)) => format!("{path}:{line}"), + (Some(path), None) => path.to_string(), + (None, _) => "scope".to_string(), + }; + 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, + change_target: Some(&diagnostic.declaration), + ..Default::default() + }) + .emit(ctx.diagnostics); + } + for tag in &report.unmatched_tags { + 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); match args.format { diff --git a/tests/cli_matrix.rs b/tests/cli_matrix.rs index 0b3c67e..8ead02b 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-16 +/// 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")); + } +}