* test(#3804): audit-uat must see all three phase-archive layouts * fix(#3804): enumerate all three phase-archive layouts (flat, workstream-archived, workstream-active) * chore(#3804): changeset fragment (pr number backfilled after PR creation) * chore(#3804): backfill changeset PR number (4022) --------- Co-authored-by: sim <sim@local>
2951 lines
144 KiB
TypeScript
2951 lines
144 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, stripFencedCode, scanFencedBlocks } = markdownSectionizer;
|
||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||
import markdownTable = require('./markdown-table.cjs');
|
||
const { splitTableRow, isDelimiterRow } = markdownTable;
|
||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||
import coreUtils = require('./core-utils.cjs');
|
||
const { toPosixPath, normalizeLineEndings } = 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, scopeToPhase } = phaseIdMod;
|
||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||
import phaseLocator = require('./phase-locator.cjs');
|
||
const { listMilestonePhaseDirs, getAllArchivedPhaseDirs } = phaseLocator;
|
||
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' | 'issue';
|
||
|
||
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;
|
||
/**
|
||
* Milestone version whose archive this phase dir was read from
|
||
* (`.planning/milestones/<version>-phases/`), or undefined for a phase still
|
||
* in the active `.planning/phases/` tree. Lets a consumer label provenance
|
||
* instead of presenting archived and in-flight work identically.
|
||
*/
|
||
archived_milestone?: string;
|
||
items: UatItem[];
|
||
/**
|
||
* True when this file contained `### N.` test blocks that yielded ZERO
|
||
* items (#3707) — a genuine parse gap (a row missing its `result:` field
|
||
* entirely, or otherwise unrecognised) rather than a file whose every row
|
||
* legitimately passed, or a Gaps-only file with nothing outstanding.
|
||
* Derived from `parseUatItemsWithStats`'s `headingsSeen` counter, NOT from
|
||
* frontmatter `status:` — see that function's doc comment. Distinguishes
|
||
* "nothing to see here" from "something here could not be read" so a file
|
||
* whose test blocks could not be parsed is still surfaced instead of
|
||
* silently vanishing.
|
||
*/
|
||
parse_gap?: boolean;
|
||
/**
|
||
* Count of `### N.` test blocks in this file that yielded ZERO items
|
||
* (`headingsSeen` from `parseUatItemsWithStats`) — set alongside `parse_gap`
|
||
* so a MIXED file (some parseable rows, some not) quantifies how many rows
|
||
* are unaccounted for instead of the boolean flag alone. Present only when
|
||
* `parse_gap` is true.
|
||
*/
|
||
unparsed_blocks?: number;
|
||
}
|
||
|
||
interface CurrentTest {
|
||
complete: boolean;
|
||
number?: number;
|
||
name?: string;
|
||
expected?: string;
|
||
}
|
||
|
||
// ─── cmdAuditUat ─────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Select the UAT documents belonging to ONE phase directory.
|
||
*
|
||
* Extracted (#2790) so `cmdAuditUat` and the read-only `planning.inspect` query
|
||
* cannot drift on which files count as this phase's UAT. `scopeToPhase` has no
|
||
* unfiltered fallback on purpose: a phase whose own UAT file is genuinely absent
|
||
* scopes to empty and contributes nothing, rather than picking up a stray
|
||
* cross-phase file (#3511).
|
||
*/
|
||
function selectPhaseUatFiles(files: string[], phaseDirName: string): string[] {
|
||
return scopeToPhase(files.filter((f) => f.includes('-UAT') && f.endsWith('.md')), phaseDirName);
|
||
}
|
||
|
||
/**
|
||
* The ONE read boundary for every document `cmdAuditUat` scans off disk
|
||
* (#3707-CR follow-up MAJOR). Wraps `fs.readFileSync` +
|
||
* `normalizeLineEndings` in a single seam so a lone-CR-separated
|
||
* `*-UAT.md`, `*-VERIFICATION.md`, or `deferred-items.md` is normalized BY
|
||
* CONSTRUCTION before it reaches ANY downstream parser — current
|
||
* (`parseUatItemsWithStats`, `parseVerificationItems`, `parseDeferredItems`)
|
||
* or future. Fixing this per-parser was the original (#3707-CR) MEDIUM fix's
|
||
* mistake: two of the four ingresses in this function were normalized by
|
||
* editing their own parsers directly, and the other two (VERIFICATION,
|
||
* deferred-items.md) were missed precisely because nothing forced a new call
|
||
* site to remember the step. Routing every read through this function
|
||
* removes that failure mode: a parser added later needs no line-ending logic
|
||
* of its own, because the text it receives is already normalized.
|
||
*/
|
||
function readNormalizedDocument(filePath: string): string {
|
||
return normalizeLineEndings(fs.readFileSync(filePath, 'utf-8'));
|
||
}
|
||
|
||
function cmdAuditUat(cwd: string, raw: boolean): void {
|
||
const phasesDir = path.join(planningDir(cwd), 'phases');
|
||
const hasActivePhases = fs.existsSync(phasesDir);
|
||
|
||
// #2766: on milestone completion `milestone.cts` MOVES each phase dir into
|
||
// `.planning/milestones/<version>-phases/` (archive-by-default since #1871),
|
||
// leaving `.planning/phases/` empty or absent. Scanning only the active tree
|
||
// meant a partly-archived project silently omitted the archived phases, and a
|
||
// fully-archived one hard-errored with "No phases directory found" —
|
||
// indistinguishable from a broken install. Outstanding UAT items do not stop
|
||
// mattering when a milestone closes: a deferred human-UAT scenario or a
|
||
// `skipped` live-stack test is exactly what gets archived still-open.
|
||
//
|
||
// Reuses the canonical `getAllArchivedPhaseDirs` seam (phase-locator.cts), which
|
||
// `findPhaseInternal` already uses for this same fallback, so the archive
|
||
// layout convention stays owned by one module.
|
||
// #3804: the guard AND the scan use the cross-workstream enumeration —
|
||
// a project whose only phases live in workstream milestone trees is a
|
||
// fully-populated audit, not a broken install.
|
||
const archivedDirs = getAllArchivedPhaseDirs(cwd);
|
||
if (!hasActivePhases && archivedDirs.length === 0) {
|
||
error('No phases directory found in planning directory');
|
||
}
|
||
|
||
const results: UatFileResult[] = [];
|
||
|
||
// Active dirs are milestone-filtered; archived dirs deliberately are NOT.
|
||
// listMilestonePhaseDirs derives the CURRENT milestone's phase directories
|
||
// (window + sentinel filtered) from ROADMAP.md, and archived phases belong
|
||
// to past milestones by definition — so applying it to them discards every
|
||
// one and silently reinstates the bug.
|
||
const scanTargets: { dir: string; phaseDir: string; milestone?: string }[] = [];
|
||
|
||
if (hasActivePhases) {
|
||
// #3185 (ADR-3180 Decision 1): routed through the canonical owner
|
||
// instead of a hand-rolled readdirSync + isDirInMilestone filter, which
|
||
// also never excluded sentinels, unlike the owner.
|
||
const dirs = listMilestonePhaseDirs(phasesDir, { cwd }).value;
|
||
for (const dir of dirs) {
|
||
scanTargets.push({ dir, phaseDir: path.join(phasesDir, dir) });
|
||
}
|
||
}
|
||
|
||
for (const archived of archivedDirs) {
|
||
scanTargets.push({
|
||
dir: archived.name,
|
||
phaseDir: archived.fullPath,
|
||
milestone: archived.milestone,
|
||
});
|
||
}
|
||
|
||
for (const { dir, phaseDir, milestone } of scanTargets) {
|
||
const phaseMatch = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
|
||
const phaseNum = phaseMatch ? phaseMatch[1] : dir;
|
||
const files = fs.readdirSync(phaseDir);
|
||
|
||
// Process UAT files — scoped to THIS phase's own token (#3511) via
|
||
// scopeToPhase, so a stray, cross-phase, or ad-hoc file cannot be reported
|
||
// under this phase's audit-uat entry. A phase whose own UAT file is
|
||
// genuinely absent scopes to empty and contributes nothing — correct, and
|
||
// the reason scopeToPhase has no unfiltered fallback.
|
||
for (const file of selectPhaseUatFiles(files, dir)) {
|
||
const uatFilePath = path.join(phaseDir, file);
|
||
const content = readNormalizedDocument(uatFilePath);
|
||
const { items, headingsSeen } = parseUatItemsWithStats(content);
|
||
const status = (extractFrontmatter(content, uatFilePath).status as string || 'unknown');
|
||
// `parse_gap` means the file contained `### N.` test blocks that
|
||
// yielded no items — NOT merely "zero items and not complete" (#3707
|
||
// MAJOR: that broader signal false-positived on an all-pass file and on
|
||
// a Gaps-only file with everything resolved). A file whose blocks all
|
||
// passed, or that has no test blocks at all, never sets `headingsSeen`,
|
||
// so it never sets the flag regardless of status.
|
||
//
|
||
// `status` deliberately does NOT gate this (#3078 security review). A
|
||
// terminal `status: complete` is an ASSERTION BY THE AUTHOR that the
|
||
// work is finished — and an assertion is exactly the thing that must
|
||
// not be allowed to switch off the detector that would contradict it.
|
||
// The earlier `status !== 'complete'` guard did precisely that: a file
|
||
// could declare itself complete and thereby suppress the report of the
|
||
// rows this tool could not read, which is a self-declared kill switch
|
||
// over the very detector this issue built. The distinction that
|
||
// actually matters is not "is it complete" but "is there anything the
|
||
// tool failed to parse":
|
||
// - complete + `headingsSeen === 0` — nothing unread, so nothing to
|
||
// contradict the claim. Still omitted entirely, exactly as before;
|
||
// that is the whole point of a terminal status and must not
|
||
// regress. (Same for a file whose blocks all parsed and passed.)
|
||
// - complete + `headingsSeen > 0` — the author's claim of
|
||
// completeness CANNOT BE VERIFIED against rows the parser could not
|
||
// read, so the file is surfaced with `parse_gap` and the
|
||
// `unparsed_blocks` count. The audit reports what it could not see
|
||
// rather than trusting the frontmatter over the file body.
|
||
//
|
||
// This check is deliberately UNCONDITIONAL on `items.length` (#3707
|
||
// follow-up BLOCKER): a MIXED file — some parseable rows plus some
|
||
// unparseable blocks — must report BOTH the real items AND the parse
|
||
// gap, quantified via `unparsed_blocks`. The previous `else if` only
|
||
// ever flagged a file with ZERO items, silently discarding
|
||
// `headingsSeen` (and every unparseable row it counted) the instant any
|
||
// single item existed anywhere in the file, including via the Gaps
|
||
// union.
|
||
if (items.length > 0 || headingsSeen > 0) {
|
||
const entry: UatFileResult = {
|
||
phase: phaseNum,
|
||
phase_dir: dir,
|
||
file,
|
||
file_path: toPosixPath(path.relative(cwd, path.join(phaseDir, file))),
|
||
type: 'uat',
|
||
status,
|
||
archived_milestone: milestone,
|
||
items,
|
||
};
|
||
if (headingsSeen > 0) {
|
||
entry.parse_gap = true;
|
||
entry.unparsed_blocks = headingsSeen;
|
||
}
|
||
results.push(entry);
|
||
}
|
||
}
|
||
|
||
// Process VERIFICATION files — scoped to THIS phase's own token (#3511)
|
||
// for the same reason as the UAT loop above.
|
||
for (const file of scopeToPhase(files.filter(f => f.includes('-VERIFICATION') && f.endsWith('.md')), dir)) {
|
||
const verificationFilePath = path.join(phaseDir, file);
|
||
const content = readNormalizedDocument(verificationFilePath);
|
||
const status = extractFrontmatter(content, verificationFilePath).status as string || 'unknown';
|
||
if (status === 'human_needed' || status === 'gaps_found') {
|
||
const items = parseVerificationItems(content, status, verificationFilePath);
|
||
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,
|
||
archived_milestone: milestone,
|
||
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 = readNormalizedDocument(path.join(phaseDir, deferredFile));
|
||
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',
|
||
archived_milestone: milestone,
|
||
items,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// Compute summary
|
||
const summary: {
|
||
total_files: number;
|
||
total_items: number;
|
||
parse_gap_files: 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),
|
||
// #3707 blocker 2: a distinct counter so a file whose test blocks
|
||
// yielded no items (structurally unparseable, not "all clear") stays
|
||
// visible even though it contributes zero to total_items. Consumers
|
||
// (audit-uat.md, progress.md) must gate their all-clear / debt checks on
|
||
// BOTH total_items === 0 AND parse_gap_files === 0.
|
||
//
|
||
// Counts EVERY entry with `parse_gap: true`, archived or not — same as
|
||
// `total_items`, which has no archived split. An outstanding item does
|
||
// not stop mattering because its phase was archived on milestone close
|
||
// (#2766): a deferred human-UAT scenario or a `skipped` live-stack test
|
||
// is exactly what gets archived still-open, so a parse gap on that same
|
||
// file is still an unread outstanding row, not closed history. Splitting
|
||
// this counter by `archived_milestone` (tried in this branch, reverted)
|
||
// demoted an in-progress phase filed under an archived dir out of the
|
||
// gate, and buried an archived outstanding row's parse failure relative
|
||
// to the identical row when it happened to parse — the exact bug class
|
||
// this issue exists to fix.
|
||
parse_gap_files: results.filter((r) => r.parse_gap).length,
|
||
by_category: {},
|
||
by_phase: {},
|
||
};
|
||
|
||
for (const r of results) {
|
||
// Deliberate (#3707 follow-up MINOR): this seeds a `by_phase` key at 0
|
||
// even for a parse-gap-only phase whose `items` is empty — do NOT "tidy"
|
||
// this away as dead code. The 0-valued key is itself the cue that this
|
||
// phase was scanned and produced no COUNTABLE items, distinguishing it
|
||
// from a phase absent from `by_phase` entirely (never scanned / no UAT
|
||
// file at all). A phase with a real outstanding item overwrites it below.
|
||
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 {
|
||
// #3707-CR: this is the render-checkpoint path's own independent ingress
|
||
// into `tokenizeHeadings` (via the `parseFirstPendingTest` fallback below),
|
||
// separate from `parseUatItemsWithStats`'s. Normalize here too, ONCE, so a
|
||
// lone-CR document cannot hide its first pending row from this path either
|
||
// — see `normalizeLineEndings` for why.
|
||
content = normalizeLineEndings(content);
|
||
|
||
// 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.
|
||
//
|
||
// #3078 blocker (same exposure as `parseUatItemsWithStats`): only a COLUMN-0
|
||
// heading is a test row — see `isColumnZeroHeading`. A `### N.` line indented
|
||
// <= 3 spaces INSIDE an `expected: |` value is value text, and must not
|
||
// register as a phantom heading and steal the real row's `result:` token.
|
||
//
|
||
// #3078 follow-up: tokenize a copy with the DELIMITER LINES of every
|
||
// wholly-INDENTED fenced block blanked out first (bodies untouched — column
|
||
// 0 is structure, indentation is content) — see
|
||
// `blankIndentedFenceDelimiters`. Without this, an
|
||
// indented ` ``` ` opener inside an `expected: |` value still reads as a
|
||
// real fence to `tokenizeHeadings` (CommonMark tolerates 1-3 leading
|
||
// spaces), which then hides every heading up to the next matching closer —
|
||
// including a later, genuinely column-0 `### N.` row.
|
||
//
|
||
// #3078 round-5 MAJOR: the row predicate is `isTestRowHeadingText`, the ONE
|
||
// shared helper `parseUatItemsWithStats` uses. It previously read
|
||
// `/^\d+\.\s+/` here while the audit path read `/^\d+\.(?!\d)/`, so
|
||
// `### 3.Foo` WAS a row on one path and was NOT on the other — two parse
|
||
// paths in one module disagreeing about the same grammar.
|
||
const subHeadings = tokenizeHeadings(blankIndentedFenceDelimiters(sectionBody)).filter(
|
||
(h) => h.level === 3 && isTestRowHeadingText(h.text) && isColumnZeroHeading(sectionBody, h),
|
||
);
|
||
|
||
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 RAW 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 via the
|
||
// SAME `parseTestRowHeadingText` seam the audit path uses (#3078 round-5
|
||
// MAJOR) — a name-mandatory `/^(\d+)\.\s+(.+)$/` here would have `continue`d
|
||
// past exactly the `### 3.` / `### 3.Foo` shapes the shared predicate just
|
||
// admitted, reintroducing the divergence one line below the fix.
|
||
const headingParts = parseTestRowHeadingText(current.text);
|
||
if (!headingParts) continue;
|
||
const testNumber = headingParts.number;
|
||
const testName = headingParts.name;
|
||
|
||
// #3078 blocker: clip the block at its first fence opener before handing
|
||
// it to `parseExpectedFromTestBlock`, so a raw read cannot reach into
|
||
// fence-hidden content — including a LATER row's own `expected:` line.
|
||
const expected = parseExpectedFromTestBlock(clipBlockAtFirstFence(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;
|
||
}
|
||
|
||
/**
|
||
* CRLF (#3078, found while hardening the scalar reader): the opener pattern
|
||
* demanded a BARE `\n` immediately after the `|`, so on a CRLF document
|
||
* `expected: |\r\n` never matched the block-scalar arm at all — control fell
|
||
* through to the INLINE arm, which happily captured the pipe character itself
|
||
* and published `expected: "|"`, discarding the entire multi-line value with no
|
||
* trace. `\r?` on the opener plus a per-line `\r` strip on the body fixes it.
|
||
* `(?:[1-9][+-]?|[+-][1-9]?)?` additionally admits the `|-` / `|+` chomping
|
||
* indicators AND the explicit indentation indicator (`|2`, `|2-`, `|-2`, ...,
|
||
* in either order per the YAML header grammar), keeping this reader in step
|
||
* with the column-0 heading rule (an indented heading inside a scalar body is
|
||
* otherwise a `expected: |-` or `expected: |2` value would be structurally
|
||
* masked but then read as the literal string `"|-"` / `"|2"` by the same
|
||
* fall-through.
|
||
*
|
||
* `[|>]` (#3078 follow-up): the `>` FOLDED-scalar family (`>`, `>-`, `>+`,
|
||
* `>2`, `>2+`, ...) hit the exact same fall-through as the CRLF/`|-`/`|+`
|
||
* bugs above — the opener only ever matched `|`, so `expected: >` fell to the
|
||
* inline arm and published the literal `">"` as the value, discarding the
|
||
* whole scalar. The opener character is now captured (group 1) so the caller
|
||
* can apply YAML's fold semantics for `>` while leaving `|` untouched.
|
||
*
|
||
* TRAILING COMMENT (#3078 round-6 MINOR 1): YAML permits a comment after a
|
||
* block-scalar header — `expected: | # sample`, `reason: >- # note` are both
|
||
* legal and open a scalar exactly as the bare forms do. The grammar was
|
||
* `$`-anchored immediately after the indicator, so those headers matched
|
||
* NEITHER `extractScalarField`'s opener (the value silently fell through to
|
||
* the inline arm and published the literal `"|"`) NOR
|
||
* `ANY_KEY_SCALAR_HEADER_LINE_RE` (so `countUnattributedIndentedRows` treated
|
||
* the scalar's own indented body heading as an unattributed lost row — a FALSE
|
||
* parse gap). `(?:#[^\r\n]*)?` closes both at the single shared source.
|
||
*/
|
||
const SCALAR_HEADER_BODY = String.raw`[ \t]*([|>])(?:[1-9][+-]?|[+-][1-9]?)?[ \t]*(?:#[^\r\n]*)?`;
|
||
|
||
/**
|
||
* Build the block-scalar HEADER grammar for an arbitrary `key:` — the ONE
|
||
* source shared by `expected:`, `reason:` and `blocked_by:` (#3078 MINOR 2:
|
||
* `reason:`/`blocked_by:` previously had no block-scalar grammar of their own
|
||
* at all, and silently published the literal `"|"` / `">"` for a `|`/`>`
|
||
* value, discarding it). A key is always a hardcoded literal at each call
|
||
* site in this module (never untrusted input), so no escaping is needed.
|
||
*/
|
||
function scalarHeaderFor(key: string): string {
|
||
return String.raw`${key}:${SCALAR_HEADER_BODY}`;
|
||
}
|
||
|
||
/**
|
||
* ANY key's block-scalar HEADER line (#3078 MINOR 1), matched against ONE
|
||
* already-CR-stripped source line instead of against a multi-line block.
|
||
* Derived from the SAME `SCALAR_HEADER_BODY` source `scalarHeaderFor` uses
|
||
* so the opener grammars (`|`, `|-`, `|+`, `|2`, `|-2`, `>`, `>-`, `>+`, `>2+`,
|
||
* ...) cannot drift between them — the generative-divergence class this repo
|
||
* pins elsewhere.
|
||
*
|
||
* `countUnattributedIndentedRows` walks back from an indented `### N.`-shaped
|
||
* line to the nearest preceding column-0 line and asks whether THAT line
|
||
* opened a block scalar that still owns the indented line as its body.
|
||
* Testing only an `expected:`-ONLY grammar there meant an indented
|
||
* heading-shaped line inside ANY OTHER block scalar — `reported: |`
|
||
* (templates/UAT.md), `reason: |`, a verbatim user response containing
|
||
* ` ### 9. Section Nine` — was miscounted as a lost row even though nothing
|
||
* is missing. YAML's indentation rule (any column-0 line terminates a scalar)
|
||
* does not care WHICH key opened the scalar, only that a `[|>]`-family opener
|
||
* did, so the walk-back only needs to recognise the opener grammar, not the
|
||
* specific key.
|
||
*/
|
||
const ANY_KEY_SCALAR_HEADER_LINE_RE = new RegExp(String.raw`^[A-Za-z_][\w-]*:${SCALAR_HEADER_BODY}$`);
|
||
|
||
/**
|
||
* Apply YAML FOLDED-scalar (`>`) line-joining to an already-dedented,
|
||
* CRLF-stripped block-scalar body: lines within a paragraph (no blank line
|
||
* between them) join with a single space; a blank line between paragraphs
|
||
* becomes a literal `\n` in the result. `|` (LITERAL) bodies are returned
|
||
* unchanged — folding is `>`-only.
|
||
*/
|
||
function foldScalarBody(body: string): string {
|
||
const lines = body.split('\n');
|
||
const paragraphs: string[] = [];
|
||
let current: string[] = [];
|
||
for (const line of lines) {
|
||
if (line === '') {
|
||
paragraphs.push(current.join(' '));
|
||
current = [];
|
||
} else {
|
||
current.push(line);
|
||
}
|
||
}
|
||
paragraphs.push(current.join(' '));
|
||
return paragraphs.join('\n');
|
||
}
|
||
|
||
/**
|
||
* Extract a YAML-lite `key:` field's value from `block` — block-scalar
|
||
* (`|`/`>` family, dedented and, for `>`, YAML-folded) OR plain inline.
|
||
* Generalized from the `expected:`-only reader (#3078 MINOR 2) so `reason:`
|
||
* and `blocked_by:` — which previously had NO block-scalar grammar at all and
|
||
* silently published the literal `"|"` / `">"` for a multi-line value,
|
||
* discarding it — go through the exact same opener grammar and fold
|
||
* semantics instead of a third, hand-rolled dialect.
|
||
*/
|
||
function extractScalarField(block: string, key: string): string | null {
|
||
const opener = String.raw`^${scalarHeaderFor(key)}\r?\n`;
|
||
const blockMatch = block.match(new RegExp(`${opener}([\\s\\S]*?)(?=^\\w[\\w-]*:\\s)`, 'm'))
|
||
|| block.match(new RegExp(`${opener}([\\s\\S]+)`, 'm'));
|
||
if (blockMatch) {
|
||
const openerChar = blockMatch[1];
|
||
const dedented = blockMatch[2]
|
||
.split('\n')
|
||
.map((line: string) => line.replace(/\r$/, '').replace(/^ {2}/, ''))
|
||
.join('\n')
|
||
.trim();
|
||
return openerChar === '>' ? foldScalarBody(dedented) : dedented;
|
||
}
|
||
|
||
const inlineMatch = block.match(new RegExp(String.raw`^${key}:\s*(.+)\s*$`, 'm'));
|
||
return inlineMatch ? inlineMatch[1].trim() : null;
|
||
}
|
||
|
||
function parseExpectedFromTestBlock(block: string): string | null {
|
||
return extractScalarField(block, 'expected');
|
||
}
|
||
|
||
// ─── 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;
|
||
direction?: 'rtl';
|
||
}
|
||
|
||
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.',
|
||
},
|
||
dutch: {
|
||
banner: 'CONTROLEPUNT: Verificatie vereist',
|
||
instruction: 'Typ `pass` of beschrijf wat er mis is.',
|
||
},
|
||
polish: {
|
||
banner: 'PUNKT KONTROLNY: Wymagana weryfikacja',
|
||
instruction: 'Wpisz `pass` lub opisz, co jest nie tak.',
|
||
},
|
||
russian: {
|
||
banner: 'КОНТРОЛЬНАЯ ТОЧКА: требуется проверка',
|
||
instruction: 'Введите `pass` или опишите, что не так.',
|
||
},
|
||
ukrainian: {
|
||
banner: 'КОНТРОЛЬНА ТОЧКА: потрібна перевірка',
|
||
instruction: 'Введіть `pass` або опишіть, що не так.',
|
||
},
|
||
turkish: {
|
||
banner: 'KONTROL NOKTASI: Doğrulama gerekli',
|
||
instruction: '`pass` yazın veya sorunu açıklayın.',
|
||
},
|
||
hindi: {
|
||
banner: 'चेकपॉइंट: सत्यापन आवश्यक',
|
||
instruction: '`pass` लिखें या बताएं कि क्या गलत है।',
|
||
},
|
||
arabic: {
|
||
banner: 'نقطة تحقق: المراجعة مطلوبة',
|
||
instruction: 'اكتب `pass` أو صف المشكلة.',
|
||
direction: 'rtl',
|
||
},
|
||
vietnamese: {
|
||
banner: 'ĐIỂM KIỂM TRA: Cần xác minh',
|
||
instruction: 'Nhập `pass` hoặc mô tả vấn đề.',
|
||
},
|
||
indonesian: {
|
||
banner: 'TITIK PEMERIKSAAN: Verifikasi diperlukan',
|
||
instruction: 'Ketik `pass` atau jelaskan apa yang salah.',
|
||
},
|
||
};
|
||
|
||
// 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',
|
||
dutch: 'dutch', nl: 'dutch', nederlands: 'dutch', flemish: 'dutch', vlaams: 'dutch',
|
||
polish: 'polish', pl: 'polish', polski: 'polish',
|
||
russian: 'russian', ru: 'russian', 'ru-ru': 'russian', 'русский': 'russian',
|
||
ukrainian: 'ukrainian', uk: 'ukrainian', ua: 'ukrainian', 'українська': 'ukrainian',
|
||
turkish: 'turkish', tr: 'turkish', 'türkçe': 'turkish', turkce: 'turkish',
|
||
hindi: 'hindi', hi: 'hindi', 'हिन्दी': 'hindi', 'हिंदी': 'hindi',
|
||
arabic: 'arabic', ar: 'arabic', 'العربية': 'arabic',
|
||
vietnamese: 'vietnamese', vi: 'vietnamese', 'tiếng việt': 'vietnamese', 'tieng viet': 'vietnamese',
|
||
indonesian: 'indonesian', id: 'indonesian', 'bahasa indonesia': 'indonesian',
|
||
};
|
||
|
||
function resolveCheckpointFrame(responseLanguage: string | undefined): CheckpointFrame {
|
||
if (!responseLanguage) return CHECKPOINT_FRAMES.english;
|
||
const key = CHECKPOINT_LANGUAGE_ALIASES[
|
||
responseLanguage.trim().normalize('NFC').toLowerCase()
|
||
];
|
||
return (key && CHECKPOINT_FRAMES[key]) || CHECKPOINT_FRAMES.english;
|
||
}
|
||
|
||
const RTL_ISOLATE = '\u2067';
|
||
const POP_DIRECTIONAL_ISOLATE = '\u2069';
|
||
|
||
function isolateCheckpointFrameText(text: string, frame: CheckpointFrame): string {
|
||
return frame.direction === 'rtl'
|
||
? `${RTL_ISOLATE}${text}${POP_DIRECTIONAL_ISOLATE}`
|
||
: text;
|
||
}
|
||
|
||
function buildCheckpoint(currentTest: { number: number; name: string; expected: string }, responseLanguage?: string): string {
|
||
const frame = resolveCheckpointFrame(responseLanguage);
|
||
const banner = isolateCheckpointFrameText(frame.banner, frame);
|
||
const instruction = isolateCheckpointFrameText(frame.instruction, frame);
|
||
return [
|
||
`### ${banner}`,
|
||
'',
|
||
`**Test ${currentTest.number}: ${currentTest.name}**`,
|
||
'',
|
||
currentTest.expected,
|
||
'',
|
||
'---',
|
||
'',
|
||
`**${instruction}**`,
|
||
].join('\n');
|
||
}
|
||
|
||
// ─── parseUatItems ────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Result tokens treated as PASSING (#3707 defect 1). Deliberately MINIMAL —
|
||
* that minimality is the point. Every token NOT in this set surfaces as an
|
||
* outstanding item, mirroring the fail-safe direction `parseGapsItems`
|
||
* already documents for this exact false-negative class (#2286): a project
|
||
* that invents a novel pass-word gets a visible, correctable false positive
|
||
* (an extra row an agent can dismiss) rather than today's invisible drop (a
|
||
* genuinely outstanding row silently vanishing with no trace). This was the
|
||
* issue's one open design question and was decided deliberately, here, in
|
||
* favor of the fail-safe direction over a larger "known synonyms" allowlist.
|
||
*/
|
||
const UAT_PASS_RESULTS = new Set(['pass', 'passed']);
|
||
|
||
/**
|
||
* A fenced-code OPENER line at COLUMN 0 (``` or ~~~).
|
||
*
|
||
* Deliberately NOT the CommonMark `{0,3}`-space form (#3078 simplification):
|
||
* inside this module a fence only ever means "document structure the tokenizer
|
||
* hid from us", and every structural fence in a UAT file starts at column 0. An
|
||
* INDENTED fence run is, by construction, part of an `expected: |` block-scalar
|
||
* value — the ordinary way a UAT row reproduces a code sample verbatim — and
|
||
* must stay invisible to the clipper, or the very field it exists to protect
|
||
* gets truncated at its own sample. Column 0 is the whole rule for every
|
||
* fence-aware scan THIS MODULE writes directly against raw block text (this
|
||
* one, `dropTopLevelFencedRegions`'s `delimRe`). It does NOT extend to
|
||
* `tokenizeHeadings`, which is a third-party CommonMark scanner with its own
|
||
* {0,3}-space fence tolerance baked in — see `blankIndentedFenceDelimiters`
|
||
* for how an indented delimiter is kept from reaching that scanner at all.
|
||
*/
|
||
const FENCE_OPENER_RE = /^(?:`{3,}|~{3,})/;
|
||
|
||
/**
|
||
* The CommonMark-tolerant (0-3 leading spaces) twin of `FENCE_OPENER_RE`,
|
||
* used ONLY by the inner delimiter-shape sweep in
|
||
* `blankIndentedFenceDelimiters` (#3078 round-7 MAJOR). That sweep runs
|
||
* strictly BETWEEN a neutralised block's own (already-blanked) delimiters,
|
||
* looking for a line `tokenizeHeadings` would itself read as a fence opener
|
||
* once those delimiters are gone — and `tokenizeHeadings` tolerates up to
|
||
* three leading spaces on an opener, so a column-0-anchored test here misses
|
||
* an INDENTED delimiter-shaped line and lets the mutation manufacture exactly
|
||
* the structure `scanFencedBlocks` never saw. `FENCE_OPENER_RE` itself stays
|
||
* column-0-anchored: every OTHER call site depends on that anchoring.
|
||
*/
|
||
const INDENT_TOLERANT_DELIM_RE = /^ {0,3}(?:`{3,}|~{3,})/;
|
||
|
||
/**
|
||
* A raw source line whose shape is a UAT `### N.` test heading — the line-level
|
||
* twin of the `h.level === 3 && /^\d+\.(?!\d)/` token filter in
|
||
* `parseUatItemsWithStats`, and anchored at COLUMN 0 to match that filter's
|
||
* `isColumnZeroHeading` guard exactly. Used ONLY to count headings that
|
||
* `tokenizeHeadings` suppressed (a fence-straddled row), never to parse one:
|
||
* the two counts must be derived by the SAME rule or the shortfall they
|
||
* bracket over- or under-reports.
|
||
*/
|
||
const TEST_HEADING_LINE_RE = /^#{3}(?!#)[ \t]+\d+\.(?!\d)/;
|
||
|
||
/**
|
||
* THE test-row grammar, in ONE place (#3078 round-5 MAJOR).
|
||
*
|
||
* `parseFirstPendingTest` (the render-checkpoint path) and
|
||
* `parseUatItemsWithStats` (the audit path) each filtered level-3 headings with
|
||
* their own literal — `/^\d+\.\s+/` vs `/^\d+\.(?!\d)/` — so the two paths in
|
||
* this one module DISAGREED about what a test row is: `### 3.Foo` (name squished
|
||
* against the dot) and `### 3.` (no name at all) were rows to the audit and were
|
||
* silently NOT rows to the checkpoint. That is the generative-divergence class
|
||
* this repo requires closed with a shared definition rather than two literals
|
||
* kept in sync by hand.
|
||
*
|
||
* The AUDIT rule wins, deliberately: `^\d+\.(?!\d)` admits `### 3.` and
|
||
* `### 3.Foo` (a heading missing or squishing its name still contributes to
|
||
* `headingsSeen`/items instead of vanishing from BOTH — the same silent-drop
|
||
* symptom the parse-gap flag exists to catch) while the `(?!\d)` lookahead keeps
|
||
* a DOTTED-SECTION heading like `### 1.2.3 Overview` out, since that is a
|
||
* document outline number, not test row 1. `TEST_HEADING_LINE_RE` /
|
||
* `INDENTED_TEST_HEADING_LINE_RE` are the raw-source-line twins of this same
|
||
* rule and carry the identical `\d+\.(?!\d)` core.
|
||
*/
|
||
const TEST_ROW_HEADING_TEXT_RE = /^\d+\.(?!\d)/;
|
||
|
||
/** True when a level-3 heading's TEXT is a UAT test row. See `TEST_ROW_HEADING_TEXT_RE`. */
|
||
function isTestRowHeadingText(text: string): boolean {
|
||
return TEST_ROW_HEADING_TEXT_RE.test(text);
|
||
}
|
||
|
||
/**
|
||
* Split a test-row heading's text into its number and display name — the
|
||
* extraction twin of `isTestRowHeadingText`, shared by both parse paths for the
|
||
* same anti-divergence reason. Returns `null` for text the predicate rejects.
|
||
*
|
||
* A bare `### 3.` (no trailing name) falls back to the heading's own trimmed
|
||
* text (`3.`) rather than yielding an empty name.
|
||
*/
|
||
function parseTestRowHeadingText(text: string): { number: number; name: string } | null {
|
||
if (!isTestRowHeadingText(text)) return null;
|
||
const parts = text.match(/^(\d+)\.\s*(.*)$/);
|
||
if (!parts) return null;
|
||
return { number: parseInt(parts[1], 10), name: parts[2].trim() || text.trim() };
|
||
}
|
||
|
||
/**
|
||
* The INDENTED (1-3 leading spaces, CommonMark-legal) twin of
|
||
* `TEST_HEADING_LINE_RE` — used by the SHORTFALL SCAN ONLY, never by the parse
|
||
* gate.
|
||
*
|
||
* #3078 round-4 MAJOR 2: `isColumnZeroHeading` refusing to PARSE an indented
|
||
* `### N.` row is deliberate and stays (no `*UAT*.md` in the tree indents one).
|
||
* But the COUNTING side inherited that anchor through
|
||
* `TEST_HEADING_LINE_RE`, so a heading the parse gate rejected could never
|
||
* reach `headingsSeen` either: ` ### 1. Indented Row` with `result: pending`
|
||
* — which origin/next's unanchored `###\s*(\d+)\.` did surface — yielded no
|
||
* item, no gap, no count and no trace at all. Refusing to parse is defensible;
|
||
* vanishing silently is the exact defect class this issue exists to close, so
|
||
* the row now surfaces as a PARSE GAP instead.
|
||
*/
|
||
const INDENTED_TEST_HEADING_LINE_RE = /^[ \t]+#{3}(?!#)[ \t]+\d+\.(?!\d)/;
|
||
|
||
/**
|
||
* True when `heading` starts at COLUMN 0 of its source line in `content`.
|
||
*
|
||
* The UAT test-row contract (#3078): a `### N.` row heading is structure ONLY
|
||
* at column 0. `tokenizeHeadings` implements CommonMark, which tolerates up to
|
||
* 3 leading spaces on an ATX heading — and that single over-permissive rule is
|
||
* what let a `### 3. Fake Row` line sitting INSIDE an `expected: |` value
|
||
* register as a phantom heading, open a block, and STEAL the real row's
|
||
* `result:` line, dropping a genuinely outstanding row from `items`. A scalar
|
||
* body is indented BY CONSTRUCTION (that is what makes it a body), so requiring
|
||
* column 0 makes every such line inert without the parser needing any notion of
|
||
* YAML block scalars at all. The shipped `templates/UAT.md` writes every `### N.`
|
||
* heading at column 0, and no UAT document in the tree indents one.
|
||
*
|
||
* `HeadingToken.offset` is the offset of the heading LINE's first character, so
|
||
* a column-0 heading is exactly one whose first character is the `#` itself.
|
||
*/
|
||
function isColumnZeroHeading(content: string, heading: { offset: number }): boolean {
|
||
return content.charCodeAt(heading.offset) === 0x23 /* '#' */;
|
||
}
|
||
|
||
/**
|
||
* An INDENTED (1-3 leading spaces, never 0) fenced-code delimiter line.
|
||
* Column 0 is intentionally EXCLUDED — a column-0 fence is real document
|
||
* structure and `tokenizeHeadings` handling it is correct; only the
|
||
* CommonMark-legal 1-3-space tolerance is the problem this targets.
|
||
*/
|
||
const INDENTED_FENCE_DELIM_RE = /^ {1,3}(?:`{3,}|~{3,})/;
|
||
|
||
/**
|
||
* Return `content` with the two DELIMITER LINES of every wholly-INDENTED
|
||
* fenced block overwritten by spaces, byte-length- and line-count-preserving,
|
||
* so every downstream offset and line index still lines up against the
|
||
* original document. The block's BODY is left verbatim — see "COLUMN 0 IS
|
||
* STRUCTURE, INDENTATION IS CONTENT" below for why that is the point, not an
|
||
* oversight.
|
||
*
|
||
* Why (#3078 follow-up, escalated design call, answered as option (b)):
|
||
* dropping `maskBlockScalarBodies` in favor of the column-0 heading filter
|
||
* (`isColumnZeroHeading`) fixed the phantom-heading theft, but it silently
|
||
* dropped a SECOND thing masking used to do — hide an INDENTED fence
|
||
* delimiter from `tokenizeHeadings` itself. `tokenizeHeadings` is a
|
||
* CommonMark scanner with its own {0,3}-space fence tolerance; a 1-3-space
|
||
* ` ``` ` inside an `expected: |` scalar body still opens a fence AS FAR AS
|
||
* THAT SCANNER IS CONCERNED, and every heading between it and its matching
|
||
* (or absent) closer — including a LATER, genuinely column-0 `### N.` row —
|
||
* is hidden from the token stream entirely, not merely mis-filtered. The
|
||
* column-0 heading filter cannot recover a heading the tokenizer never
|
||
* returned in the first place.
|
||
*
|
||
* This is deliberately the SAME "column 0 is structure, anything else is
|
||
* value text" rule already applied to headings (`isColumnZeroHeading`) and to
|
||
* this module's own raw-text fence scans (`FENCE_OPENER_RE`,
|
||
* `dropTopLevelFencedRegions`'s `delimRe`) — extended to the one place that
|
||
* rule cannot be expressed as a post-hoc filter, because the tokenizer
|
||
* consumes the fence delimiter before this module ever sees a token for it.
|
||
* It carries no YAML knowledge whatsoever (no notion of `expected:`, `|`,
|
||
* indentation width, or scalar bodies) — it blanks an indented delimiter LINE
|
||
* unconditionally, wherever it appears, the same context-free way the other
|
||
* column-0 rules do.
|
||
*
|
||
* PAIRED, NOT UNCONDITIONAL (#3078 round-4 MAJOR 1). Blanking every indented
|
||
* delimiter LINE on sight perturbs fence PAIRING in BOTH directions, because
|
||
* CommonMark lets a COLUMN-0 fence be closed by a delimiter indented up to
|
||
* three spaces:
|
||
* - a column-0 opener closed by an INDENTED closer had its closer blanked,
|
||
* so the fence never closed for `tokenizeHeadings` and every later row —
|
||
* including a genuinely column-0 `### N.` with an outstanding `result:` —
|
||
* was swallowed;
|
||
* - the mirror, an INDENTED opener closed by a COLUMN-0 closer, had its
|
||
* opener blanked, PROMOTING that closer into an opener and swallowing
|
||
* everything after it instead.
|
||
* Both documents are legal CommonMark that renders correctly, so neither may
|
||
* lose content. The decision is therefore made per FENCED BLOCK, not per line:
|
||
* a block is neutralised only when it is indented at BOTH ends (or is an
|
||
* indented opener that never closes at all) — i.e. when nothing about it is
|
||
* column-0 document structure. That is exactly the intended case, an indented
|
||
* fence pair living wholly inside an `expected: |` block-scalar value, which
|
||
* is why the helper exists; any block with a column-0 delimiter at either end
|
||
* is left completely alone so its pairing reaches the tokenizer unchanged.
|
||
*
|
||
* COLUMN 0 IS STRUCTURE, INDENTATION IS CONTENT — and that rule is applied in
|
||
* ONE direction only, to the DELIMITERS. Only the two delimiter lines of a
|
||
* neutralised block are blanked; its body is left exactly as written. A
|
||
* column-0 `### N.` sitting between two indented delimiters therefore becomes
|
||
* a real heading, and a `result:` line after it belongs to that heading. That
|
||
* is CORRECT under this rule, not theft: by the very rule that selected the
|
||
* block for neutralisation, an indented delimiter is not a fence at all, so
|
||
* there is no fence for the column-0 line to be "inside" of. The document is
|
||
* malformed; reading it this way is the consistent reading, and it is PINNED
|
||
* by test (see "#3078 round 5: column 0 is structure" in tests/uat.test.cjs).
|
||
* Blanking the whole block open-to-close was tried and REVERTED: it destroys
|
||
* content legitimately living between the delimiters, and — for the
|
||
* unterminated-opener case, where the "body" runs to EOF — silently deletes
|
||
* the entire remainder of the document, dropping every later row.
|
||
*
|
||
* NO SECOND FENCE DIALECT: the blocks come from `scanFencedBlocks`
|
||
* (markdown-sectionizer.cts), the SAME exported CommonMark state machine
|
||
* `stripFencedCode` — and therefore `tokenizeHeadings` — runs. Backtick AND
|
||
* tilde runs, run length >= 3, the <= 3-space indent tolerance, a closer of
|
||
* the same char with run length >= the opener and no trailing text, info
|
||
* strings (including the "a backtick fence's info string may not contain a
|
||
* backtick" rule), and the unterminated-at-EOF case are all classified by that
|
||
* engine, not re-derived here. This module contributes only the column-0
|
||
* question — which delimiter lines are structure — via
|
||
* `INDENTED_FENCE_DELIM_RE`.
|
||
*
|
||
* LINE-BASED by construction (`content.split('\n')` / `.join('\n')`), never
|
||
* character-array splicing — the exact bug class (`Array.from(content)`
|
||
* code-point indexing against UTF-16 offsets) that made the original
|
||
* `maskBlockScalarBodies` corrupt astral-character documents. A line's own
|
||
* `.length` and `' '.repeat(line.length)` are measured in the same (UTF-16)
|
||
* units as the string itself, so this cannot misalign regardless of
|
||
* code-point framing, and CRLF survives untouched: `split('\n')` leaves any
|
||
* `\r` attached to the end of its line, and blanking that line replaces the
|
||
* `\r` with a space exactly like every other character on it — `join('\n')`
|
||
* then reproduces the original line count and total length exactly.
|
||
*/
|
||
function blankIndentedFenceDelimiters(content: string): string {
|
||
const lines = content.split('\n');
|
||
const isIndentedDelimiter = (idx: number): boolean =>
|
||
idx >= 0 && idx < lines.length && INDENTED_FENCE_DELIM_RE.test(lines[idx].replace(/\r$/, ''));
|
||
|
||
const blank = new Set<number>();
|
||
for (const block of scanFencedBlocks(lines)) {
|
||
// A column-0 OPENER is real document structure: leave the whole block
|
||
// alone, closer included, so an indented closer still closes it.
|
||
if (!isIndentedDelimiter(block.openLineIdx)) continue;
|
||
// An indented opener paired with a COLUMN-0 closer is likewise real
|
||
// structure at its far end — blanking the opener would promote that closer
|
||
// into an opener and hide everything after it.
|
||
if (block.closeLineIdx !== -1 && !isIndentedDelimiter(block.closeLineIdx)) continue;
|
||
// DELIMITERS ONLY — never the body. THE RULE: column 0 is structure,
|
||
// indentation is content. An indented delimiter therefore neutralises
|
||
// ITSELF, but it never hides column-0 structure sitting between
|
||
// delimiters: a column-0 `### N.` there IS a heading, and a `result:`
|
||
// after it IS that heading's. Widening this to the whole block was tried
|
||
// (#3078 round 5) and reverted — it deletes content that legitimately
|
||
// lives between the delimiters, and on an UNTERMINATED indented opener it
|
||
// blanks to EOF, taking every later row with it. Pinned by test; do not
|
||
// "fix" it back.
|
||
blank.add(block.openLineIdx);
|
||
if (block.closeLineIdx !== -1) blank.add(block.closeLineIdx);
|
||
|
||
// #3078 round-6 MAJOR: the two fence engines must not disagree about the
|
||
// text handed downstream. `scanFencedBlocks` classified the ORIGINAL
|
||
// lines, but `tokenizeHeadings` re-runs its own CommonMark state machine
|
||
// over this MUTATED copy. A COLUMN-0 delimiter-shaped line that was mere
|
||
// fence CONTENT in the original — e.g. a ```-run inside an indented
|
||
// ````-pair — is PROMOTED to a real opener the instant its enclosing
|
||
// delimiters are blanked, hiding every later heading to EOF. Blank those
|
||
// too, so the mutation cannot manufacture structure that the classifying
|
||
// engine never saw.
|
||
//
|
||
// DELIMITER-SHAPED LINES ONLY. A column-0 `### N.` heading between
|
||
// neutralised delimiters stays a heading (the pinned "column 0 is
|
||
// structure" behaviour), and the field lines of a row living between two
|
||
// rows' scalars survive untouched — both are pinned by test. This adds
|
||
// exactly one shape to the blank set: a line that would itself be read as
|
||
// a fence delimiter.
|
||
const inner = block.closeLineIdx === -1 ? lines.length : block.closeLineIdx;
|
||
for (let i = block.openLineIdx + 1; i < inner; i += 1) {
|
||
if (INDENT_TOLERANT_DELIM_RE.test(lines[i].replace(/\r$/, ''))) blank.add(i);
|
||
}
|
||
}
|
||
if (blank.size === 0) return content;
|
||
|
||
return lines.map((line, i) => (blank.has(i) ? ' '.repeat(line.length) : line)).join('\n');
|
||
}
|
||
|
||
/**
|
||
* Truncate `block` at its first TOP-LEVEL fenced-code opener (#3078 blocker).
|
||
*
|
||
* `parseExpectedFromTestBlock` must read the RAW block (an `expected: |` scalar
|
||
* may legitimately reproduce fenced-looking text verbatim, so a fence-STRIPPED
|
||
* copy would corrupt the field). But a raw block slice can run straight into
|
||
* content that `tokenizeHeadings` correctly hid inside a fence — including a
|
||
* LATER test row's own `expected:` line, which the earlier row then published
|
||
* as its own. Clipping at the fence opener bounds the raw read to the part of
|
||
* the block the tokenizer also considered visible.
|
||
*
|
||
* Column-0 fences only (`FENCE_OPENER_RE`): a fenced sample nested inside a
|
||
* legitimate `expected: |` value is indented by construction, so it is invisible
|
||
* here and cannot clip the very field this exists to preserve.
|
||
*/
|
||
function clipBlockAtFirstFence(block: string): string {
|
||
const rawLines = block.split('\n');
|
||
|
||
let firstFenceLine = -1;
|
||
for (let i = 0; i < rawLines.length; i += 1) {
|
||
if (FENCE_OPENER_RE.test(rawLines[i])) {
|
||
firstFenceLine = i;
|
||
break;
|
||
}
|
||
}
|
||
if (firstFenceLine === -1) return block;
|
||
|
||
const beforeFence = rawLines.slice(0, firstFenceLine).join('\n');
|
||
if (parseExpectedFromTestBlock(beforeFence)) return beforeFence;
|
||
|
||
// #3078 follow-up MINOR 2: an `expected:` field appearing AFTER a fence has
|
||
// CLOSED is not a theft risk — only content strictly INSIDE the fence must
|
||
// stay hidden. The plain "clip at first opener" result above silently
|
||
// discards a late `expected:` even when it sits outside every fence.
|
||
// Reconstruct the block with every top-level FENCED REGION dropped, keeping
|
||
// RAW text everywhere else. This exposes a late `expected:` living after a
|
||
// fence closes, while an `expected:` living strictly inside the fence is
|
||
// dropped along with it and stays unreachable — the "inside a fence" vs.
|
||
// "after a closed fence" split falls straight out of whether the
|
||
// fence-tracking state machine below is OPEN or CLOSED at that line, not out
|
||
// of position relative to the FIRST fence opener alone.
|
||
const visible = dropTopLevelFencedRegions(rawLines);
|
||
if (parseExpectedFromTestBlock(visible)) return visible;
|
||
|
||
return beforeFence;
|
||
}
|
||
|
||
/**
|
||
* Reconstruct `rawLines` with every TOP-LEVEL fenced region removed. Mirrors
|
||
* `stripFencedCode`'s own delimiter algorithm — a fence run of the SAME
|
||
* character and at least the SAME length, with no trailing content, is what
|
||
* closes an open fence — so "inside a fence" here means the same thing it means
|
||
* to the rest of this module's fence handling. An UNTERMINATED fence (open at
|
||
* EOF) drops everything from its opener to the end, same as `stripFencedCode`.
|
||
*
|
||
* Delimiters are recognised at COLUMN 0 only, for the reason given on
|
||
* `FENCE_OPENER_RE`: an INDENTED fence run belongs to an `expected: |` value,
|
||
* not to document structure, and must not open a region here.
|
||
*/
|
||
function dropTopLevelFencedRegions(rawLines: string[]): string {
|
||
const kept: string[] = [];
|
||
let openFence: { char: string; len: number } | null = null;
|
||
const delimRe = /^(`{3,}|~{3,})(.*)$/;
|
||
|
||
for (let i = 0; i < rawLines.length; i += 1) {
|
||
const line = rawLines[i].replace(/\r$/, '');
|
||
const m = delimRe.exec(line);
|
||
if (m) {
|
||
const char = m[1][0];
|
||
const len = m[1].length;
|
||
const trailing = m[2];
|
||
if (openFence === null) {
|
||
if (char === '`' && trailing.includes('`')) {
|
||
// Not a valid fence opener (CommonMark: backtick info string must
|
||
// not contain a backtick) — ordinary content.
|
||
kept.push(rawLines[i]);
|
||
continue;
|
||
}
|
||
openFence = { char, len };
|
||
} else if (char === openFence.char && len >= openFence.len && /^\s*$/.test(trailing)) {
|
||
openFence = null;
|
||
}
|
||
continue; // all delimiter lines are dropped, opener or closer
|
||
}
|
||
if (openFence === null) kept.push(rawLines[i]);
|
||
// Lines inside an open fence are silently dropped.
|
||
}
|
||
|
||
return kept.join('\n');
|
||
}
|
||
|
||
/**
|
||
* Count the INDENTED (1-3 space) `### N.` heading-shaped lines in `surface`
|
||
* that are NOT the value text of a preceding `expected:` block scalar.
|
||
*
|
||
* Why the exclusion (#3078 round-4 MAJOR 2): the parse gate refuses BOTH
|
||
* shapes for the same reason (column 0 is structure), but only one of them is
|
||
* a lost ROW. A `### 3. Fake Row` line sitting inside an `expected: |` value is
|
||
* the row's own published `expected:` string — already surfaced, verbatim, on
|
||
* the item — so counting it would flag a parse gap against a document with
|
||
* nothing missing (the pinned scalar-body behaviour). A ` ### 1. Indented
|
||
* Row` that no scalar owns is a row the parser declined to read, and must be
|
||
* visible as an unparsed block instead of silently clean.
|
||
*
|
||
* Attribution is structural and cheap: walk BACK from the indented heading to
|
||
* the first non-blank line at column 0 (a block-scalar body is indented by
|
||
* construction, and blank lines are legal inside one). The heading is scalar
|
||
* VALUE exactly when that line is ANY `key:` scalar header — not `expected:`
|
||
* only (#3078 MINOR 1: testing the `expected:`-only grammar false-positived
|
||
* on an indented heading-shaped line inside a DIFFERENT block scalar, e.g. a
|
||
* template-sanctioned `reported: |` holding verbatim user prose, or a
|
||
* `reason: |` body) — per `ANY_KEY_SCALAR_HEADER_LINE_RE`, derived from the
|
||
* SAME `[|>]`-family opener grammar the reader itself uses. No second opener
|
||
* dialect, and no attempt to model YAML indentation levels.
|
||
*/
|
||
function countUnattributedIndentedRows(surface: string): number {
|
||
const lines = surface.split('\n');
|
||
|
||
// LINEAR, not quadratic (#3078 round-6 MINOR 2). The walk-back above was
|
||
// re-scanned per indented row, so a document of N rows and N lines cost
|
||
// O(N^2) — measured 4x per 2x on real input (1000 rows 20ms → 16000 rows
|
||
// 3.6s). The walk only ever asks ONE question of the prefix — "which is the
|
||
// nearest preceding non-blank COLUMN-0 line?" — and that is a running value,
|
||
// so a single forward pass computes it for every line at once. The
|
||
// ATTRIBUTION RULE IS UNCHANGED: a blank line and an indented line are both
|
||
// transparent (a block-scalar body is indented by construction and may
|
||
// contain blank lines), and the first line that is neither terminates the
|
||
// scalar; the heading is value text exactly when THAT line is any key's
|
||
// block-scalar header.
|
||
const stripped = lines.map((line) => line.replace(/\r$/, ''));
|
||
const nearestColumnZero: number[] = new Array<number>(lines.length);
|
||
let last = -1;
|
||
for (let i = 0; i < stripped.length; i += 1) {
|
||
nearestColumnZero[i] = last;
|
||
const line = stripped[i];
|
||
if (line.trim() !== '' && !/^[ \t]/.test(line)) last = i;
|
||
}
|
||
|
||
let count = 0;
|
||
for (let i = 0; i < lines.length; i += 1) {
|
||
if (!INDENTED_TEST_HEADING_LINE_RE.test(lines[i])) continue;
|
||
const owner = nearestColumnZero[i];
|
||
const ownedByScalar = owner !== -1 && ANY_KEY_SCALAR_HEADER_LINE_RE.test(stripped[owner]);
|
||
if (!ownedByScalar) count += 1;
|
||
}
|
||
|
||
return count;
|
||
}
|
||
|
||
/**
|
||
* `headingsSeen` is the TOTAL parse-gap tally (every heading-shaped thing this
|
||
* parser could not turn into an item). `shortfallBlocks` is the SUBSET of it
|
||
* contributed by the fence-suppression shortfall scan below — the one gap class
|
||
* this module documents as carrying an ACCEPTED OVER-REPORT (a closed-fence
|
||
* documentation sample written with literal digits is indistinguishable from a
|
||
* genuinely fence-straddled row; see the long comment at the scan itself).
|
||
* Reported separately so a consumer that must decide whether to WITHHOLD a
|
||
* derived number — as opposed to merely REPORT the gap — can tell "a row I
|
||
* definitely could not read" from "a row I possibly mis-counted".
|
||
*
|
||
* #3707-CR: `src/planning-inspect.cts`'s `buildUatRows` does NOT destructure
|
||
* this field (verified — it and `cmdAuditUat` both consume only `items` and
|
||
* `headingsSeen`), correcting an earlier stated instruction that it did.
|
||
* `shortfallBlocks` currently has NO production consumer outside this
|
||
* function's own computation. It is retained on the return value anyway,
|
||
* deliberately, as part of this function's published stats contract — tests
|
||
* assert on the full `{ items, headingsSeen, shortfallBlocks }` shape, and
|
||
* dropping a returned field is a wider, unrelated change than a line-ending
|
||
* fix warrants. A future consumer that needs to distinguish an
|
||
* accepted-over-report shortfall from the rest of `headingsSeen` (the
|
||
* original design intent above) can still do so.
|
||
*/
|
||
function parseUatItemsWithStats(content: string): { items: UatItem[]; headingsSeen: number; shortfallBlocks: number } {
|
||
content = normalizeLineEndings(content);
|
||
const items: UatItem[] = [];
|
||
let headingsSeen = 0;
|
||
let shortfallBlocks = 0;
|
||
|
||
// Locate every `### N. Name` test heading across the WHOLE document (not
|
||
// adjacency-matched against `result:`, #3707 defect 2) and slice each one's
|
||
// own block from its heading to the next heading OF ANY LEVEL (or EOF) —
|
||
// a trailing `## Gaps` section or an interleaved `### Notes` heading must
|
||
// not be absorbed into the preceding test's block, else its unanchored
|
||
// `reason:`/`blocked_by:` scans below bleed a Gaps entry's fields onto the
|
||
// last test row.
|
||
// #3078 blocker: only a COLUMN-0 heading is document structure here (see
|
||
// `isColumnZeroHeading`). The filter is applied to the WHOLE token stream,
|
||
// not just to the `### N.` rows, because an indented heading must not act as
|
||
// a block BOUNDARY either — a `### 3. Fake Row` line inside an `expected: |`
|
||
// value would otherwise truncate its own row's block just before the real
|
||
// `result:` line and drop a genuinely outstanding row from `items`.
|
||
//
|
||
// #3078 follow-up: tokenize a copy with every indented fence delimiter
|
||
// blanked out (`blankIndentedFenceDelimiters`) BEFORE the column-0 filter
|
||
// ever runs. Otherwise an indented ` ``` ` opener inside an `expected: |`
|
||
// value still opens a real fence as far as `tokenizeHeadings` (a
|
||
// CommonMark scanner, {0,3}-space fence tolerance) is concerned, hiding
|
||
// every heading up to its closer from the token stream entirely — a LATER,
|
||
// genuinely column-0 `### N.` row is never returned as a token at all, so
|
||
// no post-hoc filter over the token stream could recover it.
|
||
const allHeadings = tokenizeHeadings(blankIndentedFenceDelimiters(content)).filter((h) => isColumnZeroHeading(content, h));
|
||
// #3707 follow-up MINOR: `^\d+\.` alone — a trailing name is OPTIONAL
|
||
// (`### 3.` and `### 3.Foo`, without the space the old `\s+`-anchored
|
||
// pattern required, both count) so a heading missing or squishing its name
|
||
// still contributes to `headingsSeen`/items rather than being silently
|
||
// excluded from BOTH — the same vanishing-row symptom the parse-gap flag
|
||
// exists to catch, reachable here at the heading-filter layer instead.
|
||
// #3078 round-5 MAJOR: that rule now lives in `isTestRowHeadingText` and is
|
||
// shared verbatim with `parseFirstPendingTest`, which used to disagree.
|
||
// Carry each match's own index into `allHeadings` from the filter pass
|
||
// itself (security review finding 3) rather than re-deriving it via
|
||
// `allHeadings.indexOf(current)` inside the loop below — the latter is an
|
||
// O(n) scan per heading, making the whole loop O(n^2) in document size.
|
||
const subHeadings: { heading: (typeof allHeadings)[number]; index: number }[] = [];
|
||
allHeadings.forEach((h, index) => {
|
||
if (h.level === 3 && isTestRowHeadingText(h.text)) subHeadings.push({ heading: h, index });
|
||
});
|
||
|
||
// #3078 blocker: `tokenizeHeadings` is fence-aware, so a BALANCED fence pair
|
||
// that opens after one test row and closes after a later one makes every
|
||
// `### N.` heading between them invisible — the rows are not merely
|
||
// unparseable, they are absent from the token stream, so the loop below can
|
||
// never count them and the file reports as CLEAN with an outstanding
|
||
// `result: blocked` inside it. (origin/next's old whole-file regex did
|
||
// surface those rows, making the silent drop a regression.) Comparing the
|
||
// count of heading-SHAPED source lines against the headings the tokenizer
|
||
// actually returned recovers the shortfall; each suppressed row counts
|
||
// toward `headingsSeen`, so the file is flagged as a parse gap rather than
|
||
// silently clean. The line scan is anchored at COLUMN 0 (`TEST_HEADING_LINE_RE`)
|
||
// by the same rule the token filter uses, so a `### N.`-shaped line living
|
||
// inside an `expected: |` value — which is value text, not a suppressed row —
|
||
// cannot inflate the tally.
|
||
//
|
||
// #3078 round-7 HIGH — SYMMETRY IS THE INVARIANT. BOTH SIDES OF THIS
|
||
// COMPARISON ARE WHOLE-DOCUMENT. DO NOT SCOPE EITHER ONE. Read this whole
|
||
// comment before "optimising" the `## Notes` noise back out; three separate
|
||
// HIGH-severity silent false-cleans have been produced by three separate
|
||
// attempts to be clever about scope here, and every one of them was a
|
||
// regression against origin/next's plain whole-file regex.
|
||
//
|
||
// History of the failures, so they are not re-derived:
|
||
// - round-6 HIGH: the raw line scan was SECTION-SCOPED to the `## Tests`
|
||
// body while `subHeadings` stayed whole-document, so a legal
|
||
// `### 9. Old / result: pass` row in a preceding `## Prior` section
|
||
// decremented the shortfall by one and SILENTLY DISABLED the
|
||
// fence-straddle detector.
|
||
// - round-7 HIGH: "equalising" that by ALSO scoping the token side to the
|
||
// section's offset span made the two counters agree with each other but
|
||
// left the PARSE side whole-document — so a `### N.` row living OUTSIDE
|
||
// the first `## Tests` section was parsed and surfaced normally when
|
||
// visible, yet vanished with NO item AND NO parse_gap the moment a fence
|
||
// straddled it: neither side of the comparison covered it. Reproduced
|
||
// three ways — a straddle inside a `## Regression Tests` section, a
|
||
// straddle inside a SECOND `## Tests` section (`collectSection` takes the
|
||
// FIRST match only), and, as control, the identical straddle in a file
|
||
// with no `## Tests` heading at all, which alone reported correctly.
|
||
//
|
||
// THE RULE: the parse side reads rows wherever they live in the document, so
|
||
// the counting side must too. Scan shaped `### N.` lines over the ENTIRE
|
||
// document and compare against ALL tokenized row headings. Any narrowing of
|
||
// one side that is not matched by the other manufactures a blind spot, and a
|
||
// blind spot here is a SILENT FALSE CLEAN — a file with an outstanding
|
||
// `result: blocked` in it that never even enters `results`.
|
||
//
|
||
// ACCEPTED CONSEQUENCE, DELIBERATELY TRADED (this replaces the #3078
|
||
// follow-up MINOR 1 scoping): a `### N.`-shaped line inside a properly
|
||
// CLOSED fence in a `## Notes` section — a documentation sample of the row
|
||
// format. NOTE the shape needs LITERAL DIGITS — the scan requires `\d+`, so the
|
||
// conventional placeholder `### N. Name` does NOT trigger it; only a sample written
|
||
// with real numbers (`### 1. Example Row`) does. On FREQUENCY, claim only what is
|
||
// measurable here: the SHAPE is uncommon (it takes a literal-digit row inside a
|
||
// CLOSED fence), and that is a claim about the shape, NOT a measurement across real
|
||
// projects. The in-tree sample size for it is ZERO PHASE FILES — the only `*UAT*.md`
|
||
// anywhere in this repo is the shipped template (which `selectPhaseUatFiles` never
|
||
// scans, and which itself scores headingsSeen=11, six of them literal-digit example
|
||
// rows), so "no phase UAT file in-tree triggers it" is vacuously true and proves
|
||
// nothing about rarity in the field. Do not restate it as evidence. If you test the
|
||
// placeholder form, see no over-report, and conclude this pin is stale: it is not.
|
||
// The ordinary way to explain the syntax inside a UAT file — is
|
||
// counted as a suppressed row and raises a parse gap on a file with nothing
|
||
// actually missing. That is an OVER-report: noisy, but VISIBLE and FAIL-SAFE
|
||
// (an agent reads the file and dismisses it). Fence-closedness cannot
|
||
// distinguish it from a genuinely hidden row, because the fence-straddle
|
||
// case this scan exists to catch is ALSO a properly closed fence — so the
|
||
// only lever left is scope, and scope is exactly what produced the two
|
||
// silent false-cleans above. This entire issue exists to eliminate false
|
||
// cleans, so the trade goes this way ON PURPOSE: an extra noisy row beats an
|
||
// invisible missing one. The behaviour is pinned by test; do not "fix" it.
|
||
let shapedHeadingLines = 0;
|
||
for (const line of content.split('\n')) {
|
||
if (TEST_HEADING_LINE_RE.test(line)) shapedHeadingLines += 1;
|
||
}
|
||
if (shapedHeadingLines > subHeadings.length) {
|
||
shortfallBlocks = shapedHeadingLines - subHeadings.length;
|
||
headingsSeen += shortfallBlocks;
|
||
}
|
||
|
||
// #3078 round-4 MAJOR 2: an INDENTED `### N.` row is refused by the parse
|
||
// gate (`isColumnZeroHeading`) — correct — but must not therefore vanish
|
||
// without a trace. See `countUnattributedIndentedRows` for why an indented
|
||
// heading that is the VALUE of a preceding `expected:` block scalar is
|
||
// excluded from this tally (it is value text, not a row), keeping the
|
||
// scalar-body pins intact while a genuinely indented ROW surfaces as a gap.
|
||
//
|
||
// WHOLE-DOCUMENT, for the same reason as the shortfall scan above: this
|
||
// counter has no token-side twin to disagree with, but scoping it to a
|
||
// `## Tests` body would silently drop an indented row living anywhere else
|
||
// in the file — the identical vanishing-row class. Its own false-positive
|
||
// guard is STRUCTURAL (scalar attribution via
|
||
// `ANY_KEY_SCALAR_HEADER_LINE_RE`) — with ONE positional caveat: the walk stops at the
|
||
// nearest COLUMN-0 line, so a block scalar nested inside a `## Gaps` bullet (a
|
||
// `- truth:` entry carrying an indented `note: |`) is transparent to it and a
|
||
// heading-shaped line inside that value is counted. That is another instance of the
|
||
// accepted over-report above, not a separate defect, not positional, so it needs no scope.
|
||
headingsSeen += countUnattributedIndentedRows(content);
|
||
|
||
// #3078: an UNTERMINATED fence swallows the entire remainder of the
|
||
// document — every later test row AND a trailing `## Gaps` section — so the
|
||
// file yields nothing at all and never even enters `results`: a whole-file
|
||
// false clean. Mirrors the per-file malformed-markdown guard
|
||
// `evaluateUatPassed` already applies via `analyzeMarkdown`
|
||
// (src/uat-predicate.cts:278), which likewise gates on
|
||
// `stripFencedCode(raw).unterminatedFence`. Deliberately measured on the RAW
|
||
// document: a fence opened inside an `expected:` scalar is still an
|
||
// unterminated fence for every downstream markdown consumer, and the masked
|
||
// copy would hide it.
|
||
if (stripFencedCode(content).unterminatedFence) {
|
||
headingsSeen += 1;
|
||
}
|
||
|
||
for (let i = 0; i < subHeadings.length; i += 1) {
|
||
const { heading: current, index: currentIdx } = subHeadings[i];
|
||
const next = allHeadings[currentIdx + 1];
|
||
const block = next ? content.slice(current.offset, next.offset) : content.slice(current.offset);
|
||
// Fence-stripped copy for the `result:`/`reason:`/`blocked_by:` field
|
||
// scans below (#3707 follow-up MAJOR/regression): `block` is raw slice
|
||
// text, and a fenced code sample inside a test block (a legitimate way to
|
||
// document expected output) can contain a line that LOOKS like a field
|
||
// declaration (e.g. an example ` ```\nresult: pending\n``` `). Scanning
|
||
// raw text reads that sample's `result:` as the test's real outcome —
|
||
// origin/next returned null here, so an unstripped scan is a regression,
|
||
// not a pre-existing behavior to preserve. `parseExpectedFromTestBlock`
|
||
// below still receives the RAW `block`, not this stripped copy: an
|
||
// `expected: |` block-scalar value may legitimately reproduce
|
||
// fenced-looking text verbatim, and stripping it would corrupt that field.
|
||
// #3707 round-3 MINOR: an UNTERMINATED fence (EOF inside a fence, or —
|
||
// here, scoped per test block — the closing delimiter living in a LATER
|
||
// block, so from this block's own slice the fence never closes) makes
|
||
// `stripFencedCode` drop everything from the opener to the end of the
|
||
// block, including a real `result:`/`reason:`/`blocked_by:` line that
|
||
// follows it. Falling back to the RAW (unstripped) block in that case
|
||
// means a legitimate fenced-code false-positive (a `result:`-shaped line
|
||
// INSIDE a properly-closed sample) is still guarded against in the common
|
||
// case, while a malformed/unterminated fence no longer silently swallows
|
||
// a real field line into a false parse_gap.
|
||
const stripResult = stripFencedCode(block);
|
||
const fenceStrippedBlock = stripResult.unterminatedFence ? block : stripResult.text;
|
||
|
||
// A block with no `result:` line at all is not a test row (e.g. still
|
||
// being drafted) — no item, no false positive. It IS, however, a heading
|
||
// that failed to yield an item for a reason other than a PASS token, so
|
||
// it counts toward `headingsSeen` (used to detect a genuine parse gap).
|
||
// Deliberately NOT end-anchored (regression fix, #3707 blocker 1): a
|
||
// trailing comment/clause after the token (`result: pending (blocked on
|
||
// staging)`, `result: [skipped] # no device`, `result: blocked -
|
||
// waiting`) must still match and surface the row instead of being
|
||
// silently dropped. The trailing text itself is matched-and-ignored
|
||
// (#3707 follow-up MINOR): it is NOT synthesized into `reason` — a real
|
||
// `reason:` line is the only source for that field (see below) — because
|
||
// doing so previously changed `categorizeItem`'s classification for
|
||
// shapes origin/next categorized differently (an unpinned behavior
|
||
// change, not something the blocker required).
|
||
// #3078-CR defect A fix, split-then-match scan: the previous `.match()`
|
||
// against `/^result:.../im` ran a MULTILINE regex anchor directly over
|
||
// unsplit block text. ECMA-262's LineTerminator set for `^`/`$` under
|
||
// `/m` includes U+2028 LINE SEPARATOR and U+2029 PARAGRAPH SEPARATOR, but
|
||
// `content.split('\n')` and this module's own heading tokenizer do NOT
|
||
// treat either as a boundary. A `result:`-shaped line inside an
|
||
// `expected: |` scalar body, sitting immediately after one of these
|
||
// separators instead of an ordinary character, was therefore read as a
|
||
// genuine line start by the regex engine even though it is not
|
||
// `\n`-delimited from anything — it is exactly as much "one line" to
|
||
// every other consumer as the ordinary-character control case.
|
||
// Splitting on `\n` FIRST and testing each already-split line against a
|
||
// single-line (`/im`-anchor-free) pattern fixes this: a line is never
|
||
// split by U+2028/U+2029 (`String.prototype.split` matches only its
|
||
// literal separator argument, never the wider ECMA-262 LineTerminator
|
||
// set), so a `result:`-shaped line reachable only via one of those
|
||
// separators can never register as its own split line — the split view
|
||
// and the regex view are back in agreement, by construction, exactly the
|
||
// way `splitLines` module is documented to be immune to the sibling `\r`
|
||
// bug.
|
||
//
|
||
// FIRST MATCH WINS (byte-identical to origin/next otherwise): a block
|
||
// with more than one column-0 `result:` line resolves to the FIRST one
|
||
// encountered, same as the pre-existing `.match()` behaviour without
|
||
// `/g` — this is deliberately NOT an ambiguity/parse-gap case (that
|
||
// variant was tried and reverted: its boundary-truncation heuristic
|
||
// mistook an indented `### N.` living inside a legitimate block scalar
|
||
// for a heading boundary, corrupting every scalar/indent guard in this
|
||
// module — see tests/uat.test.cjs's #3078 scalar guard family).
|
||
// Trailing text is matched with `[^]*` rather than `.*` (final review
|
||
// MINOR 1): `.` never matches U+2028/U+2029, so a column-0 `result:`
|
||
// line whose trailing text contains one of those separators would
|
||
// otherwise never reach `$`, and the whole line would fail to match —
|
||
// an unpinned regression against origin/next, which parses it.
|
||
const RESULT_LINE_RE = /^result:\s*\[?(\w+)\]?[^]*$/i;
|
||
const resultLineMatch = fenceStrippedBlock
|
||
.split('\n')
|
||
.map((line) => line.match(RESULT_LINE_RE))
|
||
.find((m): m is RegExpMatchArray => m !== null);
|
||
if (!resultLineMatch) {
|
||
headingsSeen += 1;
|
||
continue;
|
||
}
|
||
// Security review finding 2: store the token lower-cased so the published
|
||
// `result` field agrees with `category` (which categorizeItem already
|
||
// lower-cases internally, below). No consumer needs the original casing —
|
||
// `uat-predicate.cts` runs its own independent parser and already
|
||
// lower-cases too — so the raw-cased form is kept nowhere.
|
||
const result = resultLineMatch[1].toLowerCase();
|
||
|
||
// #3707 defect 1: invert the old DROP-list filter to a PASS set — see
|
||
// UAT_PASS_RESULTS's doc comment for why this direction was chosen.
|
||
// A recognised PASS token is the ONLY reason a heading is excluded from
|
||
// `headingsSeen` without producing an item — every other non-yielding
|
||
// case (missing `result:` line, above) is a genuine parse gap.
|
||
// `result` is already lower-cased at its extraction above, which is the
|
||
// single point of normalization for this value — re-lowercasing here was
|
||
// dead work and implied a second, independent normalization that does not
|
||
// exist (#3078 round-5 MINOR).
|
||
if (UAT_PASS_RESULTS.has(result)) continue;
|
||
|
||
// #3707 follow-up MINOR: the heading filter above now admits `### 3.`
|
||
// (no name at all) and `### 3.Foo` (no space before the name), so this
|
||
// extraction is loosened in lockstep — a bare number with no trailing
|
||
// name falls back to the heading's own trimmed text (`3.`). #3078 round-5
|
||
// MAJOR: shared with `parseFirstPendingTest` via `parseTestRowHeadingText`.
|
||
const headingParts = parseTestRowHeadingText(current.text)!;
|
||
const testNumber = headingParts.number;
|
||
const testName = headingParts.name;
|
||
|
||
// Reuse the existing block-scalar/inline `expected:` grammar rather than
|
||
// re-deriving a second one (#3707 defect 2). #3078 blocker: the block is
|
||
// CLIPPED at its first top-level fence opener first — still raw text (a
|
||
// legitimate `expected: |` scalar must be read verbatim, fences and all),
|
||
// but bounded to what the tokenizer also treated as visible, so this row
|
||
// cannot reach past a fence into a LATER row's `expected:` line and
|
||
// publish it as its own. See `clipBlockAtFirstFence`.
|
||
const expected = parseExpectedFromTestBlock(clipBlockAtFirstFence(block));
|
||
|
||
// #3078 MINOR 2: `reason:`/`blocked_by:` previously had no block-scalar
|
||
// grammar at all (only a plain `/key:\s*(.+)/` single-line match), so a
|
||
// `reason: |`/`reason: >`/`blocked_by: |` value silently published as the
|
||
// literal string `"|"` / `">"`, discarding the real multi-line value the
|
||
// author wrote — and `categorizeItem` below reads exactly this field, so a
|
||
// discarded `reason` could silently change an item's category. Routed
|
||
// through the SAME `extractScalarField` machinery `expected:` already
|
||
// uses rather than adding a third hand-rolled opener dialect.
|
||
const reason = extractScalarField(fenceStrippedBlock, 'reason') ?? undefined;
|
||
const blockedBy = extractScalarField(fenceStrippedBlock, 'blocked_by') ?? undefined;
|
||
|
||
const item: UatItem = {
|
||
test: testNumber,
|
||
name: testName,
|
||
result,
|
||
category: categorizeItem(result, reason, blockedBy),
|
||
};
|
||
if (expected) item.expected = expected;
|
||
if (reason) item.reason = reason;
|
||
if (blockedBy) item.blocked_by = blockedBy;
|
||
items.push(item);
|
||
}
|
||
|
||
items.push(...parseGapsItems(content));
|
||
return { items, headingsSeen, shortfallBlocks };
|
||
}
|
||
|
||
/**
|
||
* ITEMS-ONLY convenience form over `parseUatItemsWithStats` — the same parse,
|
||
* with the `headingsSeen` parse-gap counter dropped, for a caller that only
|
||
* wants the rows.
|
||
*
|
||
* Deliberately RETAINED with no in-tree caller (#3078 round-5 MINOR): both
|
||
* `cmdAuditUat` and `src/planning-inspect.cts` need the stats form, so this is
|
||
* currently used only from outside. It is a public export of a shipped module,
|
||
* and removing an exported symbol is a CONTRACT change, out of scope for a bug
|
||
* fix — so it stays, as the documented thin wrapper it has always been, with a
|
||
* direct test of its own rather than as untested dead weight.
|
||
*/
|
||
function parseUatItems(content: string): UatItem[] {
|
||
return parseUatItemsWithStats(content).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);
|
||
}
|
||
|
||
// #2766: union with the table form. A `|`-leading line is never a `- ` bullet
|
||
// opener, so a section mixing bullet entries and a table surfaces both with no
|
||
// double-counting.
|
||
items.push(...parseGapsTableItems(gapsSection.body));
|
||
|
||
return items;
|
||
}
|
||
|
||
/**
|
||
* Split a section body into its GFM pipe tables, one entry per table (#2766).
|
||
*
|
||
* Shared by `parseGapsTableItems` and `parseDeferredTableItems` so the
|
||
* header/delimiter/table-boundary handling — the fiddly part — lives in exactly
|
||
* one place, and the two consumers only decide what a data row MEANS.
|
||
*
|
||
* Header detection is lookahead-free: the last data-shaped row is held in
|
||
* `pending` until the NEXT line decides its fate — a delimiter row
|
||
* (`|---|---|`) proves the held row was a header, anything else promotes it to a
|
||
* data row. So a conventional table drops exactly its header, a HEADERLESS table
|
||
* keeps every row (hand-authored planning tables often omit the delimiter), and
|
||
* a header with no data rows yields nothing. A prose or blank line ends the
|
||
* current table, so two tables separated by text are read independently and each
|
||
* drops its own header.
|
||
*
|
||
* Reuses the canonical `isDelimiterRow` shape check from markdown-table.cts
|
||
* rather than re-deriving it. Deliberately NOT routed through
|
||
* `parseMarkdownTable`, which reads only the FIRST table in a body and treats
|
||
* ragged/headerless shapes as errors (ADR-2143 §3) — correct for the mandated
|
||
* tables in STATE.md/ROADMAP.md, but the wrong contract here, where a malformed
|
||
* hand-written table must still surface its rows rather than be dropped.
|
||
*/
|
||
function collectTableRows(sectionBody: string): { header: string[] | null; rows: string[][] }[] {
|
||
const tables: { header: string[] | null; rows: string[][] }[] = [];
|
||
let current: { header: string[] | null; rows: string[][] } | null = null;
|
||
let pending: string[] | null = null;
|
||
|
||
const ensure = (): void => {
|
||
if (!current) current = { header: null, rows: [] };
|
||
};
|
||
const flushPending = (): void => {
|
||
if (pending) {
|
||
ensure();
|
||
current!.rows.push(pending);
|
||
pending = null;
|
||
}
|
||
};
|
||
const endTable = (): void => {
|
||
flushPending();
|
||
if (current) {
|
||
tables.push(current);
|
||
current = null;
|
||
}
|
||
};
|
||
|
||
for (const rawLine of sectionBody.split('\n')) {
|
||
const line = rawLine.replace(/\r$/, '').trim();
|
||
if (!line.startsWith('|')) {
|
||
endTable();
|
||
continue;
|
||
}
|
||
const cells = splitTableRow(line);
|
||
if (cells.length === 0) continue;
|
||
if (isDelimiterRow(cells)) {
|
||
ensure();
|
||
current!.header = pending; // may be null for a delimiter-first table
|
||
pending = null;
|
||
continue;
|
||
}
|
||
flushPending();
|
||
pending = cells;
|
||
}
|
||
endTable();
|
||
|
||
return tables;
|
||
}
|
||
|
||
/**
|
||
* Header-name → canonical Gaps field (#2766).
|
||
*
|
||
* Anchored on the `## Gaps` field vocabulary `templates/UAT.md` mandates for the
|
||
* YAML-lite bullet form (truth/status/reason/severity/test), plus the obvious
|
||
* synonyms a human writing the same information as a table reaches for instead.
|
||
*/
|
||
const GAPS_COLUMN_ALIASES: Record<string, 'truth' | 'status' | 'reason' | 'severity' | 'test'> = {
|
||
truth: 'truth', gap: 'truth', finding: 'truth', item: 'truth',
|
||
description: 'truth', issue: 'truth', name: 'truth',
|
||
status: 'status', result: 'status', state: 'status',
|
||
reason: 'reason', note: 'reason', notes: 'reason',
|
||
detail: 'reason', details: 'reason', evidence: 'reason',
|
||
severity: 'severity',
|
||
test: 'test', '#': 'test', 'test #': 'test', 'test number': 'test',
|
||
};
|
||
|
||
function mapGapsHeader(header: string[] | null): Record<string, number> | null {
|
||
if (!header) return null;
|
||
const columns: Record<string, number> = {};
|
||
header.forEach((cell, idx) => {
|
||
const key = GAPS_COLUMN_ALIASES[cell.trim().toLowerCase().replace(/\*+/g, '')];
|
||
if (key && !(key in columns)) columns[key] = idx;
|
||
});
|
||
return Object.keys(columns).length > 0 ? columns : null;
|
||
}
|
||
|
||
/**
|
||
* Extract gap entries from GFM pipe tables in a `## Gaps` section (#2766) — a
|
||
* UNION with the YAML-lite bullet scan in `parseGapsItems`, for the same reason
|
||
* `parseDeferredTableItems` exists: `splitGapsEntries` keys entirely on `- `
|
||
* bullet openers, so a table-shaped `## Gaps` section yielded ZERO items and
|
||
* every finding in it was silently invisible.
|
||
*
|
||
* Neither `templates/UAT.md` nor `templates/verification-report.md` documents a
|
||
* table for this section (both mandate the bullet/numbered form), so a table
|
||
* here is off-template hand-authoring — which is precisely why it must not fail
|
||
* silently. Note `parseVerificationItems` in this same file already reads table
|
||
* rows AND numbered AND bullet items as a union because the live sections mix
|
||
* shapes; the Gaps and deferred parsers never got the same treatment.
|
||
*
|
||
* When a header row is present its columns are mapped by name against the
|
||
* template's own field vocabulary (see GAPS_COLUMN_ALIASES) so a tabled gap
|
||
* carries the same status/reason/test fields as its bullet equivalent and
|
||
* `categorizeItem` classifies it identically. With no recognizable header, the
|
||
* row degrades to a joined-cells name with status `unknown` — surfaced, not
|
||
* dropped, matching this module's established fail-safe stance.
|
||
*
|
||
* Resolution follows the bullet path exactly: an entry is skipped ONLY on an
|
||
* explicit resolved marker — the mapped `status` column reading `resolved`, or,
|
||
* absent a status column, any cell reading exactly `resolved`. A gap with no
|
||
* parseable status is NEVER treated as resolved.
|
||
*/
|
||
function parseGapsTableItems(sectionBody: string): UatItem[] {
|
||
const items: UatItem[] = [];
|
||
|
||
for (const { header, rows } of collectTableRows(sectionBody)) {
|
||
const columns = mapGapsHeader(header);
|
||
for (const cells of rows) {
|
||
const at = (key: string): string =>
|
||
(columns && key in columns ? (cells[columns[key]] ?? '').trim() : '');
|
||
|
||
const rawStatus = at('status');
|
||
if (rawStatus && rawStatus.toLowerCase() === 'resolved') continue;
|
||
// No status column: fall back to an explicit resolved marker in any cell
|
||
// (the headerless-table equivalent of `status: resolved`).
|
||
if (!columns || !('status' in columns)) {
|
||
if (cells.some(c => /^resolved$/i.test(c.trim()))) continue;
|
||
}
|
||
|
||
const truth = at('truth');
|
||
const reason = at('reason');
|
||
const testNum = at('test');
|
||
const name = truth || cells.filter(c => c !== '').join(' — ');
|
||
if (!name) continue;
|
||
|
||
const status = rawStatus || 'unknown';
|
||
const item: UatItem = {
|
||
name,
|
||
result: status,
|
||
category: categorizeItem(status, reason || undefined, 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.
|
||
*
|
||
* #3457: when the section body contains headings, entries are delimited by
|
||
* LEAF headings (see `splitDeferredHeadingEntries`) rather than by bullets —
|
||
* the executor convention writes one deferred item as a heading followed by
|
||
* sibling `- **Field:** …` bullets, which the bullet-only split mis-counted as
|
||
* one item PER BULLET. A body with no headings keeps the original
|
||
* one-bullet-per-item split unchanged.
|
||
*/
|
||
/**
|
||
* One `deferred-items.md` entry with its RAW (un-lowercased) `status:` field
|
||
* value (`''` when the entry carries no parseable status). #3458 follow-up:
|
||
* `parseDeferredItems` (below) is now DEFINED IN TERMS OF this — it filters
|
||
* to `status !== 'resolved'` — and `audit.cts`'s `scanDeferredItems` also
|
||
* consumes this directly so it can tell `resolved` (fixed for real, never
|
||
* counted), the newer `acknowledged` (suppressed-but-tallied, #3458
|
||
* follow-up), and everything else (open) apart WITHOUT a second,
|
||
* independent entry-boundary/field-extraction pass that could drift from
|
||
* this one.
|
||
*/
|
||
function parseDeferredItemsWithStatus(content: string): Array<{ name: string; status: string }> {
|
||
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: Array<{ name: string; status: string }> = [];
|
||
|
||
// #3457: heading-delimited shape — an entry's fields live in sibling bullets
|
||
// (`- **Status:** resolved`), so the bullet marker is stripped on EVERY line
|
||
// before field extraction, not just line 0 (which `extractGapEntryFields`
|
||
// does for the headless/Gaps shape, where a later `- ` line is a nested
|
||
// sub-list, not a field).
|
||
const headingEntries = splitDeferredHeadingEntries(sectionBody);
|
||
const entries = headingEntries !== null
|
||
? headingEntries.map((entryLines) => ({
|
||
lines: entryLines,
|
||
fields: extractGapEntryFields(entryLines.map(stripLeadingBulletMarker)),
|
||
}))
|
||
: splitGapsEntries(sectionBody).map((entryLines) => ({
|
||
lines: entryLines,
|
||
fields: extractGapEntryFields(entryLines),
|
||
}));
|
||
|
||
for (const { lines: entryLines, fields } of entries) {
|
||
const text = rawGapEntryText(entryLines);
|
||
if (!text) continue;
|
||
|
||
items.push({ name: text, status: fields.status || '' });
|
||
}
|
||
|
||
// #2766: union with the table form — see parseDeferredTableItems. Executors
|
||
// write this file by hand with no mandated shape, and a GFM table is a natural
|
||
// choice for the common "test → failing seeds" case, which produced ZERO items.
|
||
// Table rows carry no independently-parseable status column in general —
|
||
// `parseDeferredTableItems` already excludes resolved/done/pass rows at its
|
||
// own layer (any cell reading exactly one of those three) — so anything it
|
||
// returns here is inherently open; `acknowledge` (#3458 follow-up) has no
|
||
// representable field to write for a table row, so those are reported with
|
||
// status `''` (never `resolved`/`acknowledged`) and remain permanently
|
||
// un-acknowledgeable via the CLI writer — a known, deliberate limitation
|
||
// (see `acknowledgeDeferredItem`'s doc comment).
|
||
items.push(...parseDeferredTableItems(sectionBody).map((item) => ({ name: item.name, status: '' })));
|
||
|
||
return items;
|
||
}
|
||
|
||
function parseDeferredItems(content: string): UatItem[] {
|
||
return parseDeferredItemsWithStatus(content)
|
||
.filter((entry) => !(entry.status && entry.status.toLowerCase() === 'resolved'))
|
||
.map((entry) => ({
|
||
name: entry.name,
|
||
result: 'unresolved',
|
||
category: 'deferred',
|
||
}));
|
||
}
|
||
|
||
// ─── acknowledgeDeferredItem ───────────────────────────────────────────────────
|
||
|
||
/** Result of `acknowledgeDeferredItem`. */
|
||
interface AcknowledgeDeferredItemResult {
|
||
content: string;
|
||
status: 'ok' | 'not_found' | 'ambiguous' | 'unsupported_heading_shape' | 'already_resolved' | 'match_verification_failed';
|
||
}
|
||
|
||
/**
|
||
* CLI-writer half of the #3458 follow-up deferred_items suppression seam.
|
||
* Sets the ONE deferred entry whose rendered text (`rawGapEntryText`, the
|
||
* same value `parseDeferredItemsWithStatus`/the audit's JSON output surface
|
||
* as `name`/`text`) exactly equals `targetText` to `status: acknowledged` —
|
||
* a NEW terminal value, distinct from the existing `resolved` (which keeps
|
||
* meaning "actually fixed"). This is the marker for this category: unlike
|
||
* every other audit category (a sibling `audit_acknowledged` frontmatter map
|
||
* that never touches the artifact's own `status:`), a deferred-items.md
|
||
* entry's `status:` field carries no OTHER meaning, so the field itself
|
||
* doubles as the marker — self-invalidating for free: edit the entry's
|
||
* `status:` away from `acknowledged` (or delete the field) and it resurfaces
|
||
* with no separate cleanup step, exactly like every other category's marker.
|
||
*
|
||
* #3781: the heading-delimited (#3457) entry shape is SUPPORTED, via
|
||
* `splitDeferredHeadingEntriesWithSpans` — a span-carrying sibling of the
|
||
* reader's walk that records each entry's (start, end) character span in the
|
||
* SAME pass that groups its lines (the identical technique
|
||
* `splitGapsEntriesWithSpans` uses for the headless shape). Leaf entries keep
|
||
* their RAW heading line as `lines[0]` so `sectionBody.slice(start, end)` is
|
||
* byte-verbatim; pending (preamble / container-direct) regions are contiguous
|
||
* slices handed to `splitGapsEntriesWithSpans` with a baseOffset translation.
|
||
* Two write rules differ from the headless path on this shape: the status
|
||
* search runs over the READER-form lines (what the reader actually parses —
|
||
* including the leaf line-0 corner where the heading text itself parses as a
|
||
* status field, whose raw line is rewritten with its ATX prefix preserved),
|
||
* and the insert branch inserts after the entry's LAST NON-BLANK line — a
|
||
* heading entry's body is frequently a soft-wrapped sentence, and splicing
|
||
* after line 0 would split it in half (#3781's sentence-split trap).
|
||
* Entries whose span embeds a GFM table row are non-contiguous (table lines
|
||
* are excluded from entries) and still refuse (`unsupported_heading_shape`)
|
||
* rather than risk a wrong-entry write; the fully-headless shape below is
|
||
* byte-for-byte the pre-#3781 path.
|
||
*
|
||
* Also refuses `ambiguous` (2+ entries share the exact same text — status must
|
||
* be unique to identify one) and `not_found`, and is a no-op
|
||
* (`already_resolved`) on an entry already carrying `status: resolved` — the
|
||
* verdict-preserving direction: acknowledging a genuinely-fixed item would
|
||
* silently downgrade its terminal state.
|
||
*
|
||
* SPAN-CARRIED, not re-searched (F1, #3458 follow-up review — see
|
||
* `splitGapsEntriesWithSpans`'s doc comment): the target entry's location
|
||
* within `sectionBody` is the (start, end) character span recorded by
|
||
* `splitGapsEntriesWithSpans` in the SAME pass that produced `entryLines` /
|
||
* `targetText` above — never re-derived afterwards by searching. The
|
||
* previous implementation re-found the entry with a regex anchored on its
|
||
* own (escaped) exact text; that regex necessarily matches the FIRST
|
||
* occurrence of that text within `sectionBody`, which is not always the
|
||
* entry that was actually selected (a continuation/quoted line inside an
|
||
* EARLIER or LATER entry can carry byte-identical text) — and because the
|
||
* mis-targeted span is byte-identical to `targetText`, no downstream check
|
||
* on the WRITTEN text could ever distinguish a wrong-entry write from a
|
||
* correct one. Carrying the span removes the re-derivation step entirely:
|
||
* there is no second search to mis-target.
|
||
*
|
||
* Section-anchored (BLOCKER 1, #3458 follow-up review): the span is
|
||
* `sectionBody`-relative — the SAME string `matches`/the `ambiguous` guard
|
||
* were computed over — not `content`-relative, so an identical bullet living
|
||
* outside `## Deferred Items` (e.g. in an unrelated `# Notes` or a
|
||
* UAT/VERIFICATION body) can never steal the write. The span is translated
|
||
* into `content`-relative offsets via `deferredSection.bodyStart` (the
|
||
* section's own start offset, an invariant `collectSection` guarantees:
|
||
* `content.slice(bodyStart, bodyEnd) === body`). Before writing, the
|
||
* spanned text's own raw entry is re-derived and compared against
|
||
* `targetText` one more time — this is now a GENUINE invariant check (the
|
||
* span was computed by `splitGapsEntriesCore`'s independent offset
|
||
* bookkeeping, a different code path than the `entryLines`/`targetText`
|
||
* comparison above), not a no-op — if it does not match, the write is
|
||
* refused with `match_verification_failed` rather than risk touching the
|
||
* wrong span.
|
||
*/
|
||
function acknowledgeDeferredItem(content: string, targetText: string): AcknowledgeDeferredItemResult {
|
||
const deferredSection = collectSection(
|
||
content,
|
||
(h) => /^deferred\s+items$/i.test(h.text) && h.level === 2,
|
||
{ levelBounded: true },
|
||
);
|
||
const sectionBody = deferredSection ? deferredSection.body : content;
|
||
|
||
// #3781: the heading-delimited shape carries its own span walk; the
|
||
// headless path below is unchanged.
|
||
const headingEntries = splitDeferredHeadingEntriesWithSpans(sectionBody);
|
||
if (headingEntries !== null) {
|
||
return acknowledgeHeadingShapedEntry({ content, sectionBody, deferredSection, headingEntries, targetText });
|
||
}
|
||
|
||
const entries = splitGapsEntriesWithSpans(sectionBody);
|
||
const matches = entries
|
||
.map((entry) => ({ entry, text: rawGapEntryText(entry.lines) }))
|
||
.filter((e) => e.text === targetText);
|
||
|
||
if (matches.length === 0) return { content, status: 'not_found' };
|
||
if (matches.length > 1) return { content, status: 'ambiguous' };
|
||
|
||
const { entry } = matches[0];
|
||
const { lines: entryLines, start, end } = entry;
|
||
const fields = extractGapEntryFields(entryLines);
|
||
if (fields.status && fields.status.toLowerCase() === 'resolved') {
|
||
return { content, status: 'already_resolved' };
|
||
}
|
||
|
||
// Anchor to the SAME section body `matches`/the `ambiguous` guard above
|
||
// were computed over (BLOCKER 1) — never the whole `content`, which could
|
||
// contain an identical bullet elsewhere. `start`/`end` are the entry's own
|
||
// span, carried directly from `splitGapsEntriesWithSpans` — no re-search.
|
||
const sectionOffset = deferredSection ? deferredSection.bodyStart : 0;
|
||
const matchedLines = sectionBody.slice(start, end).split('\n');
|
||
|
||
// Genuine invariant re-verification (see doc comment above): the span was
|
||
// computed by a code path independent of the `entryLines`/`targetText`
|
||
// comparison that selected this entry — this catches real drift between
|
||
// the two rather than a regex trivially guaranteed to agree with itself.
|
||
const strippedForVerify = matchedLines.map((l) => l.replace(/\r$/, ''));
|
||
if (rawGapEntryText(strippedForVerify) !== targetText) {
|
||
return { content, status: 'match_verification_failed' };
|
||
}
|
||
|
||
const matchIndexInContent = sectionOffset + start;
|
||
// #3740: the search must mirror the reader exactly. extractGapEntryFields
|
||
// strips a bullet marker on line 0 ALONE — a later `- ` line is a nested
|
||
// sub-list, never a field line — so a marker-prefixed match on any
|
||
// continuation line would rewrite a line no reader reads and report `ok`
|
||
// while the entry stays outstanding. Line 0 KEEPS the marker-optional
|
||
// form: the reader de-bullets it, so `- status: open` as the entry line is
|
||
// a real field there (and first-wins means the insert branch could not
|
||
// outrank it). Everything else falls through to the insert branch below,
|
||
// which the marker-free and no-status controls already round-trip.
|
||
//
|
||
// #3775: the CASE axis of the same rule. The reader lowercases BOLDED
|
||
// keys only; bare keys keep their literal case (#3457 design), so a bare
|
||
// `Status:`/`STATUS:` line is stored under key `Status` and never read as
|
||
// fields.status. The search therefore matches a bolded key in ANY case
|
||
// and a bare key in LOWERCASE only — never a bare Title-case/UPPER line,
|
||
// which must fall through to the insert branch whose lowercase output the
|
||
// reader consumes (leaving any human `Status: resolved` untouched).
|
||
const statusFieldBoldedRe = /^\s*\*+status:\*+/i;
|
||
const statusFieldBareRe = /^\s*status:/;
|
||
const statusFieldBoldedReLine0 = /^\s*(?:-\s+)?\*+status:\*+/i;
|
||
const statusFieldBareReLine0 = /^\s*(?:-\s+)?status:/;
|
||
const statusLineIdx = matchedLines.findIndex((rawLine, idx) => {
|
||
const line = rawLine.replace(/\r$/, '');
|
||
return idx === 0
|
||
? (statusFieldBoldedReLine0.test(line) || statusFieldBareReLine0.test(line))
|
||
: (statusFieldBoldedRe.test(line) || statusFieldBareRe.test(line));
|
||
});
|
||
|
||
// No CRLF-preservation branch here (WARNING 1, #3458 follow-up review):
|
||
// every write goes through `platformWriteSync` → `normalizeContent`, which
|
||
// for a `.md` path unconditionally runs `_normalizeMd` — whole-file
|
||
// `\r\n` → `\n`, plus blank-line normalization around headings/lists — on
|
||
// EVERY write, not just this one. That is this codebase's single,
|
||
// deliberate OS-facing I/O seam (`shell-command-projection.cts`), applied
|
||
// uniformly to every `.md` writer; carving out one exception here would
|
||
// fight it rather than follow it, for a guarantee (byte-identical CRLF on
|
||
// disk) the seam already makes impossible. A marker write on a CRLF
|
||
// `deferred-items.md` normalizes the WHOLE file to LF, same as any other
|
||
// `.md` write in this codebase — expected, not a regression to guard
|
||
// against. Where a source line still carries a trailing `\r` (read from an
|
||
// on-disk CRLF document before normalization), `String.prototype.replace`
|
||
// consumes it as part of `.*$` and the replacement text does not
|
||
// reproduce it, so it is dropped here too — consistent with the eventual
|
||
// whole-file normalization rather than duplicating it.
|
||
let newMatchedLines: string[];
|
||
if (statusLineIdx === -1) {
|
||
const bulletIndentMatch = matchedLines[0].match(/^(\s*)-\s+/);
|
||
const continuationIndent = ' '.repeat((bulletIndentMatch ? bulletIndentMatch[1].length : 0) + 2);
|
||
newMatchedLines = [
|
||
matchedLines[0],
|
||
`${continuationIndent}status: acknowledged`,
|
||
...matchedLines.slice(1),
|
||
];
|
||
} else {
|
||
const original = matchedLines[statusLineIdx];
|
||
const replaced = original.replace(
|
||
/^(\s*(?:-\s+)?)(\*+status:\*+|status:)(\s*).*$/i,
|
||
(_m, indent: string, key: string, ws: string) => `${indent}${key}${ws}acknowledged`,
|
||
);
|
||
newMatchedLines = matchedLines.slice();
|
||
newMatchedLines[statusLineIdx] = replaced;
|
||
}
|
||
|
||
const newContent = content.slice(0, matchIndexInContent) + newMatchedLines.join('\n') + content.slice(matchIndexInContent + (end - start));
|
||
return { content: newContent, status: 'ok' };
|
||
}
|
||
|
||
/**
|
||
* #3781 — one entry of the heading-delimited deferred shape, carrying the
|
||
* exact character span it occupies within the `sectionBody` it was derived
|
||
* from. `lines[0]` of a LEAF entry is the RAW heading line (hashes intact) so
|
||
* `sectionBody.slice(start, end)` is byte-verbatim; `readerLines` is the
|
||
* reader-form of the same lines (ATX-stripped line 0, bullet-stripped body)
|
||
* the identity text and field extraction are computed over. `embeddedTable`
|
||
* marks entries whose span contains a GFM table line — table lines are
|
||
* excluded from entries, so such a span is non-contiguous and its entries
|
||
* refuse rather than risk a wrong write.
|
||
*/
|
||
interface DeferredHeadingEntrySpan {
|
||
kind: 'leaf' | 'pending';
|
||
lines: string[];
|
||
readerLines: string[];
|
||
text: string;
|
||
fields: Record<string, string>;
|
||
start: number;
|
||
end: number;
|
||
embeddedTable: boolean;
|
||
}
|
||
|
||
/**
|
||
* #3781 — strip an ATX heading prefix, mirroring `tokenizeHeadings`' own ATX
|
||
* regex (≤3 leading spaces, 1–6 `#`, space/tab separator, optional closing
|
||
* `#` sequence) so the raw heading line reconciles byte-exactly with the
|
||
* hash-stripped `text` the reader exposes. Returns null when the line is not
|
||
* an ATX heading line.
|
||
*/
|
||
function stripAtxPrefix(line: string): string | null {
|
||
const m = /^( {0,3})(#{1,6})([ \t]+.*|[ \t]*)?$/.exec(line.replace(/\r$/, ''));
|
||
if (!m) return null;
|
||
return m[3] === undefined
|
||
? ''
|
||
: m[3].replace(/^[ \t]+/, '').replace(/[ \t]+#+[ \t]*$/, '').replace(/^#+[ \t]*$/, '').trim();
|
||
}
|
||
|
||
/**
|
||
* #3781 — span-carrying sibling of `splitDeferredHeadingEntries`: ONE walk,
|
||
* identical grouping rules (leaf = childless heading whose body carries a
|
||
* bullet; container = next heading deeper; preamble/container-direct lines →
|
||
* headless entries; table lines excluded), additionally recording each
|
||
* entry's (start, end) character span within `sectionBody`. Returns null when
|
||
* the body contains no heading at all — the caller then takes the unchanged
|
||
* fully-headless path.
|
||
*/
|
||
function splitDeferredHeadingEntriesWithSpans(sectionBody: string): DeferredHeadingEntrySpan[] | null {
|
||
const headings = tokenizeHeadings(sectionBody);
|
||
if (headings.length === 0) return null;
|
||
|
||
const lines = sectionBody.split('\n');
|
||
const lineStarts: number[] = [];
|
||
const lineEnds: number[] = [];
|
||
let cursor = 0;
|
||
for (const rawLine of lines) {
|
||
lineStarts.push(cursor);
|
||
cursor += rawLine.length;
|
||
lineEnds.push(cursor);
|
||
cursor += 1;
|
||
}
|
||
|
||
const headingByLine = new Map<number, { text: string; isContainer: boolean }>();
|
||
for (let i = 0; i < headings.length; i++) {
|
||
const isContainer = i + 1 < headings.length && headings[i + 1].level > headings[i].level;
|
||
headingByLine.set(headings[i].line, { text: headings[i].text, isContainer });
|
||
}
|
||
|
||
const isTableLine = (l: string): boolean => /^\s*\|/.test(l.replace(/\r$/, ''));
|
||
const isBulletLine = (l: string): boolean => /^\s*-\s/.test(l.replace(/\r$/, ''));
|
||
|
||
const entries: DeferredHeadingEntrySpan[] = [];
|
||
let current: string[] | null = null;
|
||
let currentReaderLine0: string | null = null;
|
||
let currentStartLine = -1;
|
||
let currentEndLine = -1;
|
||
let currentHasBullet = false;
|
||
let currentTable = false;
|
||
let pendingStartLine = -1;
|
||
let pendingEndLine = -1;
|
||
|
||
const flushCurrent = (): void => {
|
||
if (current !== null && currentHasBullet && currentReaderLine0 !== null) {
|
||
const bodyReader = current.slice(1).map(stripLeadingBulletMarker);
|
||
entries.push({
|
||
kind: 'leaf',
|
||
lines: current,
|
||
readerLines: [currentReaderLine0, ...bodyReader],
|
||
text: rawGapEntryText([currentReaderLine0, ...current.slice(1)]),
|
||
fields: extractGapEntryFields([currentReaderLine0, ...bodyReader]),
|
||
start: lineStarts[currentStartLine],
|
||
end: lineEnds[currentEndLine],
|
||
embeddedTable: currentTable,
|
||
});
|
||
}
|
||
current = null;
|
||
currentReaderLine0 = null;
|
||
currentStartLine = -1;
|
||
currentEndLine = -1;
|
||
currentHasBullet = false;
|
||
currentTable = false;
|
||
};
|
||
const flushPending = (): void => {
|
||
if (pendingStartLine === -1) return;
|
||
// The pending region is contiguous (a heading flushes it), but table lines
|
||
// inside it were skipped by the walk: the reader's identity for this
|
||
// region is computed over the table-FILTERED join, which may merge
|
||
// entries across the gap, so spans cannot be translated faithfully —
|
||
// mark the region's entries as refusing instead.
|
||
let regionTable = false;
|
||
for (let i = pendingStartLine; i <= pendingEndLine; i++) {
|
||
if (isTableLine(lines[i])) regionTable = true;
|
||
}
|
||
const base = lineStarts[pendingStartLine];
|
||
const regionText = sectionBody.slice(lineStarts[pendingStartLine], lineEnds[pendingEndLine]);
|
||
for (const e of splitGapsEntriesWithSpans(regionText)) {
|
||
entries.push({
|
||
kind: 'pending',
|
||
lines: e.lines,
|
||
readerLines: e.lines,
|
||
text: rawGapEntryText(e.lines),
|
||
fields: extractGapEntryFields(e.lines),
|
||
start: base + e.start,
|
||
end: base + e.end,
|
||
embeddedTable: regionTable,
|
||
});
|
||
}
|
||
pendingStartLine = -1;
|
||
pendingEndLine = -1;
|
||
};
|
||
|
||
for (let i = 0; i < lines.length; i++) {
|
||
const heading = headingByLine.get(i + 1);
|
||
if (heading !== undefined) {
|
||
flushCurrent();
|
||
flushPending();
|
||
if (!heading.isContainer) {
|
||
current = [lines[i]];
|
||
currentReaderLine0 = heading.text;
|
||
currentStartLine = i;
|
||
currentEndLine = i;
|
||
currentHasBullet = false;
|
||
currentTable = false;
|
||
}
|
||
continue;
|
||
}
|
||
if (isTableLine(lines[i])) {
|
||
if (current !== null) currentTable = true;
|
||
continue;
|
||
}
|
||
if (current !== null) {
|
||
current.push(lines[i]);
|
||
currentEndLine = i;
|
||
if (isBulletLine(lines[i])) currentHasBullet = true;
|
||
} else {
|
||
if (pendingStartLine === -1) pendingStartLine = i;
|
||
pendingEndLine = i;
|
||
}
|
||
}
|
||
flushCurrent();
|
||
flushPending();
|
||
|
||
return entries;
|
||
}
|
||
|
||
/**
|
||
* #3781 — the heading-shaped half of `acknowledgeDeferredItem`, sharing the
|
||
* headless path's guards (not_found / ambiguous / already_resolved /
|
||
* match_verification_failed) and its rewrite/insert machinery, with the two
|
||
* shape-specific rules documented on `acknowledgeDeferredItem` (reader-form
|
||
* status search incl. the leaf line-0 ATX corner; insert after the entry's
|
||
* last non-blank line). Extracted so the headless path stays byte-identical.
|
||
*/
|
||
function acknowledgeHeadingShapedEntry({ content, sectionBody, deferredSection, headingEntries, targetText }: {
|
||
content: string;
|
||
sectionBody: string;
|
||
deferredSection: { body: string; bodyStart: number } | null;
|
||
headingEntries: DeferredHeadingEntrySpan[];
|
||
targetText: string;
|
||
}): AcknowledgeDeferredItemResult {
|
||
const matches = headingEntries.filter((e) => e.text === targetText);
|
||
if (matches.length === 0) return { content, status: 'not_found' };
|
||
if (matches.length > 1) return { content, status: 'ambiguous' };
|
||
const entry = matches[0];
|
||
if (entry.embeddedTable) return { content, status: 'unsupported_heading_shape' };
|
||
if (entry.fields.status && entry.fields.status.toLowerCase() === 'resolved') {
|
||
return { content, status: 'already_resolved' };
|
||
}
|
||
|
||
const sectionOffset = deferredSection ? deferredSection.bodyStart : 0;
|
||
const rawSlice = sectionBody.slice(entry.start, entry.end);
|
||
const rawSliceLines = rawSlice.split('\n');
|
||
|
||
// Genuine invariant re-verification: re-derive the entry's identity from
|
||
// the span's own bytes and compare against the targetText that selected it
|
||
// — the span was recorded by an offset bookkeeping independent of the
|
||
// identity comparison above.
|
||
const verifyText = entry.kind === 'leaf'
|
||
? (() => {
|
||
const stripped = stripAtxPrefix(rawSliceLines[0]);
|
||
return stripped === null ? null : rawGapEntryText([stripped, ...rawSliceLines.slice(1)]);
|
||
})()
|
||
: rawGapEntryText(rawSliceLines.map((l) => l.replace(/\r$/, '')));
|
||
if (verifyText !== targetText) {
|
||
return { content, status: 'match_verification_failed' };
|
||
}
|
||
|
||
// Status search over the READER-form lines — the exact set the reader
|
||
// parses (bolded any case + bare lowercase, per #3775; line-0 forms per
|
||
// #3740). Reader lines are index-aligned 1:1 with the raw lines.
|
||
const statusFieldBoldedRe = /^\s*\*+status:\*+/i;
|
||
const statusFieldBareRe = /^\s*status:/;
|
||
const statusFieldBoldedReLine0 = /^\s*(?:-\s+)?\*+status:\*+/i;
|
||
const statusFieldBareReLine0 = /^\s*(?:-\s+)?status:/;
|
||
const readerLines = entry.kind === 'leaf'
|
||
? [
|
||
stripAtxPrefix(rawSliceLines[0]) ?? rawSliceLines[0].replace(/\r$/, ''),
|
||
...rawSliceLines.slice(1).map((l) => stripLeadingBulletMarker(l.replace(/\r$/, ''))),
|
||
]
|
||
: rawSliceLines.map((l) => l.replace(/\r$/, ''));
|
||
const statusLineIdx = readerLines.findIndex((line, idx) =>
|
||
idx === 0
|
||
? (statusFieldBoldedReLine0.test(line) || statusFieldBareReLine0.test(line))
|
||
: (statusFieldBoldedRe.test(line) || statusFieldBareRe.test(line)));
|
||
|
||
let newRawLines: string[];
|
||
if (statusLineIdx === -1) {
|
||
// Insert branch: after the entry's LAST NON-BLANK line — a heading
|
||
// entry's body is frequently a soft-wrapped sentence, and splicing after
|
||
// line 0 would split it in half (#3781's sentence trap). The headless
|
||
// (no-heading-anywhere) path keeps its own splice-after-line-0 shape.
|
||
let lastNonBlank = rawSliceLines.length - 1;
|
||
while (lastNonBlank > 0 && rawSliceLines[lastNonBlank].replace(/\r$/, '').trim() === '') {
|
||
lastNonBlank--;
|
||
}
|
||
const indent = entry.kind === 'pending'
|
||
? (() => {
|
||
const bulletIndentMatch = rawSliceLines[0].match(/^(\s*)-\s+/);
|
||
return ' '.repeat((bulletIndentMatch ? bulletIndentMatch[1].length : 0) + 2);
|
||
})()
|
||
: ' ';
|
||
newRawLines = [
|
||
...rawSliceLines.slice(0, lastNonBlank + 1),
|
||
`${indent}status: acknowledged`,
|
||
...rawSliceLines.slice(lastNonBlank + 1),
|
||
];
|
||
} else {
|
||
newRawLines = rawSliceLines.slice();
|
||
if (entry.kind === 'leaf' && statusLineIdx === 0) {
|
||
// Leaf line 0 is the RAW heading line — rewrite the heading-text portion
|
||
// the reader treats as a field, with the ATX prefix preserved.
|
||
const replacedReader = readerLines[statusLineIdx].replace(
|
||
/^(\s*(?:-\s+)?)(\*+status:\*+|status:)(\s*).*$/i,
|
||
(_m, indent: string, key: string, ws: string) => `${indent}${key}${ws}acknowledged`,
|
||
);
|
||
const atxMatch = /^(\s*#+[ \t]*)(.*)$/.exec(rawSliceLines[0].replace(/\r$/, ''));
|
||
newRawLines[0] = atxMatch ? atxMatch[1] + replacedReader : replacedReader;
|
||
} else {
|
||
// Every other status line is rewritten on its RAW line, so the bullet
|
||
// marker and indent survive the write (the reader-form line has the
|
||
// marker stripped — writing it back would mangle the markdown shape and
|
||
// change the entry's identity text). Same replacement regex as the
|
||
// headless path.
|
||
newRawLines[statusLineIdx] = rawSliceLines[statusLineIdx].replace(
|
||
/^(\s*(?:-\s+)?)(\*+status:\*+|status:)(\s*).*$/i,
|
||
(_m, indent: string, key: string, ws: string) => `${indent}${key}${ws}acknowledged`,
|
||
);
|
||
}
|
||
}
|
||
|
||
const matchIndexInContent = sectionOffset + entry.start;
|
||
const newContent = content.slice(0, matchIndexInContent) + newRawLines.join('\n') + content.slice(matchIndexInContent + (entry.end - entry.start));
|
||
return { content: newContent, status: 'ok' };
|
||
}
|
||
|
||
/**
|
||
* Strip one leading `- ` bullet marker (#3457). Heading-delimited deferred
|
||
* entries carry their fields as sibling bullets; `extractGapEntryFields` only
|
||
* de-bullets line 0 (Gaps-protective — there, a later `- ` line is a nested
|
||
* sub-list), so the deferred heading path de-bullets every line itself before
|
||
* field extraction. Non-bullet lines pass through untouched.
|
||
*/
|
||
function stripLeadingBulletMarker(line: string): string {
|
||
return line.replace(/^(\s*)-\s+/, '');
|
||
}
|
||
|
||
/**
|
||
* Split a deferred-items section body into entries delimited by LEAF headings
|
||
* (#3457). Returns `null` when the body contains no heading at all — the
|
||
* caller then falls back to `splitGapsEntries`, keeping headless
|
||
* one-bullet-per-item files byte-for-byte on the pre-#3457 path.
|
||
*
|
||
* A heading is a CONTAINER (group/provenance/title label, contributes no
|
||
* entry) iff the NEXT heading is deeper — a deeper heading lives inside its
|
||
* span. Otherwise it is a LEAF: an entry boundary. This handles all three
|
||
* corpus shapes without hardcoding a depth: flat `#` title + `##` entries
|
||
* (title's next heading is deeper → container; each `##` followed by a
|
||
* same-or-shallower heading → leaf), a `##` container with `###` entries
|
||
* (container's next heading is deeper), and mixed-depth files where a
|
||
* childless `##` entry sits alongside a `##` group with `###` children — every
|
||
* childless heading is a leaf at whatever depth it is written. The shallower
|
||
* rules the issue reports as already tried (split on every heading; shallowest
|
||
* level; deepest level) each mis-count one of these shapes.
|
||
*
|
||
* A leaf entry is [heading text, ...body lines up to the next heading] and is
|
||
* kept only when its body (minus table lines) contains at least one `- `
|
||
* bullet:
|
||
* - a prose-only or bare heading contributes nothing — "prose is not an item"
|
||
* is this parser's pre-existing contract (see the `# Notes` case);
|
||
* - a table-only body is left entirely to `parseDeferredTableItems`, which
|
||
* unions over the same section body, so the heading cannot double-count the
|
||
* table's rows.
|
||
*
|
||
* Lines before the first heading, and lines directly under a container heading
|
||
* (before its first child), are split one-bullet-per-item by the unchanged
|
||
* `splitGapsEntries` — headless parity, so loose bullets before a later
|
||
* heading group (the mixed shape) stay one item each.
|
||
*/
|
||
function splitDeferredHeadingEntries(sectionBody: string): string[][] | null {
|
||
const headings = tokenizeHeadings(sectionBody);
|
||
if (headings.length === 0) return null;
|
||
|
||
const lines = sectionBody.split('\n');
|
||
const headingByLine = new Map<number, { text: string; isContainer: boolean }>();
|
||
for (let i = 0; i < headings.length; i++) {
|
||
// Container iff the next heading is deeper (see doc comment). An empty
|
||
// heading text (`##` alone) does not itself mean container — the flag is
|
||
// carried explicitly so a bare LEAF heading still opens an entry.
|
||
const isContainer = i + 1 < headings.length && headings[i + 1].level > headings[i].level;
|
||
headingByLine.set(headings[i].line, { text: headings[i].text, isContainer });
|
||
}
|
||
|
||
const entries: string[][] = [];
|
||
let current: string[] | null = null; // accumulating a leaf heading's entry
|
||
let pending: string[] = []; // preamble / container-heading body lines
|
||
let currentHasBullet = false;
|
||
|
||
const flushCurrent = (): void => {
|
||
// Keep the leaf entry only when its body carries a bullet; the heading
|
||
// text line itself (element 0) never counts as one.
|
||
if (current !== null && currentHasBullet) entries.push(current);
|
||
current = null;
|
||
currentHasBullet = false;
|
||
};
|
||
const flushPending = (): void => {
|
||
entries.push(...splitGapsEntries(pending.join('\n')));
|
||
pending = [];
|
||
};
|
||
|
||
for (let i = 0; i < lines.length; i++) {
|
||
const lineNo = i + 1;
|
||
const heading = headingByLine.get(lineNo);
|
||
if (heading !== undefined) {
|
||
flushCurrent();
|
||
// Headless-shaped region (preamble / container-direct bullets) ends at
|
||
// ANY heading; flushing here keeps entries in document order even when
|
||
// a container's direct bullets precede its first child entry.
|
||
flushPending();
|
||
if (!heading.isContainer) {
|
||
// Leaf heading: open an entry with the heading text as line 0.
|
||
current = [heading.text];
|
||
currentHasBullet = false;
|
||
}
|
||
continue;
|
||
}
|
||
// Table lines belong to parseDeferredTableItems, never to a heading entry.
|
||
if (/^\s*\|/.test(lines[i].replace(/\r$/, ''))) continue;
|
||
if (current !== null) {
|
||
current.push(lines[i]);
|
||
if (/^\s*-\s/.test(lines[i].replace(/\r$/, ''))) currentHasBullet = true;
|
||
} else {
|
||
pending.push(lines[i]);
|
||
}
|
||
}
|
||
flushCurrent();
|
||
flushPending();
|
||
|
||
return entries;
|
||
}
|
||
|
||
/**
|
||
* Extract deferred entries from GFM pipe tables in a deferred-items.md body
|
||
* (#2766) — a UNION with the bullet scan in `parseDeferredItems`.
|
||
*
|
||
* Cells are joined with ` — ` rather than taking only the first: these tables
|
||
* carry the useful detail in the later columns (the failing seeds, the reason,
|
||
* the owner), and dropping them would surface a name with no context.
|
||
*
|
||
* A row is skipped when any cell reads exactly `resolved`/`done`/`pass`
|
||
* (case-insensitive), mirroring the "explicit resolution only" convention
|
||
* `parseGapsItems` uses for `status: resolved` and `parseVerificationItems` uses
|
||
* for its `hasPassResult` cell scan — so a human can close a tabled deferred
|
||
* item in place and keep deferred-items.md the single source of truth.
|
||
*
|
||
* Deliberately permissive: an unrelated table in a deferred-items.md (say a
|
||
* table of environment notes) will surface as deferred entries. That is the
|
||
* correct fail-safe direction for a false-NEGATIVE bug — the whole file exists to
|
||
* record outstanding work, and this module's established stance (see
|
||
* parseGapsItems' 'unknown'-status fallback) is to surface a questionable entry
|
||
* rather than silently drop a real one.
|
||
*/
|
||
function parseDeferredTableItems(sectionBody: string): UatItem[] {
|
||
const items: UatItem[] = [];
|
||
|
||
for (const { rows } of collectTableRows(sectionBody)) {
|
||
for (const cells of rows) {
|
||
if (cells.some(c => /^(resolved|done|pass)$/i.test(c))) continue;
|
||
const name = cells.filter(c => c !== '').join(' — ');
|
||
if (!name) continue;
|
||
items.push({
|
||
name,
|
||
result: 'unresolved',
|
||
category: 'deferred',
|
||
});
|
||
}
|
||
}
|
||
|
||
return items;
|
||
}
|
||
|
||
/**
|
||
* One `splitGapsEntries` entry together with the exact character SPAN it
|
||
* occupies within the `sectionBody` it was derived from —
|
||
* `sectionBody.slice(start, end)` is the entry's own original text,
|
||
* byte-for-byte (CRLF preserved, unlike `lines`, which strips a trailing
|
||
* `\r` off every line). See `splitGapsEntriesWithSpans`'s doc comment for why
|
||
* a caller would want this over the plain `lines` shape.
|
||
*/
|
||
interface GapsEntrySpan {
|
||
lines: string[];
|
||
start: number;
|
||
end: number;
|
||
}
|
||
|
||
/**
|
||
* Shared walk behind `splitGapsEntries` and `splitGapsEntriesWithSpans` — ONE
|
||
* pass over `sectionBody` that both groups its lines into entries (see
|
||
* `splitGapsEntries`'s doc comment for the grouping rule) AND records each
|
||
* entry's (start, end) character offset within `sectionBody`. Extracted so
|
||
* the two public shapes can never drift apart on what counts as an entry
|
||
* boundary — a second, independently-written grouping pass is exactly how a
|
||
* span-carrying sibling could disagree with the plain-lines version it is
|
||
* supposed to be span-annotating.
|
||
*/
|
||
function splitGapsEntriesCore(sectionBody: string): GapsEntrySpan[] {
|
||
const rawLines = sectionBody.split('\n');
|
||
const lineStarts: number[] = [];
|
||
const lineEnds: number[] = [];
|
||
let cursor = 0;
|
||
for (const rawLine of rawLines) {
|
||
lineStarts.push(cursor);
|
||
cursor += rawLine.length;
|
||
lineEnds.push(cursor);
|
||
cursor += 1; // the '\n' separator — absent after the final line, but nothing reads past it
|
||
}
|
||
|
||
const entries: GapsEntrySpan[] = [];
|
||
let current: string[] | null = null;
|
||
let currentStartLine = -1;
|
||
let currentEndLine = -1;
|
||
let baseIndent: number | null = null;
|
||
|
||
const flush = (): void => {
|
||
if (current !== null) {
|
||
entries.push({ lines: current, start: lineStarts[currentStartLine], end: lineEnds[currentEndLine] });
|
||
}
|
||
};
|
||
|
||
rawLines.forEach((rawLine, idx) => {
|
||
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) {
|
||
flush();
|
||
current = [line];
|
||
currentStartLine = idx;
|
||
currentEndLine = idx;
|
||
return;
|
||
}
|
||
}
|
||
if (current !== null) {
|
||
current.push(line);
|
||
currentEndLine = idx;
|
||
}
|
||
// else: pre-first-bullet content (e.g. the template's HTML comment) — discarded.
|
||
});
|
||
flush();
|
||
|
||
return entries;
|
||
}
|
||
|
||
/**
|
||
* 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[][] {
|
||
return splitGapsEntriesCore(sectionBody).map((entry) => entry.lines);
|
||
}
|
||
|
||
/**
|
||
* Sibling of `splitGapsEntries` (F1, #3458 follow-up review) that ADDITIVELY
|
||
* carries each entry's character span — every existing `splitGapsEntries`
|
||
* caller (`parseGapsItems`, `parseDeferredItemsWithStatus`,
|
||
* `splitDeferredHeadingEntries`'s `flushPending`) is unaffected and keeps
|
||
* using the plain `lines`-only shape. `acknowledgeDeferredItem` is the one
|
||
* caller that needs a span: it used to select an entry via `splitGapsEntries`
|
||
* and then RE-FIND that entry's location with a fresh regex search over
|
||
* `sectionBody` — matching the FIRST occurrence of the entry's exact text,
|
||
* not necessarily the entry actually selected (a continuation/quoted line
|
||
* inside a DIFFERENT entry can carry byte-identical text). Because the
|
||
* mis-targeted span is byte-identical to the target text, no check on the
|
||
* WRITTEN result could ever tell a wrong-entry write apart from a correct
|
||
* one. Carrying the span out of THIS same pass — the one that already knows
|
||
* exactly where the entry lives — removes the re-derivation step entirely.
|
||
*/
|
||
function splitGapsEntriesWithSpans(sectionBody: string): GapsEntrySpan[] {
|
||
return splitGapsEntriesCore(sectionBody);
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
*
|
||
* #3457: markdown emphasis around the KEY (`**Status:** resolved` — the
|
||
* deferred-items convention bolds every field, and a bolded resolution marker
|
||
* previously failed this regex outright and surfaced as its own bogus
|
||
* unresolved entry) is unwrapped before the match, still anchored at the
|
||
* start of the line. The unwrapped key is lower-cased, because the bolded
|
||
* convention form is Title-cased (`**Status:**`) while the field vocabulary
|
||
* this module reads is lowercase (`status`) — the same normalization
|
||
* `mapGapsHeader` already applies to table header cells. Bare (unbolded) keys
|
||
* keep their literal case, and mid-line emphasis is untouched, preserving the
|
||
* start-anchored decoy invariant above.
|
||
*/
|
||
function extractGapEntryFields(entryLines: string[]): Record<string, string> {
|
||
const fields: Record<string, string> = {};
|
||
const fieldLineRe = /^([A-Za-z_][A-Za-z0-9_-]*):\s*(.*)$/;
|
||
const boldedKeyRe = /^\*+([A-Za-z_][A-Za-z0-9_-]*):\*+/;
|
||
|
||
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())
|
||
.replace(boldedKeyRe, (_m, key: string) => `${key.toLowerCase()}:`);
|
||
|
||
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, sourcePath?: 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, sourcePath);
|
||
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(rawResult: string, reason?: string, blockedBy?: string): UatCategory {
|
||
// Normalize once so this comparison agrees with the PASS-token check
|
||
// (`UAT_PASS_RESULTS.has(result)`, over an already-lower-cased token):
|
||
// `result: PENDING` and
|
||
// `result: Blocked` must categorize the same as their lowercase forms,
|
||
// not fall through to 'unknown'.
|
||
const result = rawResult.toLowerCase();
|
||
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';
|
||
// #3707: the template-sanctioned `result: issue` token (templates/UAT.md)
|
||
// has no UatCategory branch here, so a surfaced issue row previously fell
|
||
// through to 'unknown' — placed AFTER the blocked/skipped/pending checks
|
||
// above so it never shadows their more specific categorization.
|
||
if (result === 'issue') return 'issue';
|
||
return 'unknown';
|
||
}
|
||
|
||
export = {
|
||
cmdAuditUat,
|
||
cmdRenderCheckpoint,
|
||
parseCurrentTest,
|
||
parseUatItems,
|
||
parseUatItemsWithStats,
|
||
selectPhaseUatFiles,
|
||
buildCheckpoint,
|
||
CHECKPOINT_FRAMES,
|
||
CHECKPOINT_LANGUAGE_ALIASES,
|
||
resolveCheckpointFrame,
|
||
parseDeferredItems,
|
||
parseDeferredItemsWithStatus,
|
||
acknowledgeDeferredItem,
|
||
};
|