fix(#1159): phase complete ignores historical/deferred requirement metadata (#1197)

* fix(#1159): phase complete ignores historical/deferred requirement metadata

Defect A: `phase complete` emitted a false "has unresolved gaps" warning
when a VERIFICATION.md file's frontmatter contained `status: passed` but
the body contained `previous_status: gaps_found`. The full-text regex
`/status: gaps_found/` matched the substring inside `previous_status:
gaps_found`, producing a spurious warning. Fixed by reading only the
frontmatter `status` key via `extractFrontmatter` in both `phase.cts`
(phase complete warning check) and `commands.cts` (determinePhaseStatus).

Defect B: requirement IDs under explicitly deferred/backlog/future/v2
section headings in REQUIREMENTS.md were flagged as missing from the
Traceability table. Fixed by splitting the body into markdown sections,
detecting deferred-intent headings via a keyword regex, and skipping
those sections when collecting IDs to check against the table.

Both fixes include boundary tests (false positive suppressed; genuine
gap/missing warnings still fire) in 4-phase-complete-cjs-regression.test.cjs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(#1159): address adversarial-review findings (subheading + case sensitivity)

- Defect B: Replace section-split approach with line-by-line depth-tracking
  to correctly propagate deferred status to sub-headings and ignore headings
  inside fenced code blocks. Previously `## Future Backlog` / `### Sub` would
  leak sub-heading IDs (split created a new non-deferred section per heading).
- Defect A/commands: restore case-insensitive status matching via toLowerCase()
  to match the prior /status:\s*passed/i regex semantics.
- Add test #1159-B-4 covering the nested-subheading case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(changeset): backfill PR number for #1159 fix

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-14 00:00:37 -04:00
committed by GitHub
parent 5fa4dcd78c
commit be132445aa
4 changed files with 450 additions and 10 deletions

View File

@@ -110,9 +110,17 @@ function determinePhaseStatus(plans: number, summaries: number, phaseDir: string
const verificationFile = files.find(f => f === 'VERIFICATION.md' || f.endsWith('-VERIFICATION.md'));
if (verificationFile) {
const content = platformReadSync(path.join(phaseDir, verificationFile)) || '';
if (/status:\s*passed/i.test(content)) return 'Complete';
if (/status:\s*human_needed/i.test(content)) return 'Needs Review';
if (/status:\s*gaps_found/i.test(content)) return 'Executed';
// #1159 (Defect A): read ONLY the frontmatter `status` key to avoid false
// matches from historical body metadata such as `previous_status: gaps_found`.
// Full-text regexes like /status:\s*gaps_found/ match the substring inside
// `previous_status: gaps_found`, producing incorrect phase status labels.
const fm = extractFrontmatter(content) as Record<string, unknown>;
// Normalise to lower-case to preserve the prior case-insensitive behaviour
// while reading only the frontmatter `status` key (not the full body text).
const fmStatus = typeof fm['status'] === 'string' ? fm['status'].trim().toLowerCase() : '';
if (fmStatus === 'passed') return 'Complete';
if (fmStatus === 'human_needed') return 'Needs Review';
if (fmStatus === 'gaps_found') return 'Executed';
// Verification exists but unrecognized status — treat as executed
return 'Executed';
}

View File

@@ -1373,8 +1373,16 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
(f) => f.includes('-VERIFICATION') && f.endsWith('.md'),
)) {
const content = fs.readFileSync(path.join(phaseFullDir, file), 'utf-8');
if (/status: human_needed/.test(content)) warnings.push(`${file}: needs human verification`);
if (/status: gaps_found/.test(content)) warnings.push(`${file}: has unresolved gaps`);
// #1159 (Defect A): read ONLY the frontmatter `status` key to avoid false positives
// from historical metadata in the file body (e.g. `previous_status: gaps_found`).
// A full-text regex like /status: gaps_found/ matches the substring inside
// `previous_status: gaps_found`, producing spurious warnings even when the
// current frontmatter status is `passed`.
const verFm = extractFrontmatter(content) as Record<string, unknown>;
// Normalise to lower-case so `status: Passed` (title-case) is not missed.
const verStatus = typeof verFm['status'] === 'string' ? verFm['status'].trim().toLowerCase() : '';
if (verStatus === 'human_needed') warnings.push(`${file}: needs human verification`);
if (verStatus === 'gaps_found') warnings.push(`${file}: has unresolved gaps`);
}
} catch {
/* intentionally empty */
@@ -1495,12 +1503,58 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
}
}
// #1159 (Defect B): collect requirement IDs only from ACTIVE sections.
// Requirements under headings whose text contains "deferred", "backlog",
// "future", or "v2" (case-insensitive) are explicitly out of current scope
// and must not be flagged as missing from the Traceability table.
//
// Strategy: walk lines, track heading depth, and toggle a "deferred" flag
// when a heading matching the pattern is encountered. A sub-heading (higher
// depth) that is ITSELF in a deferred parent remains deferred unless it
// opens a same-or-shallower heading that does NOT match the pattern.
// Lines inside fenced code blocks (``` or ~~~) are treated as content, not
// headings, to avoid false deferred-section detection from code examples.
const DEFERRED_HEADING_RE = /\b(?:deferred|backlog|future|v\d+)\b/i;
const bodyReqIds: string[] = [];
const bodyReqPattern = /\*\*([A-Z][A-Z0-9]*-\d+)\*\*/g;
let bodyMatch: RegExpExecArray | null;
while ((bodyMatch = bodyReqPattern.exec(reqContent)) !== null) {
const id = bodyMatch[1];
if (!bodyReqIds.includes(id)) bodyReqIds.push(id);
// deferredDepth: the heading level that opened the current deferred block,
// or 0 when we are in an active section.
let deferredDepth = 0;
let inFence = false;
for (const line of reqContent.split(/\r?\n/)) {
// Track fenced code blocks (``` or ~~~).
if (/^\s*(?:```|~~~)/.test(line)) {
inFence = !inFence;
continue;
}
if (inFence) continue; // ignore content inside a code fence
const headingM = line.match(/^(#{1,6})\s+(.*)/);
if (headingM) {
const depth = headingM[1].length;
const text = headingM[2];
if (deferredDepth > 0 && depth > deferredDepth) {
// Sub-heading inside a deferred block: stays deferred regardless of name.
continue;
}
// Heading at same level or shallower than current deferred opener,
// or no active deferred block yet.
if (DEFERRED_HEADING_RE.test(text)) {
deferredDepth = depth; // enter a deferred block
} else {
deferredDepth = 0; // back in an active section
}
continue;
}
if (deferredDepth > 0) continue; // skip content in deferred sections
// Collect bold REQ-ID patterns from active-section lines.
const reqPat = /\*\*([A-Z][A-Z0-9]*-\d+)\*\*/g;
let bodyMatch: RegExpExecArray | null;
while ((bodyMatch = reqPat.exec(line)) !== null) {
const id = bodyMatch[1];
if (!bodyReqIds.includes(id)) bodyReqIds.push(id);
}
}
const traceabilityHeadingMatch = reqContent.match(/^#{1,6}\s+Traceability\b/im);