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:
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user