fix(#4794): decision-coverage answers an unmeasured shape on could-not-parse; a non-file context path fails closed (#4889)
* test(#4794): failing-first — could-not-parse must answer an unmeasured shape; a directory context path fails closed * fix(#4794): could-not-parse answers an unmeasured shape (null counts, unreadable ids, no uncovered); a non-file context path fails closed * chore(#4794): backfill changeset PR number (4889) * test(#4794): skip the directory-identity probe when the platform cannot discriminate (windows runner volume collapse, measured) Two consecutive windows conformance runs failed the probe with measured identical (dev, ino) for two distinct mkdtemp directories (dev=3606225537, ino=9007199255243448 for both) — a runner-volume property, not a regression in the guard. On such a platform the guard's identity containment degrades to refuse-everything (fail-closed, documented); the probe asserts capability, so the honest response is an explicit t.skip carrying the measurement (ADR-2719 §6), not a red lane for every PR. --------- Co-authored-by: sim <sim@local>
This commit is contained in:
@@ -292,11 +292,12 @@ function buildVerifyMessage(notHonored: UncoveredItem[]): string {
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function loadDecisionExtraction(contextPath: string): { trackable: Decision[]; outcome: 'parsed' | 'none-present' | 'could-not-parse' } {
|
||||
function loadDecisionExtraction(contextPath: string): { trackable: Decision[]; outcome: 'parsed' | 'none-present' | 'could-not-parse'; unreadableIds: string[] } {
|
||||
const extraction = extractDecisions(readIfExists(contextPath));
|
||||
return {
|
||||
trackable: extraction.decisions.filter((d) => d.trackable),
|
||||
outcome: extraction.outcome,
|
||||
unreadableIds: extraction.unreadableIds ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -347,23 +348,48 @@ function cmdDecisionCoveragePlan(projectDir: string, args: string[], raw: boolea
|
||||
output({ passed: true, skipped: true, reason: 'CONTEXT.md missing', total: 0, covered: 0, uncovered: [], message: 'No CONTEXT.md - nothing to check.' }, raw, undefined);
|
||||
return;
|
||||
}
|
||||
// #4794: a NON-FILE path (a directory — the adjacent same-looking positional
|
||||
// swapped, the issue's repro 2) is a caller error like #2770's empty argument:
|
||||
// fs.existsSync is true, the read yields nothing, and the gate used to
|
||||
// certify passed:true on a phase full of decisions. Fail closed, naming it.
|
||||
// The stat is wrapped: a path that vanishes between existsSync and statSync
|
||||
// (or any stat failure) must answer the SAME fail-closed JSON, never a throw.
|
||||
let contextIsFile = false;
|
||||
let contextKind = 'non-file entry';
|
||||
try {
|
||||
const st = fs.statSync(contextPath);
|
||||
contextIsFile = st.isFile();
|
||||
if (st.isDirectory()) contextKind = 'directory';
|
||||
} catch {
|
||||
contextIsFile = false;
|
||||
contextKind = 'unreadable path';
|
||||
}
|
||||
if (!contextIsFile) {
|
||||
output({ passed: false, skipped: false, reason: 'context path is not a file', total: null, covered: null, message: `Decision coverage gate: the context path "${contextArg}" is not a readable file (${contextKind}). Swap the adjacent positionals or pass --context <path-to-CONTEXT.md>.` }, raw, undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const { trackable: decisions, outcome } = loadDecisionExtraction(contextPath);
|
||||
const { trackable: decisions, outcome, unreadableIds } = loadDecisionExtraction(contextPath);
|
||||
|
||||
// #1365 fail-loud gate: any could-not-parse outcome must NOT silently pass —
|
||||
// even when some decisions were extracted (e.g. D-01 valid but D-02 malformed).
|
||||
// A parse-miss on ANY bullet means the gate cannot certify full coverage.
|
||||
// Fire independent of decisions.length so a partial-parse still blocks.
|
||||
if (outcome === 'could-not-parse') {
|
||||
// #4794: nothing was measured — the answer must not carry the fields of a
|
||||
// gate that did. total/covered are null (a type change is the point:
|
||||
// 0 reads as data, null does not), `uncovered` is OMITTED (the list was
|
||||
// never built), and the ids that failed to parse are carried so a caller
|
||||
// capturing stdout knows which decision to fix.
|
||||
const partialParse = decisions.length > 0;
|
||||
output({
|
||||
passed: false,
|
||||
skipped: false,
|
||||
reason: 'could-not-parse',
|
||||
total: decisions.length,
|
||||
covered: 0,
|
||||
uncovered: [],
|
||||
message: partialParse
|
||||
total: null,
|
||||
covered: null,
|
||||
unreadable: unreadableIds,
|
||||
message: (partialParse
|
||||
? 'Decision coverage gate: decisions could not be fully parsed — one or more ' +
|
||||
'`- **D-NN ...**` bullets appear malformed (missing `:` or ` — ` separator, or a phase ' +
|
||||
'prefix that is not a digit run, e.g. `D4x-01`). Fix the bullet format so all decisions ' +
|
||||
@@ -373,7 +399,8 @@ function cmdDecisionCoveragePlan(projectDir: string, args: string[], raw: boolea
|
||||
'or D- tokens) but no decision bullets could be extracted. Check the formatting of the decisions ' +
|
||||
'block and ensure bullets follow the `- **D-NN:** text`, `- **D4-NN:** text` (phase-prefixed), ' +
|
||||
'or `- **D-NN — title** body` form. An ID grammar the parser does not support (e.g. `DEC-01`) ' +
|
||||
'also lands here.',
|
||||
'also lands here.')
|
||||
+ (unreadableIds.length > 0 ? ' Unreadable ids: ' + unreadableIds.join(', ') + '.' : ''),
|
||||
}, raw, undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -52,6 +52,14 @@ export type DecisionOutcome = 'parsed' | 'none-present' | 'could-not-parse';
|
||||
export interface DecisionExtraction {
|
||||
decisions: Decision[];
|
||||
outcome: DecisionOutcome;
|
||||
/**
|
||||
* #4794: the attempted D-ids of bullets that fell to the parse-miss guard on
|
||||
* a could-not-parse outcome — the ids a JSON caller needs to name what to
|
||||
* fix. Empty when no id could be derived from a missed line; may be ABSENT
|
||||
* on the evidence-based could-not-parse path (no guard-matched line exists
|
||||
* to derive an id from).
|
||||
*/
|
||||
unreadableIds?: string[];
|
||||
}
|
||||
|
||||
const DISCRETION_HEADINGS = new Set([
|
||||
@@ -431,6 +439,8 @@ function joinWrappedBoldLeadIns(lines: string[]): string[] {
|
||||
interface ParseDecisionLinesResult {
|
||||
decisions: Decision[];
|
||||
parseMisses: number;
|
||||
/** #4794: the attempted D-ids of bullets that fell to the parse-miss guard. */
|
||||
unreadableIds: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -453,6 +463,7 @@ function parseDecisionLines(block: string): ParseDecisionLinesResult {
|
||||
let current: Decision | null = null;
|
||||
let openIndent: number | null = null;
|
||||
let parseMisses = 0;
|
||||
const unreadableIds: string[] = [];
|
||||
|
||||
const flush = (): void => {
|
||||
if (current) {
|
||||
@@ -561,6 +572,10 @@ function parseDecisionLines(block: string): ParseDecisionLinesResult {
|
||||
if (parseMissGuardRe.test(line)) {
|
||||
flush();
|
||||
parseMisses += 1;
|
||||
// #4794: carry the attempted id so a caller capturing stdout (a JSON gate)
|
||||
// can name the decision to fix — today it exists only in this stderr warn.
|
||||
const attempted = line.match(/\*\*\s*(D(?:[0-9][A-Za-z0-9]*)?-[A-Za-z0-9_-]*)/);
|
||||
if (attempted && !unreadableIds.includes(attempted[1])) unreadableIds.push(attempted[1]);
|
||||
console.warn(`parseDecisions: ignored unparseable decision bullet: ${trimmed}`);
|
||||
continue;
|
||||
}
|
||||
@@ -578,7 +593,7 @@ function parseDecisionLines(block: string): ParseDecisionLinesResult {
|
||||
}
|
||||
}
|
||||
flush();
|
||||
return { decisions: out, parseMisses };
|
||||
return { decisions: out, parseMisses, unreadableIds };
|
||||
}
|
||||
|
||||
// ─── Primary entry point: extractDecisions ────────────────────────────────────
|
||||
@@ -607,13 +622,13 @@ export function extractDecisions(content: unknown): DecisionExtraction {
|
||||
const taggedBlocks = extractTaggedBlocks(stripped, 'decisions');
|
||||
if (taggedBlocks.length > 0) {
|
||||
const combined = taggedBlocks.join('\n\n');
|
||||
const { decisions, parseMisses } = parseDecisionLines(combined);
|
||||
const { decisions, parseMisses, unreadableIds } = parseDecisionLines(combined);
|
||||
if (decisions.length > 0 && parseMisses === 0) {
|
||||
return { decisions, outcome: 'parsed' };
|
||||
}
|
||||
// FIX B: parse-misses present — could-not-parse even if some decisions extracted.
|
||||
if (parseMisses > 0) {
|
||||
return { decisions, outcome: 'could-not-parse' };
|
||||
return { decisions, outcome: 'could-not-parse', unreadableIds };
|
||||
}
|
||||
// FIX A: Block present but 0 extracted and no parse-misses.
|
||||
// Only report could-not-parse when there is genuine evidence of real decisions
|
||||
@@ -640,13 +655,13 @@ export function extractDecisions(content: unknown): DecisionExtraction {
|
||||
);
|
||||
|
||||
if (section !== null) {
|
||||
const { decisions, parseMisses } = parseDecisionLines(section.body);
|
||||
const { decisions, parseMisses, unreadableIds } = parseDecisionLines(section.body);
|
||||
if (decisions.length > 0 && parseMisses === 0) {
|
||||
return { decisions, outcome: 'parsed' };
|
||||
}
|
||||
// FIX B: parse-misses present — could-not-parse even if some decisions extracted.
|
||||
if (parseMisses > 0) {
|
||||
return { decisions, outcome: 'could-not-parse' };
|
||||
return { decisions, outcome: 'could-not-parse', unreadableIds };
|
||||
}
|
||||
// FIX A: Heading found but 0 extracted and no parse-misses.
|
||||
// Report could-not-parse when the section body holds a decision-entry-shaped
|
||||
|
||||
Reference in New Issue
Block a user