* fix(#3597): count scenario expectation failures in the QA ratchet gate buildReport counts totals.violations as oracle violations PLUS scenario expectFailures, but collectFindings read only step.violations. A scenario whose declared expect failed therefore produced ok:false and violations:1 in the report while the ratchet printed "0 violations" and exited 0. multi-workstream has failed that way on every CI run since 2026-08-10, when #3217 (PR #3318) made computeProgressPercent withhold a percentage whose scope is not COMPLETE. The walk detected the change the day it landed; nothing was listening. - collectFindings returns a third bucket, expectationFailures, carrying no fingerprint so it can never be baselined or acked away - both modes of main() print and gate on it; the summary line reports it - guard runMain(main) behind require.main === module, so the QA suite can require the script to test collectFindings without running a real walk (that import side effect is why the gate logic had no test) - multi-workstream now asserts the true contract: phase_scope unreadable and percent null, per ADR-3180 7.6 rule 4 - the perturbation test asserts scenario ok, closing the test-side half Closes #3597 * fix(#3597): resolve the milestone window against the active workstream listMilestonePhaseDirs defaulted its ws option to null. planningDir treats undefined as "resolve the ambient workstream" and null as "force the project root", so that default suppressed the ambient resolution every other planning-path read uses. All 18 call sites derive phasesDir ambiently via planningPaths(cwd), so the counts came from the workstream while the milestone window came from the root .planning/ROADMAP.md — the exact numerator/denominator scope split ADR-3180 7.6 rule 3 forbids. workstream create migrates that root roadmap away, so the read threw and scope stayed UNREADABLE, and rule 4 then correctly withheld the percentage. Proof: with a workstream tree byte-unchanged, copying its own ROADMAP to the project root flipped --ws alpha progress from phase_scope:unreadable/percent:null to complete/100. This is the defect the loop QA walk was pointing at all along; the scenario expectation is restored to percent:100 rather than bent to match the bug. - pass ws through as undefined so ambient resolution applies - multi-workstream asserts phase_scope complete + percent 100 - regression test in completion-ratio-scope-withholding covers a workstream-only project with no root ROADMAP - replace the vacuous require.main test: runMain defers through a promise, so the in-process timing check passed against the unguarded file too; a child-process spawn now observes the guard for real - tie the oracle-violation test to expectationFailures, and cover the absent-key, multi-scenario and zero-step report shapes in parity - flatten scenario-authored strings before rendering them into the step summary and CI logs (forged markdown / ANSI injection) - widen the scenario contract assertions past perturbation-* so multi-workstream is actually covered test-side Closes #3597 * fix(#3597): flatten scenario-authored strings on the CI-log output path The step-summary path already routed findings through flattenUntrusted; the check-mode NEW-smell and STALE-entry console.error blocks, and the repro line in both printers, still interpolated raw. detail carries a scenario-authored expect[].path verbatim, and reason/scenario/id come from contributor-authored baseline and ack fragments validated only as non-empty strings. A crafted path could print a forged summary line into the CI log directly above the real one, plus ANSI repaint and unbounded length. Exit codes are unaffected — this is log spoofing, not gate bypass. * fix(#3597): refuse to archive on an unreadable milestone window; close review gaps Resolving the milestone window against the active workstream can leave the window UNREADABLE when that workstream has no ROADMAP of its own. getMilestonePhaseFilter throws, the window degrades to a pass-all fallback, and milestone complete would then move every phase dir -- breaking the guarantee stated at the archive site that no out-of-window directory is touched. milestone complete now refuses to archive when the window is UNREADABLE and reports the refusal; --dry-run previews the same refusal from the same shared derivation. The guard is scoped to UNREADABLE, not to every non-COMPLETE scope. A broader condition regressed ordinary root projects: the QA walk caught milestone-rollover leaving 01-parser on disk, which then tripped the #1447 abort in phases clear. UNSCOPED and TRUNCATED are pre-existing classifications and keep their existing behavior. Review fixes: - the workstream regression test asserted complete/100 but its fixture wrote no workstream STATE.md, so it resolved unscoped/null and the test failed; it now asserts a milestone and genuinely fails-first - the parity test hand-supplied totals.violations, hardcoding the very formula under test; at least one case now goes through the real buildReport - drop a vacuous qa-report.json assertion (jsonOut defaults to null, so no report is written by either shape) - buildRepro emitted a repo-relative binary path after cd-ing into a temp project, so every repro died with MODULE_NOT_FOUND; it now resolves an absolute path - flattenUntrusted truncated the repro to 300 chars, handing reviewers a command that looks complete and is not; length capping is now opt-out for repro while newline/control/backtick stripping still applies * chore(#3597): backfill changeset pr number (#3607) --------- Co-authored-by: sim <sim@local>
219 lines
9.1 KiB
JavaScript
219 lines
9.1 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* report.cjs — turns an array of `runScenario` reports (see `scenario.cjs`)
|
|
* into a single, serializable `qa-report.json` document.
|
|
*
|
|
* WHY THIS FILE EXISTS
|
|
* ────────────────────
|
|
* `runScenario` reports one walk at a time and is deliberately silent about
|
|
* anything cross-scenario (totals, a merged smell index, a human-runnable
|
|
* repro line). This module is the aggregation seam: `buildReport` takes the
|
|
* raw per-scenario reports plus run metadata and produces one plain object;
|
|
* `writeReport` serializes it to disk. Neither function performs a CLI
|
|
* invocation, discovers scenario files, or reads the clock — see
|
|
* `run-report.cjs` for the executable that wires this to the filesystem and
|
|
* to `LoopWalk`/`runOracles`.
|
|
*
|
|
* DETERMINISM: `buildReport` never calls `Date.now()` / `new Date()` — the
|
|
* caller supplies `meta.generatedAt`. A report builder that stamped its own
|
|
* wall-clock time would make two builds of the exact same walk compare as
|
|
* different documents, which defeats diffing/reviewing a report in CI.
|
|
*
|
|
* REPRO LINES ARE ALWAYS STRINGS: `step.repro` is either a real,
|
|
* copy-pasteable `cd <dir> && node <absolute path to gsd-tools.cjs> ...`
|
|
* command (plus a trailing `#` shell-comment note about the pinned clock and
|
|
* cleared env — see `buildRepro`), or a string clearly prefixed
|
|
* `NOT RUNNABLE: ...` explaining why (the tree was not preserved, or the
|
|
* step declared no CLI invocation at all). A repro line that *looks*
|
|
* runnable but points at a directory that was already deleted is worse than
|
|
* no repro line, so the two cases are never conflated into one shape that
|
|
* "sometimes has a command".
|
|
*/
|
|
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
/** Bump when the shape of the emitted report document changes incompatibly. */
|
|
const REPORT_VERSION = 1;
|
|
|
|
/**
|
|
* Build a single copy-pasteable repro command/explanation for one step.
|
|
*
|
|
* The emitted `node` path is ABSOLUTE (resolved from this file's own
|
|
* location via `__dirname`), never the repo-relative
|
|
* `gsd-core/bin/gsd-tools.cjs` — `preservedDir` is a temp project directory
|
|
* unrelated to the repo checkout, so `cd`ing into it first and then
|
|
* resolving a repo-relative path throws `MODULE_NOT_FOUND` (#3597).
|
|
*
|
|
* @param {{preservedDir?: string, argv: string[]}} params
|
|
* @returns {string}
|
|
*/
|
|
function buildRepro({ preservedDir, argv }) {
|
|
if (!Array.isArray(argv) || argv.length === 0) {
|
|
return 'NOT RUNNABLE: this step declared no CLI invocation (no "run" array) — there is nothing to reproduce.';
|
|
}
|
|
if (!preservedDir) {
|
|
return 'NOT RUNNABLE: the scenario tree was not preserved for this run — re-run with `--keep` (or `GSD_QA_KEEP=1`) to get a reproducible command.';
|
|
}
|
|
const gsdToolsPath = path.resolve(__dirname, '..', '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
|
|
// The trailing `# ...` is a shell comment, not part of the command: pasting
|
|
// the whole line (including the note) into a shell still runs correctly,
|
|
// since everything from `#` to end-of-line is ignored. This keeps the
|
|
// string a single copy-pasteable line while telling a human reader the
|
|
// walk also pins a clock and clears ambient env vars this repro cannot.
|
|
return `cd ${preservedDir} && node ${gsdToolsPath} --json-errors ${argv.join(' ')}`
|
|
+ ' # note: the walk also pins GSD_NOW_MS and clears ambient GSD_* vars, so results may differ slightly';
|
|
}
|
|
|
|
/**
|
|
* Merge one scenario's already-computed `smellSummary` (see
|
|
* `scenario.cjs`'s `summarizeSmells`) into the running whole-report index.
|
|
*
|
|
* @param {Map<string, {count: number, examples: string[]}>} byId
|
|
* @param {Array<{id: string, count: number, examples: string[]}>} smellSummary
|
|
*/
|
|
function mergeSmellSummary(byId, smellSummary) {
|
|
for (const entry of smellSummary || []) {
|
|
const existing = byId.get(entry.id) || { count: 0, examples: [] };
|
|
existing.count += entry.count;
|
|
if (existing.examples.length < 3) {
|
|
existing.examples = existing.examples.concat(entry.examples).slice(0, 3);
|
|
}
|
|
byId.set(entry.id, existing);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build the plain, serializable report document from an array of
|
|
* `runScenario(...)` reports and run metadata. Never throws away input it
|
|
* cannot classify — a scenario report with an unrecognized shape fails loud
|
|
* (naming the offending index) rather than silently producing a hollow
|
|
* document.
|
|
*
|
|
* @param {Array<{
|
|
* name: string,
|
|
* ok: boolean,
|
|
* fixture?: string,
|
|
* steps: Array<{at: string, argv: string[], kind: string|null,
|
|
* expectFailures: string[], oracleFailures: {id:string,detail:string}[],
|
|
* smells: {id:string,detail:string}[], mutation: {id:string,target:string}|null,
|
|
* mutationNoop: boolean, mutationObserved: boolean}>,
|
|
* smellSummary?: Array<{id:string, count:number, examples:string[]}>,
|
|
* preservedDir?: string,
|
|
* }>} scenarioReports the array of objects returned by `runScenario`
|
|
* (optionally carrying a `fixture` field attached by the caller — see
|
|
* `run-report.cjs`, which knows the scenario's `fixture` even though
|
|
* `runScenario`'s own return value does not).
|
|
* @param {{nodeVersion: string, platform: string, generatedAt: string}} meta
|
|
* caller-supplied run metadata. `generatedAt` MUST be supplied by the
|
|
* caller (e.g. `new Date().toISOString()`) — this function never reads the
|
|
* clock itself, to keep its output deterministic and reviewable.
|
|
* @returns {object} the plain report document (see this file's header for
|
|
* its top-level shape).
|
|
*/
|
|
function buildReport(scenarioReports, meta) {
|
|
if (!Array.isArray(scenarioReports)) {
|
|
throw new Error(`buildReport: scenarioReports must be an array, got ${JSON.stringify(scenarioReports)}`);
|
|
}
|
|
if (!meta || typeof meta !== 'object') {
|
|
throw new Error(`buildReport: meta must be an object, got ${JSON.stringify(meta)}`);
|
|
}
|
|
for (const key of ['nodeVersion', 'platform', 'generatedAt']) {
|
|
if (typeof meta[key] !== 'string' || meta[key] === '') {
|
|
throw new Error(`buildReport: meta.${key} must be a non-empty string, got ${JSON.stringify(meta[key])}`);
|
|
}
|
|
}
|
|
|
|
let totalSteps = 0;
|
|
let totalViolations = 0;
|
|
let mutationsApplied = 0;
|
|
let mutationsObserved = 0;
|
|
/** @type {Map<string, {count: number, examples: string[]}>} */
|
|
const smellById = new Map();
|
|
|
|
const scenarios = scenarioReports.map((sr, index) => {
|
|
if (!sr || typeof sr !== 'object' || typeof sr.name !== 'string' || !Array.isArray(sr.steps)) {
|
|
throw new Error(`buildReport: scenarioReports[${index}] does not look like a runScenario report, got ${JSON.stringify(sr)}`);
|
|
}
|
|
|
|
const preservedDir = typeof sr.preservedDir === 'string' ? sr.preservedDir : undefined;
|
|
|
|
const steps = sr.steps.map((step) => {
|
|
totalSteps += 1;
|
|
const violations = step.oracleFailures || [];
|
|
const expectFailures = step.expectFailures || [];
|
|
totalViolations += violations.length + expectFailures.length;
|
|
|
|
if (step.mutation && !step.mutationNoop) mutationsApplied += 1;
|
|
if (step.mutationObserved) mutationsObserved += 1;
|
|
|
|
return {
|
|
at: step.at,
|
|
argv: Array.isArray(step.argv) ? step.argv : [],
|
|
kind: step.kind,
|
|
expectFailures,
|
|
violations,
|
|
smells: step.smells || [],
|
|
mutation: step.mutation || null,
|
|
mutationNoop: !!step.mutationNoop,
|
|
mutationObserved: !!step.mutationObserved,
|
|
repro: buildRepro({ preservedDir, argv: step.argv }),
|
|
};
|
|
});
|
|
|
|
mergeSmellSummary(smellById, sr.smellSummary);
|
|
|
|
return {
|
|
name: sr.name,
|
|
ok: !!sr.ok,
|
|
fixture: typeof sr.fixture === 'string' ? sr.fixture : null,
|
|
steps,
|
|
...(preservedDir ? { preservedDir } : {}),
|
|
};
|
|
});
|
|
|
|
const totalSmells = [...smellById.values()].reduce((sum, entry) => sum + entry.count, 0);
|
|
const smellSummary = [...smellById.entries()]
|
|
.map(([id, entry]) => ({ id, count: entry.count, examples: entry.examples }))
|
|
.sort((a, b) => a.id.localeCompare(b.id));
|
|
|
|
return {
|
|
reportVersion: REPORT_VERSION,
|
|
meta,
|
|
totals: {
|
|
scenarios: scenarios.length,
|
|
steps: totalSteps,
|
|
violations: totalViolations,
|
|
smells: totalSmells,
|
|
mutationsApplied,
|
|
mutationsObserved,
|
|
},
|
|
scenarios,
|
|
smellSummary,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Serialize `reportObject` to `outPath` as pretty-printed JSON, creating any
|
|
* missing parent directories, and return the absolute path written.
|
|
*
|
|
* @param {object} reportObject
|
|
* @param {string} outPath
|
|
* @returns {string} the absolute path the report was written to.
|
|
*/
|
|
function writeReport(reportObject, outPath) {
|
|
if (!reportObject || typeof reportObject !== 'object') {
|
|
throw new Error(`writeReport: reportObject must be an object, got ${JSON.stringify(reportObject)}`);
|
|
}
|
|
if (typeof outPath !== 'string' || outPath === '') {
|
|
throw new Error(`writeReport: outPath must be a non-empty string, got ${JSON.stringify(outPath)}`);
|
|
}
|
|
const abs = path.resolve(outPath);
|
|
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
fs.writeFileSync(abs, `${JSON.stringify(reportObject, null, 2)}\n`, 'utf-8');
|
|
return abs;
|
|
}
|
|
|
|
module.exports = { buildReport, writeReport, REPORT_VERSION };
|