feat(sdk): flag headings missing the result field (#3184)

Cycle 12 of ~15: heading-without-result-line was previously
silently dropped by the regex — meaning a phase with an
unfilled UAT item could falsely pass the predicate. We now
scan for orphan headings and emit ORPHAN_ITEM_MISSING_RESULT
so the operator's typo / unfilled-template is surfaced.
This commit is contained in:
Tom Boucher
2026-05-18 22:53:37 -04:00
parent d7aa4ce6e3
commit f78f26688b
2 changed files with 97 additions and 0 deletions

View File

@@ -306,6 +306,39 @@ result: pass
}
});
it("emits ORPHAN_ITEM_MISSING_RESULT reason for headings missing the result field", async () => {
const localTmp = await mkdtemp(join(tmpdir(), 'gsd-uat-c12-'));
try {
const phaseDir = join(localTmp, '.planning', 'phases', '05-orphan-heading');
await mkdir(phaseDir, { recursive: true });
const content = `---
status: complete
phase: 5
source: roadmap
started: 2026-05-18T00:00:00Z
updated: 2026-05-18T00:00:00Z
---
### 1. Forgot to fill this in
expected: something
### 2. Real one
expected: works
result: pass
`;
await writeFile(join(phaseDir, '05-HUMAN-UAT.md'), content);
const result = await isPhaseUatPassed(localTmp, '5');
expect(result.passed).toBe(false);
expect(result.items.length).toBe(1);
expect(result.reasons.length).toBe(1);
expect(result.reasons[0].code).toBe(REASON_CODE.ORPHAN_ITEM_MISSING_RESULT);
expect(result.reasons[0].itemName).toBe('Forgot to fill this in');
} finally {
await rm(localTmp, { recursive: true, force: true });
}
});
it("emits CASE_MISMATCH reason when result value is \"PASS\" (uppercase variant of pass)", async () => {
const localTmp = await mkdtemp(join(tmpdir(), 'gsd-uat-c10-'));
try {

View File

@@ -15,6 +15,9 @@ export const REASON_CODE = Object.freeze({
NON_PASS_RESULT: 'non_pass_result',
CASE_MISMATCH: 'case_mismatch',
HUMAN_VERIFICATION_NEEDED: 'human_verification_needed',
ORPHAN_ITEM_MISSING_RESULT: 'orphan_item_missing_result',
BRACKETED_PLACEHOLDER: 'bracketed_placeholder',
NO_ITEMS_EXTRACTED: 'no_items_extracted',
NO_PHASE_DIR: 'no_phase_dir',
NO_UAT_FILES: 'no_uat_files',
} as const);
@@ -75,6 +78,33 @@ function parseAllUatItems(content: string): UatItem[] {
return items;
}
const HEADING_PATTERN = /###\s*(\d+)\.\s*([^\n]+)/g;
/**
* Scan stripped body for `### N. Name` headings whose number is NOT represented
* in the set of captured item numbers. Returns orphan entries.
* Headings that have a bracketed result line are excluded here — they will be
* handled by bracketed-placeholder detection (cycle 13).
*/
function findOrphanHeadings(
strippedBody: string,
capturedNumbers: Set<number>,
brackPlaceholderNumbers: Set<number>,
): Array<{ num: number; name: string }> {
const orphans: Array<{ num: number; name: string }> = [];
HEADING_PATTERN.lastIndex = 0;
let m: RegExpMatchArray | null;
while ((m = HEADING_PATTERN.exec(strippedBody)) !== null) {
const num = parseInt(m[1], 10);
const name = m[2].trim();
if (!capturedNumbers.has(num) && !brackPlaceholderNumbers.has(num)) {
orphans.push({ num, name });
}
}
HEADING_PATTERN.lastIndex = 0;
return orphans;
}
export async function isPhaseUatPassed(
projectDir: string,
phase: string,
@@ -114,6 +144,7 @@ export async function isPhaseUatPassed(
const filePath = join(dir, file);
const relFile = relative(projectDir, filePath);
const content = await readFile(filePath, 'utf-8');
const strippedBody = stripMarkdownInjection(content);
const parsed = parseAllUatItems(content);
for (const item of parsed) {
items.push(item);
@@ -131,6 +162,39 @@ export async function isPhaseUatPassed(
}
}
// Detect bracketed placeholders (cycle 13): headings with result: [value]
const brackPlaceholderNumbers = new Set<number>();
const BRACK_RESULT_PATTERN = /result:\s*\[(\w+)\]/g;
let bm: RegExpMatchArray | null;
BRACK_RESULT_PATTERN.lastIndex = 0;
while ((bm = BRACK_RESULT_PATTERN.exec(strippedBody)) !== null) {
// find nearest preceding heading
const before = strippedBody.slice(0, bm.index);
const headingMatch = before.match(/###\s*(\d+)\.\s*([^\n]+)\s*$/);
if (headingMatch) {
const num = parseInt(headingMatch[1], 10);
const name = headingMatch[2].trim();
brackPlaceholderNumbers.add(num);
reasons.push({
code: REASON_CODE.BRACKETED_PLACEHOLDER,
file: relFile,
itemName: name,
capturedValue: `[${bm[1]}]`,
});
}
}
// Detect orphan headings: headings with no captured item and no bracketed result.
const capturedNumbers = new Set(parsed.map((i) => i.test));
const orphans = findOrphanHeadings(strippedBody, capturedNumbers, brackPlaceholderNumbers);
for (const orphan of orphans) {
reasons.push({
code: REASON_CODE.ORPHAN_ITEM_MISSING_RESULT,
file: relFile,
itemName: orphan.name,
});
}
// Merge frontmatter human_verification items into the roster.
const fm = extractFrontmatter(content);
const fmItems = parseVerificationFrontmatterItems(fm);