Files
msd-core/sdk/src/query/phase-uat-passed.ts
Tom Boucher 29a48d8a78 feat(sdk): emit CASE_MISMATCH reason for non-canonical pass casing (#3184)
Cycle 10 of ~15: a captured 'result:' value that lowercases to
'pass' but isn't literally 'pass' now produces a CASE_MISMATCH
reason rather than a generic NON_PASS_RESULT — so operators can
distinguish a real non-pass from a likely typo.
2026-05-18 23:30:38 -04:00

136 lines
3.8 KiB
TypeScript

/**
* isPhaseUatPassed — SDK predicate answering "is phase N's UAT contract satisfied?"
*
* Cycle 2 of ~15: introduces REASON_CODE frozen enum and UatReason typed shape.
* Non-pass items (result not literally 'pass') emit a typed NON_PASS_RESULT reason.
*/
import { readFile, readdir } from 'node:fs/promises';
import { join, relative } from 'node:path';
import { resolvePhaseDir } from './phase-list-queries.js';
export const REASON_CODE = Object.freeze({
NON_PASS_RESULT: 'non_pass_result',
CASE_MISMATCH: 'case_mismatch',
NO_PHASE_DIR: 'no_phase_dir',
NO_UAT_FILES: 'no_uat_files',
} as const);
export type ReasonCode = typeof REASON_CODE[keyof typeof REASON_CODE];
export type UatReason = {
code: ReasonCode;
file?: string;
itemName?: string;
capturedValue?: string;
};
/** Regex to parse all UAT items regardless of result value.
* Accepts optional bold markers (**key:**) around expected/result keys. */
const UAT_ITEM_PATTERN =
/###\s*(\d+)\.\s*([^\n]+)\n(?:\*\*)?expected:(?:\*\*)?\s*([^\n]+)\n(?:\*\*)?result:(?:\*\*)?\s*(\w+)/g;
interface UatItem {
test: number;
name: string;
expected: string;
result: string;
}
/**
* Strip regions from file content that could contain markdown-shaped text
* but should not be treated as UAT items (frontmatter, code fences, etc.).
* Passes are applied in order; each returns a sanitised string.
*/
function stripMarkdownInjection(content: string): string {
// Pass 1: strip YAML frontmatter region (---\n...\n---)
let s = content.replace(/^---\r?\n[\s\S]*?\r?\n---/m, '');
// Pass 2: strip fenced code blocks (``` ... ```)
s = s.replace(/```[\s\S]*?```/g, '');
// Pass 3: strip HTML comment regions (<!-- ... -->)
s = s.replace(/<!--[\s\S]*?-->/g, '');
// Pass 4: strip blockquote-prefixed lines (any line starting with optional whitespace + >)
s = s.replace(/^\s*>.*$/gm, '');
return s;
}
function parseAllUatItems(content: string): UatItem[] {
const sanitised = stripMarkdownInjection(content);
const items: UatItem[] = [];
UAT_ITEM_PATTERN.lastIndex = 0;
let m: RegExpMatchArray | null;
while ((m = UAT_ITEM_PATTERN.exec(sanitised)) !== null) {
const [, num, name, expected, result] = m;
items.push({
test: parseInt(num, 10),
name: name.trim(),
expected: expected.trim(),
result,
});
}
UAT_ITEM_PATTERN.lastIndex = 0;
return items;
}
export async function isPhaseUatPassed(
projectDir: string,
phase: string,
workstream?: string,
): Promise<{
passed: boolean;
reasons: UatReason[];
reasonsHuman: string[];
items: Record<string, unknown>[];
}> {
const dir = await resolvePhaseDir(phase, projectDir, workstream);
if (!dir) {
return {
passed: false,
reasons: [{ code: REASON_CODE.NO_PHASE_DIR }],
reasonsHuman: [],
items: [],
};
}
const files = await readdir(dir);
const uatFiles = files.filter((f) => f.endsWith('-HUMAN-UAT.md'));
if (uatFiles.length === 0) {
return {
passed: false,
reasons: [{ code: REASON_CODE.NO_UAT_FILES }],
reasonsHuman: [],
items: [],
};
}
const items: UatItem[] = [];
const reasons: UatReason[] = [];
for (const file of uatFiles) {
const filePath = join(dir, file);
const relFile = relative(projectDir, filePath);
const content = await readFile(filePath, 'utf-8');
const parsed = parseAllUatItems(content);
for (const item of parsed) {
items.push(item);
if (item.result !== 'pass') {
const code =
item.result.toLowerCase() === 'pass'
? REASON_CODE.CASE_MISMATCH
: REASON_CODE.NON_PASS_RESULT;
reasons.push({
code,
file: relFile,
itemName: item.name,
capturedValue: item.result,
});
}
}
}
const passed = items.length > 0 && reasons.length === 0;
return { passed, reasons, reasonsHuman: [], items };
}