Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions spec/functional/FR-026-matrix-subcommand.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<scope>` — not only under `<scope>/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

Expand Down
38 changes: 37 additions & 1 deletion src/commands/matrix.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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, &registry)?.coverage_matrix;
let report = compute_report(ctx, &scope, &registry)?;
// 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 {
Expand Down
55 changes: 55 additions & 0 deletions tests/cli_matrix.rs
Original file line number Diff line number Diff line change
Expand Up @@ -972,3 +972,58 @@ fn tc841_every_matrix_exit_code_is_in_the_fr007_taxonomy() {
}
assert_eq!(seen.into_iter().collect::<Vec<_>>(), [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<Value> = 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"));
}
}
Loading