* fix(#2402): honor response_language across orchestrator output + UAT checkpoint renderer Replays the in-flight bot branch fix/2402-response-language-orchestrator-coverage (seven commits, never pushed) onto current origin/next as a single squashed commit. The original work was substantial and correct; this commit preserves its full scope, trimmed where rebase conflicts + workflow size budgets required it. Three independent layers where response_language was being dropped are closed: Layer 1 — orchestrator-facing directives across workflows. Adds the strong "All user-facing output in this workflow MUST be presented in {response_language}; technical terms, code, paths, and subagent prompts stay in English" directive to ~40 workflows that previously either lacked it entirely (verify-work, new-project, new-milestone, quick, manager, and ~35 more) or carried only the weak subagent-prompt-only form (plan-phase, execute-phase). The directive covers narration between tool calls and banner output, not just the AskUserQuestion prompts. Layer 2 — UAT checkpoint renderer (src/uat.cts). buildCheckpoint now accepts an optional responseLanguage parameter and renders the frame strings ("CHECKPOINT: Verification Required", "Type `pass` or describe what's wrong.") in any of 9 languages (English/Spanish/French/German/Portuguese/Japanese/ Chinese/Korean/Italian) with an alias table covering ~30 input variants (en, es, español, ja, 日本語, etc.). cmdRenderCheckpoint reads config.response_language via loadConfig(cwd) and passes it through, so the byte-for-byte block verify-work.md reprints verbatim is already localized when written — preserving the anti-injection hygiene rule at verify-work.md (the model is forbidden to translate after the fact). CJK display width is computed by East Asian Width property ranges (W/F) so the right ║ border of the banner stays aligned for full-width characters. English fallback is byte-identical to the pre-fix behavior when response_language is unset or unrecognized. Layer 3 — literal English report templates in execute-phase. The top-of- workflow directive covers all template sites (templates are a structural source, not literal output). Inline render-language notes that previously sat at each template site were removed during the squash because they pushed execute-phase.md over its frozen pre-phase-6 byte ceiling (93600 — ADR-857 Phase 6 capstone). The single top directive covers the same surface with fewer bytes. Also extends src/docs.cts and src/init.cts to propagate response_language into the init JSON bundle of the additional workflows so the directive can read it. Tests added: - tests/uat.test.cjs: buildCheckpoint with unset/unrecognized language falls back to English default; recognized language swaps only the two frame strings while structural lines stay untouched; CJK display-width regression (independent recomputation of East Asian Width W/F ranges). - tests/workspace.test.cjs, tests/docs-update.test.cjs: response_language wiring through docs.cts/init.cts. References: #2402; reporter's three-layer triage + Layer-4 follow-up; the byte-for-byte anti-injection hygiene rule at verify-work.md (the reason Layer 2 must be renderer-side, not model-translated). This is a squash of the in-flight bot branch — seven commits representing the original implementation plus its subsequent fix/CJK-padding/test/ changeset/regen cycles, none of which were ever pushed or PR'd. The squash captures the final coherent state. * chore(#2402): backfill pr:2457 in .changeset/2402-response-language-orchestrator-coverage.md * chore(#2402): regen golden + size baseline after rebase against #2315 (PR #2451) Rebase conflicts were entirely in generated artifacts (golden-install-parity fixtures + workflow-size-baseline.json). After taking theirs during rebase, regenerated cleanly against the merged source tree.
956 lines
41 KiB
TypeScript
956 lines
41 KiB
TypeScript
/**
|
|
* UAT Audit — Cross-phase UAT/VERIFICATION scanner
|
|
*
|
|
* Reads all *-UAT.md and *-VERIFICATION.md files across all phases.
|
|
* Extracts non-passing items. Returns structured JSON for workflow consumption.
|
|
*
|
|
* ADR-457 build-at-publish: the hand-written bin/lib/uat.cjs collapsed
|
|
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
|
|
* from the prior hand-written .cjs; only strict types are added.
|
|
*/
|
|
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
import io = require('./io.cjs');
|
|
const { output, error } = io;
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
import markdownSectionizer = require('./markdown-sectionizer.cjs');
|
|
const { collectSection, tokenizeHeadings } = markdownSectionizer;
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
import markdownTable = require('./markdown-table.cjs');
|
|
const { splitTableRow } = markdownTable;
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
import roadmapParser = require('./roadmap-parser.cjs');
|
|
const { getMilestonePhaseFilter } = roadmapParser;
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
import coreUtils = require('./core-utils.cjs');
|
|
const { toPosixPath } = coreUtils;
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
import planningWorkspace = require('./planning-workspace.cjs');
|
|
const { planningDir } = planningWorkspace;
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
import frontmatter = require('./frontmatter.cjs');
|
|
const { extractFrontmatter } = frontmatter;
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
import phaseIdMod = require('./phase-id.cjs');
|
|
const { PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod;
|
|
import { requireSafePath, sanitizeForDisplay } from './security.cjs';
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- config-loader.cjs is an export= CommonJS module
|
|
import configLoader = require('./config-loader.cjs');
|
|
const { loadConfig } = configLoader;
|
|
|
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
|
|
type UatResult = string;
|
|
type UatCategory = 'server_blocked' | 'device_needed' | 'build_needed' | 'third_party' | 'blocked' | 'skipped_unresolved' | 'pending' | 'human_uat' | 'unknown' | 'deferred';
|
|
|
|
interface UatItem {
|
|
test?: number;
|
|
name: string;
|
|
expected?: string;
|
|
result: UatResult;
|
|
category: UatCategory;
|
|
reason?: string;
|
|
blocked_by?: string;
|
|
}
|
|
|
|
interface UatFileResult {
|
|
phase: string;
|
|
phase_dir: string;
|
|
file: string;
|
|
file_path: string;
|
|
type: 'uat' | 'verification' | 'deferred';
|
|
status: string;
|
|
items: UatItem[];
|
|
}
|
|
|
|
interface CurrentTest {
|
|
complete: boolean;
|
|
number?: number;
|
|
name?: string;
|
|
expected?: string;
|
|
}
|
|
|
|
// ─── cmdAuditUat ─────────────────────────────────────────────────────────────
|
|
|
|
function cmdAuditUat(cwd: string, raw: boolean): void {
|
|
const phasesDir = path.join(planningDir(cwd), 'phases');
|
|
if (!fs.existsSync(phasesDir)) {
|
|
error('No phases directory found in planning directory');
|
|
}
|
|
|
|
const isDirInMilestone = getMilestonePhaseFilter(cwd);
|
|
const results: UatFileResult[] = [];
|
|
|
|
// Scan all phase directories
|
|
const dirs = fs.readdirSync(phasesDir, { withFileTypes: true })
|
|
.filter(e => e.isDirectory())
|
|
.map(e => e.name)
|
|
.filter(isDirInMilestone)
|
|
.sort();
|
|
|
|
for (const dir of dirs) {
|
|
const phaseMatch = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
|
|
const phaseNum = phaseMatch ? phaseMatch[1] : dir;
|
|
const phaseDir = path.join(phasesDir, dir);
|
|
const files = fs.readdirSync(phaseDir);
|
|
|
|
// Process UAT files
|
|
for (const file of files.filter(f => f.includes('-UAT') && f.endsWith('.md'))) {
|
|
const content = fs.readFileSync(path.join(phaseDir, file), 'utf-8');
|
|
const items = parseUatItems(content);
|
|
if (items.length > 0) {
|
|
results.push({
|
|
phase: phaseNum,
|
|
phase_dir: dir,
|
|
file,
|
|
file_path: toPosixPath(path.relative(cwd, path.join(phaseDir, file))),
|
|
type: 'uat',
|
|
status: (extractFrontmatter(content).status as string || 'unknown'),
|
|
items,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Process VERIFICATION files
|
|
for (const file of files.filter(f => f.includes('-VERIFICATION') && f.endsWith('.md'))) {
|
|
const content = fs.readFileSync(path.join(phaseDir, file), 'utf-8');
|
|
const status = extractFrontmatter(content).status as string || 'unknown';
|
|
if (status === 'human_needed' || status === 'gaps_found') {
|
|
const items = parseVerificationItems(content, status);
|
|
if (items.length > 0) {
|
|
results.push({
|
|
phase: phaseNum,
|
|
phase_dir: dir,
|
|
file,
|
|
file_path: toPosixPath(path.relative(cwd, path.join(phaseDir, file))),
|
|
type: 'verification',
|
|
status,
|
|
items,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Process deferred-items.md (#2287) — the SCOPE BOUNDARY convention
|
|
// (agents/gsd-executor.md) has the executor log out-of-scope discoveries
|
|
// to this file; nothing previously read it back. Surface every
|
|
// UNRESOLVED entry (see parseDeferredItems for the resolved/unresolved
|
|
// parsing rule) as a 'deferred'-typed result, keeping deferred-items.md
|
|
// itself the single source of truth — no duplicate pending-todo entry
|
|
// required.
|
|
const deferredFile = 'deferred-items.md';
|
|
if (files.includes(deferredFile)) {
|
|
const content = fs.readFileSync(path.join(phaseDir, deferredFile), 'utf-8');
|
|
const items = parseDeferredItems(content);
|
|
if (items.length > 0) {
|
|
results.push({
|
|
phase: phaseNum,
|
|
phase_dir: dir,
|
|
file: deferredFile,
|
|
file_path: toPosixPath(path.relative(cwd, path.join(phaseDir, deferredFile))),
|
|
type: 'deferred',
|
|
status: 'unresolved',
|
|
items,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Compute summary
|
|
const summary: {
|
|
total_files: number;
|
|
total_items: number;
|
|
by_category: Record<string, number>;
|
|
by_phase: Record<string, number>;
|
|
} = {
|
|
total_files: results.length,
|
|
total_items: results.reduce((sum, r) => sum + r.items.length, 0),
|
|
by_category: {},
|
|
by_phase: {},
|
|
};
|
|
|
|
for (const r of results) {
|
|
if (!summary.by_phase[r.phase]) summary.by_phase[r.phase] = 0;
|
|
for (const item of r.items) {
|
|
summary.by_phase[r.phase]++;
|
|
const cat = item.category || 'unknown';
|
|
summary.by_category[cat] = (summary.by_category[cat] || 0) + 1;
|
|
}
|
|
}
|
|
|
|
output({ results, summary }, raw, undefined);
|
|
}
|
|
|
|
// ─── cmdRenderCheckpoint ──────────────────────────────────────────────────────
|
|
|
|
function cmdRenderCheckpoint(cwd: string, options: { file?: string } = {}, raw: boolean): void {
|
|
const filePath = options.file;
|
|
if (!filePath) {
|
|
error('UAT file required: use uat render-checkpoint --file <path>');
|
|
}
|
|
|
|
const resolvedPath = requireSafePath(filePath, cwd, 'UAT file', { allowAbsolute: true });
|
|
if (!fs.existsSync(resolvedPath)) {
|
|
error(`UAT file not found: ${filePath}`);
|
|
}
|
|
|
|
const content = fs.readFileSync(resolvedPath, 'utf-8');
|
|
const currentTest = parseCurrentTest(content);
|
|
|
|
if (currentTest.complete) {
|
|
error('UAT session is already complete; no pending checkpoint to render');
|
|
}
|
|
|
|
const config = loadConfig(cwd);
|
|
const responseLanguage = typeof config.response_language === 'string' ? config.response_language : undefined;
|
|
const checkpoint = buildCheckpoint(currentTest as Required<Omit<CurrentTest, 'complete'>> & { complete: false }, responseLanguage);
|
|
output({
|
|
file_path: toPosixPath(path.relative(cwd, resolvedPath)),
|
|
test_number: currentTest.number,
|
|
test_name: currentTest.name,
|
|
checkpoint,
|
|
}, raw, checkpoint);
|
|
}
|
|
|
|
// ─── parseCurrentTest ─────────────────────────────────────────────────────────
|
|
|
|
function parseCurrentTest(content: string): CurrentTest {
|
|
// Use the seam to locate the ## Current Test section (ADR-1372 T5).
|
|
// HTML-comment stripping within the section body is UAT-specific, so we keep
|
|
// the comment removal caller-side after extracting the body.
|
|
const currentTestSection = collectSection(
|
|
content,
|
|
(h) => /^current\s+test$/i.test(h.text) && h.level === 2,
|
|
{ levelBounded: true },
|
|
);
|
|
if (!currentTestSection) {
|
|
error('UAT file is missing a Current Test section');
|
|
}
|
|
|
|
// Remove any leading HTML comment block (UAT-specific document structure)
|
|
const rawBody = currentTestSection!.body.replace(/^<!--[\s\S]*?-->\s*\n?/, '');
|
|
const section = rawBody.trimEnd();
|
|
if (!section.trim()) {
|
|
error('Current Test section is empty');
|
|
}
|
|
|
|
if (/\[testing complete\]/i.test(section)) {
|
|
return { complete: true };
|
|
}
|
|
|
|
const numberMatch = section.match(/^number:\s*(\d+)\s*$/m);
|
|
const nameMatch = section.match(/^name:\s*(.+)\s*$/m);
|
|
const expectedBlockMatch = section.match(/^expected:\s*\|\n([\s\S]*?)(?=^\w[\w-]*:\s)/m)
|
|
|| section.match(/^expected:\s*\|\n([\s\S]+)/m);
|
|
const expectedInlineMatch = section.match(/^expected:\s*(.+)\s*$/m);
|
|
|
|
if (!numberMatch || !nameMatch || (!expectedBlockMatch && !expectedInlineMatch)) {
|
|
if (!numberMatch && !nameMatch && !expectedBlockMatch && !expectedInlineMatch) {
|
|
const pendingTest = parseFirstPendingTest(content);
|
|
if (pendingTest) {
|
|
return pendingTest;
|
|
}
|
|
error('Current Test section is non-structured and no pending UAT test remains to resume');
|
|
}
|
|
error('Current Test section is malformed');
|
|
}
|
|
|
|
let expected: string;
|
|
if (expectedBlockMatch) {
|
|
expected = expectedBlockMatch[1]
|
|
.split('\n')
|
|
.map((line: string) => line.replace(/^ {2}/, ''))
|
|
.join('\n')
|
|
.trim();
|
|
} else {
|
|
expected = expectedInlineMatch![1].trim();
|
|
}
|
|
|
|
return {
|
|
complete: false,
|
|
number: parseInt(numberMatch![1], 10),
|
|
name: sanitizeForDisplay(nameMatch![1].trim()),
|
|
expected: sanitizeForDisplay(expected),
|
|
};
|
|
}
|
|
|
|
function parseFirstPendingTest(content: string): CurrentTest | null {
|
|
// Use the seam to locate the ## Tests section (ADR-1372 T5).
|
|
const testsSection = collectSection(
|
|
content,
|
|
(h) => /^tests$/i.test(h.text) && h.level === 2,
|
|
{ levelBounded: true },
|
|
);
|
|
if (!testsSection) {
|
|
return null;
|
|
}
|
|
|
|
const sectionBody = testsSection.body;
|
|
|
|
// Within the Tests section body, find ### N. Name sub-headings.
|
|
// tokenizeHeadings operates on the section body as a standalone document,
|
|
// filtering to level-3 headings matching the UAT-specific "N. Name" pattern.
|
|
// The UAT-specific item parsing (number extraction, result parsing) stays caller-side.
|
|
const subHeadings = tokenizeHeadings(sectionBody).filter(
|
|
(h) => h.level === 3 && /^\d+\.\s+/.test(h.text),
|
|
);
|
|
|
|
for (let i = 0; i < subHeadings.length; i += 1) {
|
|
const current = subHeadings[i];
|
|
const next = subHeadings[i + 1];
|
|
// Slice the block for this sub-test from the section body text
|
|
const block = next
|
|
? sectionBody.slice(current.offset, next.offset)
|
|
: sectionBody.slice(current.offset);
|
|
|
|
if (!/^result:\s*\[?pending\]?\s*$/im.test(block)) {
|
|
continue;
|
|
}
|
|
|
|
// Extract the UAT-specific number and name from the heading text
|
|
const headingParts = current.text.match(/^(\d+)\.\s+(.+)$/);
|
|
if (!headingParts) continue;
|
|
const testNumber = parseInt(headingParts[1], 10);
|
|
const testName = headingParts[2].trim();
|
|
|
|
const expected = parseExpectedFromTestBlock(block);
|
|
if (!expected) {
|
|
error(`Pending UAT test ${testNumber} is missing an expected field`);
|
|
}
|
|
|
|
return {
|
|
complete: false,
|
|
number: testNumber,
|
|
name: sanitizeForDisplay(testName),
|
|
expected: sanitizeForDisplay(expected),
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function parseExpectedFromTestBlock(block: string): string | null {
|
|
const expectedBlockMatch = block.match(/^expected:\s*\|\n([\s\S]*?)(?=^\w[\w-]*:\s)/m)
|
|
|| block.match(/^expected:\s*\|\n([\s\S]+)/m);
|
|
if (expectedBlockMatch) {
|
|
return expectedBlockMatch[1]
|
|
.split('\n')
|
|
.map((line: string) => line.replace(/^ {2}/, ''))
|
|
.join('\n')
|
|
.trim();
|
|
}
|
|
|
|
const expectedInlineMatch = block.match(/^expected:\s*(.+)\s*$/m);
|
|
return expectedInlineMatch ? expectedInlineMatch[1].trim() : null;
|
|
}
|
|
|
|
// ─── buildCheckpoint ──────────────────────────────────────────────────────────
|
|
//
|
|
// Localized frame strings (#2402): the checkpoint banner + instruction line are
|
|
// the byte-for-byte block verify-work.md reprints verbatim, so the model can't
|
|
// translate it after the fact — the frame must already be in `response_language`
|
|
// when this function returns it. Bounded table with an ENGLISH FALLBACK for
|
|
// unset/unrecognized languages keeps the default path byte-identical.
|
|
|
|
interface CheckpointFrame {
|
|
banner: string;
|
|
instruction: string;
|
|
}
|
|
|
|
const CHECKPOINT_BOX_WIDTH = 64; // total column width of the ╔══...╗ border, borders stay byte-identical
|
|
|
|
const CHECKPOINT_FRAMES: Record<string, CheckpointFrame> = {
|
|
english: {
|
|
banner: 'CHECKPOINT: Verification Required',
|
|
instruction: 'Type `pass` or describe what\'s wrong.',
|
|
},
|
|
spanish: {
|
|
banner: 'PUNTO DE CONTROL: Verificación requerida',
|
|
instruction: 'Escribe `pass` o describe qué está mal.',
|
|
},
|
|
french: {
|
|
banner: 'POINT DE CONTRÔLE : Vérification requise',
|
|
instruction: 'Tapez `pass` ou décrivez ce qui ne va pas.',
|
|
},
|
|
german: {
|
|
banner: 'KONTROLLPUNKT: Überprüfung erforderlich',
|
|
instruction: 'Gib `pass` ein oder beschreibe, was nicht stimmt.',
|
|
},
|
|
portuguese: {
|
|
banner: 'PONTO DE VERIFICAÇÃO: Verificação necessária',
|
|
instruction: 'Digite `pass` ou descreva o que está errado.',
|
|
},
|
|
japanese: {
|
|
banner: 'チェックポイント: 検証が必要です',
|
|
instruction: '`pass` と入力するか、問題点を説明してください。',
|
|
},
|
|
chinese: {
|
|
banner: '检查点:需要验证',
|
|
instruction: '输入 `pass` 或描述问题所在。',
|
|
},
|
|
korean: {
|
|
banner: '체크포인트: 검증 필요',
|
|
instruction: '`pass`를 입력하거나 문제를 설명하세요.',
|
|
},
|
|
italian: {
|
|
banner: 'PUNTO DI CONTROLLO: Verifica richiesta',
|
|
instruction: 'Digita `pass` o descrivi cosa non va.',
|
|
},
|
|
};
|
|
|
|
// Free-form response_language aliases → canonical CHECKPOINT_FRAMES key.
|
|
const CHECKPOINT_LANGUAGE_ALIASES: Record<string, string> = {
|
|
english: 'english', en: 'english', 'en-us': 'english', 'en-gb': 'english',
|
|
spanish: 'spanish', es: 'spanish', 'español': 'spanish', espanol: 'spanish', castellano: 'spanish',
|
|
french: 'french', fr: 'french', 'français': 'french', francais: 'french',
|
|
german: 'german', de: 'german', deutsch: 'german',
|
|
portuguese: 'portuguese', pt: 'portuguese', 'pt-br': 'portuguese', 'português': 'portuguese', portugues: 'portuguese', 'brazilian portuguese': 'portuguese',
|
|
japanese: 'japanese', ja: 'japanese', '日本語': 'japanese',
|
|
chinese: 'chinese', zh: 'chinese', 'zh-cn': 'chinese', 'zh-tw': 'chinese', mandarin: 'chinese', 'simplified chinese': 'chinese', 'traditional chinese': 'chinese', '中文': 'chinese',
|
|
korean: 'korean', ko: 'korean', '한국어': 'korean',
|
|
italian: 'italian', it: 'italian', italiano: 'italian',
|
|
};
|
|
|
|
function resolveCheckpointFrame(responseLanguage: string | undefined): CheckpointFrame {
|
|
if (!responseLanguage) return CHECKPOINT_FRAMES.english;
|
|
const key = CHECKPOINT_LANGUAGE_ALIASES[responseLanguage.trim().toLowerCase()];
|
|
return (key && CHECKPOINT_FRAMES[key]) || CHECKPOINT_FRAMES.english;
|
|
}
|
|
|
|
// Approximate East Asian Width ranges (Unicode property values W and F) — the
|
|
// CJK scripts CHECKPOINT_FRAMES ships (Japanese/Chinese/Korean) render each
|
|
// matching code point at 2 terminal/display columns, not 1. Padding computed
|
|
// from `.length` (UTF-16 code units) undercounts these by one column per
|
|
// wide character, visually misaligning the box's right border (#2402 review
|
|
// medium finding). Latin-script frames (English/Spanish/French/German/
|
|
// Portuguese/Italian) contain no wide code points, so displayWidth === length
|
|
// for them — no behavior change there.
|
|
function isWideCodePoint(codePoint: number): boolean {
|
|
return (
|
|
(codePoint >= 0x1100 && codePoint <= 0x115f) || // Hangul Jamo
|
|
codePoint === 0x2329 || codePoint === 0x232a ||
|
|
(codePoint >= 0x2e80 && codePoint <= 0x303e) || // CJK Radicals .. CJK Symbols and Punctuation
|
|
(codePoint >= 0x3041 && codePoint <= 0x33ff) || // Hiragana .. CJK Compatibility
|
|
(codePoint >= 0x3400 && codePoint <= 0x4dbf) || // CJK Unified Ideographs Extension A
|
|
(codePoint >= 0x4e00 && codePoint <= 0x9fff) || // CJK Unified Ideographs
|
|
(codePoint >= 0xa000 && codePoint <= 0xa4cf) || // Yi Syllables
|
|
(codePoint >= 0xac00 && codePoint <= 0xd7a3) || // Hangul Syllables
|
|
(codePoint >= 0xf900 && codePoint <= 0xfaff) || // CJK Compatibility Ideographs
|
|
(codePoint >= 0xfe30 && codePoint <= 0xfe4f) || // CJK Compatibility Forms
|
|
(codePoint >= 0xff00 && codePoint <= 0xff60) || // Fullwidth Forms
|
|
(codePoint >= 0xffe0 && codePoint <= 0xffe6) ||
|
|
(codePoint >= 0x20000 && codePoint <= 0x3fffd) // CJK Unified Ideographs Extension B+ / supplementary
|
|
);
|
|
}
|
|
|
|
// Iterates by Unicode code point (not UTF-16 code unit) so astral characters
|
|
// are measured once, not as two surrogate units.
|
|
function displayWidth(text: string): number {
|
|
let width = 0;
|
|
for (const ch of text) {
|
|
width += isWideCodePoint(ch.codePointAt(0) as number) ? 2 : 1;
|
|
}
|
|
return width;
|
|
}
|
|
|
|
// Pads `text` into a `║ text… ║` line matching CHECKPOINT_BOX_WIDTH. Content
|
|
// that overflows the box (a longer translated string) is left unpadded rather
|
|
// than truncated — a slightly ragged border beats losing text.
|
|
function checkpointBoxLine(text: string): string {
|
|
const innerWidth = CHECKPOINT_BOX_WIDTH - 2;
|
|
const content = ` ${text}`;
|
|
const padLength = innerWidth - displayWidth(content);
|
|
const padded = padLength > 0 ? content + ' '.repeat(padLength) : content;
|
|
return `║${padded}║`;
|
|
}
|
|
|
|
function buildCheckpoint(currentTest: { number: number; name: string; expected: string }, responseLanguage?: string): string {
|
|
const frame = resolveCheckpointFrame(responseLanguage);
|
|
return [
|
|
'╔══════════════════════════════════════════════════════════════╗',
|
|
checkpointBoxLine(frame.banner),
|
|
'╚══════════════════════════════════════════════════════════════╝',
|
|
'',
|
|
`**Test ${currentTest.number}: ${currentTest.name}**`,
|
|
'',
|
|
currentTest.expected,
|
|
'',
|
|
'──────────────────────────────────────────────────────────────',
|
|
frame.instruction,
|
|
'──────────────────────────────────────────────────────────────',
|
|
].join('\n');
|
|
}
|
|
|
|
// ─── parseUatItems ────────────────────────────────────────────────────────────
|
|
|
|
function parseUatItems(content: string): UatItem[] {
|
|
const items: UatItem[] = [];
|
|
// Match test blocks: ### N. Name\nexpected: ...\nresult: ...\n
|
|
// Accept both bare (result: pending) and bracketed (result: [pending]) formats (#2273)
|
|
const testPattern = /###\s*(\d+)\.\s*([^\n]+)\nexpected:\s*([^\n]+)\nresult:\s*\[?(\w+)\]?(?:\n(?:reported|reason|blocked_by):\s*[^\n]*)?/g;
|
|
let match: RegExpExecArray | null;
|
|
while ((match = testPattern.exec(content)) !== null) {
|
|
const [, num, name, expected, result] = match;
|
|
if (result === 'pending' || result === 'skipped' || result === 'blocked') {
|
|
// Extract optional fields — limit to current test block (up to next ### or EOF)
|
|
const afterMatch = content.slice(match.index);
|
|
const nextHeading = afterMatch.indexOf('\n###', 1);
|
|
const blockText = nextHeading > 0 ? afterMatch.slice(0, nextHeading) : afterMatch;
|
|
const reasonMatch = blockText.match(/reason:\s*(.+)/);
|
|
const blockedByMatch = blockText.match(/blocked_by:\s*(.+)/);
|
|
|
|
const item: UatItem = {
|
|
test: parseInt(num, 10),
|
|
name: name.trim(),
|
|
expected: expected.trim(),
|
|
result,
|
|
category: categorizeItem(result, reasonMatch?.[1], blockedByMatch?.[1]),
|
|
};
|
|
if (reasonMatch) item.reason = reasonMatch[1].trim();
|
|
if (blockedByMatch) item.blocked_by = blockedByMatch[1].trim();
|
|
items.push(item);
|
|
}
|
|
}
|
|
items.push(...parseGapsItems(content));
|
|
return items;
|
|
}
|
|
|
|
// ─── parseGapsItems ───────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Extract unresolved entries from a UAT file's `## Gaps` section (#2286).
|
|
*
|
|
* `## Gaps` records open findings as a YAML-lite bullet list (see
|
|
* `templates/UAT.md`'s `## Gaps` block: `- truth: "..."` followed by indented
|
|
* continuation lines `status:` / `reason:` / `severity:` / `test:` / etc.,
|
|
* and — for `artifacts:` / `missing:` — a further-nested `- ` sub-list).
|
|
* `parseUatItems`'s `### N.` test-block regex never looks at this section at
|
|
* all, so a UAT file whose only outstanding findings live in `## Gaps` was
|
|
* silently invisible — the false-negative this fix addresses.
|
|
*
|
|
* Reuses the existing `collectSection` seam (already used elsewhere in this
|
|
* file for `## Current Test` / `## Tests`) to locate the section. Field
|
|
* extraction is deliberately NOT done via `iterateBullets`: that seam folds
|
|
* every continuation line onto ONE space-joined `text` string per bullet,
|
|
* which erases line boundaries — a `key:` scan against that flattened text
|
|
* matches the FIRST `key:`-shaped substring anywhere, including one that
|
|
* happens to appear inside an EARLIER field's own quoted free-text value
|
|
* (e.g. `truth: "The status: resolved workflow should trigger"` — a real
|
|
* `status: failed` on the next line would never be reached, silently
|
|
* DROPPING a genuinely open gap — the exact false-negative class #2286
|
|
* exists to fix, so the fix must not reintroduce it). `splitGapsEntries` /
|
|
* `extractGapEntryFields` below instead walk the section PER LINE and only
|
|
* recognise a field at the START of its own (trimmed) line, so a `key:`
|
|
* embedded inside another field's quoted value can never be mistaken for a
|
|
* field declaration.
|
|
*
|
|
* Every entry whose `status` is present and NOT `resolved` (case-insensitive)
|
|
* is surfaced — mirroring the "ignore passing/resolved" convention already
|
|
* used for `### N.` test blocks (`result: pass` is never surfaced) and the
|
|
* VERIFICATION table-row PASS/resolved skip (`hasPassResult`, below). An
|
|
* entry with NO parseable `status:` field is surfaced too, as `result:
|
|
* 'unknown'` — #2286 is a false-NEGATIVE bug, and a `## Gaps` entry only
|
|
* exists to record an outstanding finding (a template-conformant RESOLVED
|
|
* entry always carries an explicit `status: resolved`); a garbled or
|
|
* non-conformant entry is far more likely to be an unresolved finding whose
|
|
* `status:` line failed to parse than a genuinely resolved one, so the
|
|
* fail-safe direction is to surface it rather than silently drop it.
|
|
*/
|
|
function parseGapsItems(content: string): UatItem[] {
|
|
const gapsSection = collectSection(
|
|
content,
|
|
(h) => /^gaps$/i.test(h.text) && h.level === 2,
|
|
{ levelBounded: true },
|
|
);
|
|
if (!gapsSection) return [];
|
|
|
|
const items: UatItem[] = [];
|
|
|
|
for (const entryLines of splitGapsEntries(gapsSection.body)) {
|
|
const fields = extractGapEntryFields(entryLines);
|
|
const rawStatus = fields.status;
|
|
if (rawStatus && rawStatus.toLowerCase() === 'resolved') continue;
|
|
// Fail-safe: missing/garbled status surfaces as 'unknown' rather than
|
|
// being dropped (see doc comment above).
|
|
const status = rawStatus || 'unknown';
|
|
|
|
const truth = fields.truth;
|
|
const reason = fields.reason;
|
|
const testNum = fields.test;
|
|
|
|
const item: UatItem = {
|
|
name: truth || rawGapEntryText(entryLines),
|
|
result: status,
|
|
category: categorizeItem(status, reason, undefined),
|
|
};
|
|
if (testNum && /^\d+$/.test(testNum)) item.test = parseInt(testNum, 10);
|
|
if (reason) item.reason = reason;
|
|
items.push(item);
|
|
}
|
|
|
|
return items;
|
|
}
|
|
|
|
// ─── parseDeferredItems ────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Extract unresolved entries from a phase directory's `deferred-items.md`
|
|
* (#2287) — the SCOPE BOUNDARY convention `agents/gsd-executor.md` instructs
|
|
* the executor to follow: "Log out-of-scope discoveries to `deferred-items.md`
|
|
* in the phase directory". Nothing previously read this file back, so a
|
|
* deferred entry was permanently invisible outside the phase directory.
|
|
*
|
|
* The writer convention (unchanged by this fix, per the issue's stated
|
|
* out-of-scope) emits a plain bullet list, typically under a `## Deferred
|
|
* Items` heading (see the issue's own reproduction fixture), one entry per
|
|
* top-level `- ` line with optional indented continuation lines. There is no
|
|
* mandated heading text, so if no `## Deferred Items`-shaped level-2 heading
|
|
* is found, the WHOLE file is scanned as the entry list — fail-safe, so an
|
|
* agent writing a differently-headed (or headless) deferred-items.md still
|
|
* has its entries surfaced rather than silently skipped.
|
|
*
|
|
* Reuses the same per-line field/entry-splitting seams as `parseGapsItems`
|
|
* (`splitGapsEntries`, `extractGapEntryFields`, `rawGapEntryText`) — an entry
|
|
* is RESOLVED only when it carries an explicit `status: resolved` field
|
|
* (case-insensitive), mirroring the established Gaps convention so a human or
|
|
* follow-up agent can mark a deferred item done in place, keeping
|
|
* `deferred-items.md` the single source of truth (no duplicate
|
|
* `.planning/todos/pending/*.md` entry required). Every other entry —
|
|
* including one with no `status:` field at all — is UNRESOLVED and is
|
|
* surfaced.
|
|
*/
|
|
function parseDeferredItems(content: string): UatItem[] {
|
|
const deferredSection = collectSection(
|
|
content,
|
|
(h) => /^deferred\s+items$/i.test(h.text) && h.level === 2,
|
|
{ levelBounded: true },
|
|
);
|
|
const sectionBody = deferredSection ? deferredSection.body : content;
|
|
|
|
const items: UatItem[] = [];
|
|
|
|
for (const entryLines of splitGapsEntries(sectionBody)) {
|
|
const fields = extractGapEntryFields(entryLines);
|
|
const rawStatus = fields.status;
|
|
if (rawStatus && rawStatus.toLowerCase() === 'resolved') continue;
|
|
|
|
const text = rawGapEntryText(entryLines);
|
|
if (!text) continue;
|
|
|
|
items.push({
|
|
name: text,
|
|
result: 'unresolved',
|
|
category: 'deferred',
|
|
});
|
|
}
|
|
|
|
return items;
|
|
}
|
|
|
|
/**
|
|
* Split a `## Gaps` section body into per-entry line groups on TOP-LEVEL
|
|
* `- ` bullet openers.
|
|
*
|
|
* The indentation of the FIRST bullet line encountered establishes the
|
|
* "top-level" indent for the whole section; any subsequent `- `-opening line
|
|
* at that same indent (or shallower) starts a NEW entry, while everything
|
|
* more deeply indented — field continuation lines (` status: ...`) AND
|
|
* nested sub-lists (` - src/foo.ts` under ` artifacts:`) — is folded into
|
|
* the CURRENT entry. This keeps a `artifacts:`/`missing:` sub-list's `- `
|
|
* items from being mis-split into spurious standalone entries (#2286 review
|
|
* LOW finding).
|
|
*
|
|
* Lines before the first bullet (e.g. the `<!-- YAML format ... -->` comment
|
|
* the template emits) are discarded. An empty/whitespace-only section body
|
|
* (heading present, no bullets) returns `[]`.
|
|
*/
|
|
function splitGapsEntries(sectionBody: string): string[][] {
|
|
const lines = sectionBody.split('\n');
|
|
const entries: string[][] = [];
|
|
let current: string[] | null = null;
|
|
let baseIndent: number | null = null;
|
|
|
|
for (const rawLine of lines) {
|
|
const line = rawLine.replace(/\r$/, '');
|
|
const bulletMatch = line.match(/^(\s*)-\s/);
|
|
if (bulletMatch) {
|
|
const indent = bulletMatch[1].length;
|
|
if (baseIndent === null) baseIndent = indent;
|
|
if (indent <= baseIndent) {
|
|
if (current) entries.push(current);
|
|
current = [line];
|
|
continue;
|
|
}
|
|
}
|
|
if (current) current.push(line);
|
|
// else: pre-first-bullet content (e.g. the template's HTML comment) — discarded.
|
|
}
|
|
if (current) entries.push(current);
|
|
|
|
return entries;
|
|
}
|
|
|
|
/**
|
|
* Extract `key: value` fields from one Gaps entry's lines, anchored to the
|
|
* START of each (bullet-marker-stripped, trimmed) line — never scanning the
|
|
* REST of a line, so a colon-bearing phrase inside a quoted `truth`/`reason`
|
|
* value is never misread as a field declaration (see `parseGapsItems`'s doc
|
|
* comment for the false-negative this specifically guards against).
|
|
*
|
|
* Recognises a double-quoted value (`truth: "..."`, stripped of its wrapping
|
|
* quotes — the value may itself contain any character, including `:`) or a
|
|
* bare value (`status: open`, `test: 2`, `artifacts: []`) taken verbatim.
|
|
* The FIRST occurrence of a given key wins (top-level fields always precede
|
|
* any nested sub-list content in the template's field ordering); later
|
|
* `key:`-shaped nested-list content is captured, if it parses as one, but
|
|
* never overrides an already-seen top-level field.
|
|
*/
|
|
function extractGapEntryFields(entryLines: string[]): Record<string, string> {
|
|
const fields: Record<string, string> = {};
|
|
const fieldLineRe = /^([A-Za-z_][A-Za-z0-9_-]*):\s*(.*)$/;
|
|
|
|
entryLines.forEach((rawLine, idx) => {
|
|
const line = rawLine.replace(/\r$/, '');
|
|
// Strip ONLY the entry-opening bullet marker (idx 0); a bullet marker on
|
|
// a later line belongs to a nested sub-list and is handled by
|
|
// `splitGapsEntries` already folding it in — it is not itself a field
|
|
// line unless it independently matches `key: value` after stripping.
|
|
const bulletStripped = line.match(/^(\s*)-\s+(.*)$/);
|
|
const content = idx === 0 && bulletStripped ? bulletStripped[2] : line.trim();
|
|
|
|
const m = fieldLineRe.exec(content);
|
|
if (!m) return;
|
|
const key = m[1];
|
|
let value = m[2].trim();
|
|
if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
|
|
value = value.slice(1, -1);
|
|
}
|
|
if (!(key in fields)) fields[key] = value;
|
|
});
|
|
|
|
return fields;
|
|
}
|
|
|
|
/** Fallback display text for a Gaps entry with no parseable `truth:` field. */
|
|
function rawGapEntryText(entryLines: string[]): string {
|
|
return entryLines
|
|
.map((l, i) => (i === 0 ? l.replace(/^(\s*)-\s+/, '') : l.trim()))
|
|
.join(' ')
|
|
.trim();
|
|
}
|
|
|
|
// ─── parseVerificationItems ───────────────────────────────────────────────────
|
|
|
|
function parseVerificationItems(content: string, status: string): UatItem[] {
|
|
const items: UatItem[] = [];
|
|
if (status === 'human_needed') {
|
|
// #2286: the frontmatter's structured `human_verification:` YAML array
|
|
// (extractFrontmatter) is the PRIMARY source of truth when present and
|
|
// non-empty — it fully bypasses the body-shape scan below, so a file
|
|
// whose frontmatter declares the array doesn't require any particular
|
|
// `## Human Verification` body shape at all. An absent or empty array
|
|
// (length 0) falls back to the body scan unchanged.
|
|
const frontmatter = extractFrontmatter(content);
|
|
const humanVerification = frontmatter.human_verification;
|
|
if (Array.isArray(humanVerification) && humanVerification.length > 0) {
|
|
humanVerification.forEach((entry, idx) => {
|
|
items.push({
|
|
test: idx + 1,
|
|
name: normalizeHumanVerificationEntry(entry),
|
|
result: 'human_needed',
|
|
category: 'human_uat',
|
|
});
|
|
});
|
|
return items;
|
|
}
|
|
|
|
// Use the seam to locate the ## Human Verification section (ADR-1372 T5).
|
|
const hvSection = collectSection(
|
|
content,
|
|
(h) => /^human\s+verification/i.test(h.text) && h.level === 2,
|
|
{ levelBounded: true },
|
|
);
|
|
if (hvSection) {
|
|
// #2245 review Fix 3: reverted to the pre-Phase-4 (HEAD 2cbf18642)
|
|
// implementation. The live Human Verification section is NOT a strict
|
|
// GFM table — the planner/verifier templates mix table rows, numbered
|
|
// items, and bullet items in the same section (and a `### N.` heading
|
|
// format is common too), so a table-XOR-list read (parse a table, and
|
|
// if it parses, suppress numbered/bullet items entirely) silently
|
|
// dropped items on any mixed or malformed section: a malformed
|
|
// `| N | … |` table with no valid header/delimiter yielded ZERO items
|
|
// instead of reading the rows positionally. This per-line scan reads
|
|
// table rows AND numbered items AND bullet items as a UNION (whichever
|
|
// pattern a given line matches), exactly like OLD, and reads
|
|
// `| N | desc |` rows even without a valid table header/delimiter.
|
|
//
|
|
// #2245 audit: the table-row branch's CELL SPLIT is name/position-
|
|
// addressed via `splitTableRow` (escape-aware, canonical) instead of a
|
|
// hand-rolled pipe regex — candidacy itself is decided WITHOUT a table
|
|
// regex (a leading `|` plus a purely-numeric first cell), so this no
|
|
// longer needs an allow-adhoc-markdown suppression at all.
|
|
const lines = hvSection.body.split('\n');
|
|
for (const line of lines) {
|
|
const trimmedLine = line.trim();
|
|
// Match table rows: | N | description | ... — candidacy requires a
|
|
// leading pipe and a purely-numeric first cell (mirrors what the old
|
|
// regex effectively required: a "|digit|" cell immediately followed
|
|
// by more content), with at least 2 physical cells so a bare "| N |"
|
|
// with nothing after it is NOT treated as a row.
|
|
//
|
|
// #2245 review Fix 9: this is NOT the same as OLD for a row whose
|
|
// ONLY content past the digit cell is trailing whitespace (e.g.
|
|
// "| N | ", no second delimiting `|`). OLD's `([^|]+)` regex ran
|
|
// against the RAW (untrimmed) line and its `\s*` would backtrack to
|
|
// let `[^|]+` swallow that trailing whitespace, so OLD matched and
|
|
// pushed an item with an EMPTY (`.trim()`-collapsed) name. Here,
|
|
// `trimmedLine = line.trim()` strips that trailing whitespace BEFORE
|
|
// `splitTableRow` ever sees it, collapsing the line to a single cell
|
|
// (`candidateCells.length === 1`), which fails the `>= 2` check —
|
|
// the item is silently dropped instead. A real, acceptable behaviour
|
|
// change (an empty-named UAT item is not useful either way), but the
|
|
// two implementations are NOT equivalent on this input.
|
|
let tableCells: string[] | null = null;
|
|
if (trimmedLine.startsWith('|')) {
|
|
const candidateCells = splitTableRow(trimmedLine);
|
|
if (candidateCells.length >= 2 && /^\d+$/.test(candidateCells[0])) {
|
|
tableCells = candidateCells;
|
|
}
|
|
}
|
|
// Match bullet items: - description
|
|
const bulletMatch = line.match(/^[-*]\s+(.+)/);
|
|
// Match numbered items: 1. description
|
|
const numberedMatch = line.match(/^(\d+)\.\s+(.+)/);
|
|
|
|
if (tableCells) {
|
|
// Skip rows that already have a passing result (PASS, pass, resolved, etc.)
|
|
// — checked over every cell AFTER the description column, mirroring
|
|
// OLD's rowRemainder scan (which only ever saw cells past the
|
|
// description, the description itself having already been consumed).
|
|
const hasPassResult = tableCells.slice(2).some(c => /^pass$/i.test(c) || /^resolved$/i.test(c));
|
|
if (hasPassResult) continue;
|
|
items.push({
|
|
test: parseInt(tableCells[0], 10),
|
|
name: tableCells[1] ?? '',
|
|
result: 'human_needed',
|
|
category: 'human_uat',
|
|
});
|
|
} else if (numberedMatch) {
|
|
items.push({
|
|
test: parseInt(numberedMatch[1], 10),
|
|
name: numberedMatch[2].trim(),
|
|
result: 'human_needed',
|
|
category: 'human_uat',
|
|
});
|
|
} else if (bulletMatch && bulletMatch[1].length > 10) {
|
|
items.push({
|
|
name: bulletMatch[1].trim(),
|
|
result: 'human_needed',
|
|
category: 'human_uat',
|
|
});
|
|
}
|
|
}
|
|
|
|
// #2286: fall back to the `### N. <label>` heading + bold-led paragraph
|
|
// shape (the canonical form emitted by `templates/verification-report.md`
|
|
// — `### 1. {Test Name}` followed by `**Test:** ... **Expected:** ...
|
|
// **Why human:** ...`), which the table/bullet/numbered per-line scan
|
|
// above never recognises (a `###`-prefixed line matches none of those
|
|
// three patterns). Uses the same `tokenizeHeadings` seam
|
|
// `parseFirstPendingTest` already uses for `### N.` sub-headings,
|
|
// applied here to the Human Verification section body. Runs in
|
|
// addition to (a union with) the scan above — the two shapes don't
|
|
// collide, so this only adds items a `###` heading page would have
|
|
// silently produced zero for.
|
|
const hvSubHeadings = tokenizeHeadings(hvSection.body).filter(
|
|
(h) => h.level === 3 && /^\d+\.\s+/.test(h.text),
|
|
);
|
|
for (let i = 0; i < hvSubHeadings.length; i += 1) {
|
|
const current = hvSubHeadings[i];
|
|
const next = hvSubHeadings[i + 1];
|
|
const block = next
|
|
? hvSection.body.slice(current.offset, next.offset)
|
|
: hvSection.body.slice(current.offset);
|
|
const bodyAfterHeading = block.slice(block.indexOf('\n') + 1);
|
|
// Require a bold-led paragraph body (`**Test:** ...`) to distinguish
|
|
// a genuine verification item from an unrelated numbered heading.
|
|
if (!/^\s*\*\*/.test(bodyAfterHeading)) continue;
|
|
|
|
const headingParts = current.text.match(/^(\d+)\.\s+(.+)$/);
|
|
if (!headingParts) continue;
|
|
items.push({
|
|
test: parseInt(headingParts[1], 10),
|
|
name: headingParts[2].trim(),
|
|
result: 'human_needed',
|
|
category: 'human_uat',
|
|
});
|
|
}
|
|
}
|
|
}
|
|
// gaps_found items are already handled by plan-phase --gaps pipeline
|
|
return items;
|
|
}
|
|
|
|
/**
|
|
* Normalize a single `human_verification:` frontmatter array entry (#2286)
|
|
* into a display-ready name.
|
|
*
|
|
* #2286 review (LOW finding): `extractFrontmatter`'s generic array-item
|
|
* parser (`src/frontmatter.cts`, the `line.trim().startsWith('- ')` branch)
|
|
* has NO notion of nested key/value objects — regardless of whether the
|
|
* source YAML was authored as `- test: "..."` (an implied-but-unsupported
|
|
* shorthand) or `- "plain string"`, it ALWAYS pushes the raw post-`- ` text
|
|
* (with only a single layer of wrapping quotes stripped) as a plain string.
|
|
* There is therefore no reliable signal here to distinguish a genuine
|
|
* `key: value`-shaped pseudo-field from a legitimate plain string that
|
|
* itself happens to start with a word and a colon (e.g. `"Confirm: the
|
|
* button responds"`). A prior version of this function stripped a leading
|
|
* `word:` prefix on the assumption it was always a flattened nested-object
|
|
* key — that assumption is false, and it silently truncated real plain-string
|
|
* content. No such stripping is applied: any residual wrapping-quote noise
|
|
* left by `extractFrontmatter`'s own (anchor-only) quote handling is cleaned
|
|
* up, and everything else is preserved verbatim.
|
|
*/
|
|
function normalizeHumanVerificationEntry(raw: unknown): string {
|
|
if (typeof raw !== 'string') {
|
|
return raw === null || raw === undefined ? '' : JSON.stringify(raw);
|
|
}
|
|
const s = raw.trim().replace(/^["']+|["']+$/g, '').trim();
|
|
return s || raw.trim();
|
|
}
|
|
|
|
// ─── categorizeItem ───────────────────────────────────────────────────────────
|
|
|
|
function categorizeItem(result: string, reason?: string, blockedBy?: string): UatCategory {
|
|
if (result === 'blocked' || blockedBy) {
|
|
if (blockedBy) {
|
|
if (/server/i.test(blockedBy)) return 'server_blocked';
|
|
if (/device|physical/i.test(blockedBy)) return 'device_needed';
|
|
if (/build|release|preview/i.test(blockedBy)) return 'build_needed';
|
|
if (/third.party|twilio|stripe/i.test(blockedBy)) return 'third_party';
|
|
}
|
|
return 'blocked';
|
|
}
|
|
if (result === 'skipped') {
|
|
if (reason) {
|
|
if (/server|not running|not available/i.test(reason)) return 'server_blocked';
|
|
if (/simulator|physical|device/i.test(reason)) return 'device_needed';
|
|
if (/build|release|preview/i.test(reason)) return 'build_needed';
|
|
}
|
|
return 'skipped_unresolved';
|
|
}
|
|
if (result === 'pending') return 'pending';
|
|
if (result === 'human_needed') return 'human_uat';
|
|
return 'unknown';
|
|
}
|
|
|
|
export = {
|
|
cmdAuditUat,
|
|
cmdRenderCheckpoint,
|
|
parseCurrentTest,
|
|
buildCheckpoint,
|
|
parseDeferredItems,
|
|
};
|