Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD across contents and paths, upstream package/repo coordinates -> @golem15/msd-core and golem15com/msd-core. Deep links into upstream history, sibling upstream packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is. Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line, package/plugin identity, regenerated lockfile, install-tree fixtures, derived registries and benchmark baseline; migration checksum baseline re-locked (MSD keeps its own install state, so no install had applied the old sums); sort-order and regex-escaped expectations in tests adjusted.
4032 lines
205 KiB
TypeScript
4032 lines
205 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, frontmatterListEntries, flattenObjectListItem } = 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;
|
||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||
import auditMod = require('./audit.cjs');
|
||
const { isAuditItemAcknowledged, deriveUatGapSnapshotValue } = auditMod;
|
||
import { requireSafePath, sanitizeForDisplay, PathAcceptance } 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[] = [];
|
||
let acknowledgedFiles = 0;
|
||
|
||
// 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 uatFm = extractFrontmatter(content, uatFilePath) as Record<string, unknown>;
|
||
const status = ((uatFm.status as string) || 'unknown').toLowerCase();
|
||
// #3805: honour the audit_acknowledged marker with the SAME snapshot
|
||
// key audit.cts's scanUatGaps uses ('gap_snapshot', derived value
|
||
// composed by the shared derivation) — one acknowledgement means the
|
||
// same thing to both commands.
|
||
if (isAuditItemAcknowledged(uatFm, { snapshotKey: 'gap_snapshot', currentValue: deriveUatGapSnapshotValue(status, content) })) {
|
||
acknowledgedFiles++;
|
||
continue;
|
||
}
|
||
// `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 verFm = extractFrontmatter(content, verificationFilePath) as Record<string, unknown>;
|
||
const status = ((verFm.status as string) || 'unknown').toLowerCase();
|
||
// #3805: same marker, same 'status' snapshot key as scanVerificationGaps,
|
||
// and the same ORDERING — the open-status gate runs FIRST (a marker on
|
||
// a file that would never surface is not a suppressed item), then the
|
||
// acknowledgement suppresses what the gate surfaced.
|
||
if (status === 'human_needed' || status === 'gaps_found') {
|
||
if (isAuditItemAcknowledged(verFm, { snapshotKey: 'status', currentValue: status })) {
|
||
acknowledgedFiles++;
|
||
continue;
|
||
}
|
||
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/msd-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>;
|
||
current_milestone: { files: number; items: number };
|
||
archived: { files: number; items: number; by_milestone: 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: {},
|
||
// #3783: additive segmentation so a consumer reads one field instead of
|
||
// re-deriving the `archived_milestone` filter itself. Deliberately does
|
||
// NOT touch total_items/parse_gap_files — see the parse_gap_files
|
||
// comment above for why splitting THAT counter by archive status was
|
||
// tried and reverted; this is a purely additive pair of new keys.
|
||
current_milestone: { files: 0, items: 0 },
|
||
archived: { files: 0, items: 0, by_milestone: {} },
|
||
};
|
||
|
||
for (const r of results) {
|
||
const resultItemCount = r.items.length;
|
||
if (r.archived_milestone) {
|
||
summary.archived.files++;
|
||
summary.archived.items += resultItemCount;
|
||
summary.archived.by_milestone[r.archived_milestone] =
|
||
(summary.archived.by_milestone[r.archived_milestone] || 0) + resultItemCount;
|
||
} else {
|
||
summary.current_milestone.files++;
|
||
summary.current_milestone.items += resultItemCount;
|
||
}
|
||
// 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;
|
||
}
|
||
}
|
||
|
||
// #3805: acknowledged files surface as a COUNT (audit-open's honesty
|
||
// model: the marker fired, the items are suppressed, both facts visible).
|
||
output({ results, summary, acknowledged_files: acknowledgedFiles }, 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', PathAcceptance.AbsoluteInsideRoot);
|
||
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/msd-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 `splitDeferredHeadingEntriesDetailed`) 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 = splitDeferredHeadingEntriesDetailed(sectionBody);
|
||
// The opener flags are HANDED DOWN rather than pre-applied (#3702 round 3,
|
||
// m7/m8). Marker-stripping the lines here and passing the result meant the
|
||
// reader's fence scan ran over text the splitter never saw, and the namer
|
||
// stripped a marker off the heading TEXT. Both consumers now take the raw
|
||
// lines plus the splitter's own per-line verdict — a rejected ordinal
|
||
// ("3. status: resolved" as prose) still keeps its `3. ` and yields no field,
|
||
// because that verdict is what carries the rejection.
|
||
const entries = headingEntries !== null
|
||
? headingEntries.map((entry) => ({
|
||
lines: entry.lines,
|
||
opener: entry.opener,
|
||
fields: extractGapEntryFields(entry.lines, DEFERRED_BULLET_MARKERS, entry.opener),
|
||
}))
|
||
: splitGapsEntries(sectionBody, DEFERRED_BULLET_MARKERS).map((entryLines) => ({
|
||
lines: entryLines,
|
||
opener: undefined,
|
||
fields: extractGapEntryFields(entryLines, DEFERRED_BULLET_MARKERS),
|
||
}));
|
||
|
||
for (const { lines: entryLines, opener, fields } of entries) {
|
||
const text = rawGapEntryText(entryLines, DEFERRED_BULLET_MARKERS, opener);
|
||
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',
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* The line ending for an entry that ends the FILE, where the entry is a single
|
||
* line and therefore carries no terminator of its own to copy. No entry-local
|
||
* evidence exists here — the separator before the entry terminates the
|
||
* PREVIOUS line, not this one — so this asks the weaker question that CAN be
|
||
* answered: does anything before the entry, within the scope the caller passes,
|
||
* contradict CRLF? Uniform CRLF across that scope is the one case where
|
||
* appending a `\r\n` cannot make the file more irregular. It fails CLOSED:
|
||
* any bare `\n` in scope, or no scope at all, yields LF.
|
||
*
|
||
* Adopted from #3773 (`crlfAtEof`), whose four counterexamples fixed the scope
|
||
* and are ported alongside it. Every simpler choice is refuted by a named test:
|
||
* the separator immediately PRECEDING the entry propagates an isolated CRLF
|
||
* into an LF-dominant list, because it terminates the previous line rather than
|
||
* this one — that is the algorithm this PR shipped through round 3 and it is
|
||
* withdrawn here. The whole DOCUMENT rejects CRLF over an unrelated bare `\n`
|
||
* elsewhere, inside a fenced block say. The deferred-items SECTION body is
|
||
* right when a heading delimits one, and becomes the whole document when it
|
||
* does not.
|
||
*
|
||
* Scope, therefore: the section body when `## Deferred Items` delimits one (its
|
||
* own preamble belongs to that section), else the entry-list region, where only
|
||
* the entries can be trusted.
|
||
*
|
||
* WITH ONE CORRECTION to #3773, which is its B4. The entry-list region goes
|
||
* EMPTY exactly when the list is undelimited AND holds a single entry, since
|
||
* the region runs from the first entry's start to the insertion point and those
|
||
* coincide. `crlfAtEof('')` is `false`, so a bare `\n` was inserted into a CRLF
|
||
* document — `'preamble\r\n\r\n- alpha'` gained one — which is the very defect
|
||
* the fallback exists to close, and it breaks the fix's own uniform-CRLF
|
||
* invariant. When the preferred region is empty the caller widens to everything
|
||
* preceding the insertion point rather than asserting LF from no evidence. That
|
||
* can only ever loosen a scope that was carrying zero information, and the
|
||
* predicate stays fail-closed over the wider one, so a contradicting bare `\n`
|
||
* still yields LF. An entry at offset 0 of an undelimited document has no
|
||
* evidence under either scope and stays LF, rather than inventing an ending
|
||
* from nothing.
|
||
*/
|
||
function crlfAtEof(before: string): boolean {
|
||
return before.length > 0 && !/(^|[^\r])\n/.test(before);
|
||
}
|
||
|
||
// ─── 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. The
|
||
* reader's own walk, `splitDeferredHeadingEntriesDetailed`, records each
|
||
* entry's (start, end) character span in the SAME pass that groups its
|
||
* lines — the technique `splitGapsEntriesWithSpans` already uses for the
|
||
* headless shape — so there is no second walk for the writer to drift from
|
||
* (#3702 round 5: upstream's fix shipped a hyphen-only sibling walk, and this
|
||
* PR's widened grammar would have left it reading a different set of
|
||
* entries than the reader; folding the spans into the one walk is what
|
||
* keeps the writer and the reader on one grammar). The heading half,
|
||
* `acknowledgeHeadingShapedEntry`, shares this function's guards and its
|
||
* rewrite/insert machinery through the same `entryFieldLines` seam, with two
|
||
* shape-specific rules: the status search runs over the READER-form lines
|
||
* (the heading TEXT on a leaf's line 0 — including the corner where that
|
||
* text itself parses as a status field, rewritten with its ATX prefix
|
||
* preserved), and the insert branch inserts after the entry's LAST NON-BLANK
|
||
* line, because a heading entry's body is frequently a soft-wrapped
|
||
* sentence and splicing after line 0 would split it (#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 spans, recorded by
|
||
// the reader's walk; the headless path below is unchanged.
|
||
const headingEntries = splitDeferredHeadingEntriesDetailed(sectionBody);
|
||
if (headingEntries !== null) {
|
||
return acknowledgeHeadingShapedEntry({ content, sectionBody, deferredSection, headingEntries, targetText });
|
||
}
|
||
|
||
const entries = splitGapsEntriesWithSpans(sectionBody, DEFERRED_BULLET_MARKERS);
|
||
const matches = entries
|
||
.map((entry) => ({ entry, text: rawGapEntryText(entry.lines, DEFERRED_BULLET_MARKERS) }))
|
||
.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, DEFERRED_BULLET_MARKERS);
|
||
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, DEFERRED_BULLET_MARKERS) !== targetText) {
|
||
return { content, status: 'match_verification_failed' };
|
||
}
|
||
|
||
const matchIndexInContent = sectionOffset + start;
|
||
// Locate the status line with the READER'S OWN classifier, never a
|
||
// writer-side regex (#3702 round 3, B1/B3 — the shape is #3773's,
|
||
// parameterised here by the widened marker set per the round-3 review's
|
||
// prescribed end state). The only line worth rewriting in place is one the
|
||
// reader will read back as `fields.status`.
|
||
//
|
||
// What this closes: round 2 widened the writer's finder to the deferred
|
||
// marker set while `extractGapEntryFields` still read a marker only on line
|
||
// 0. A nested ` * status: pending` was therefore SELECTED by the writer and
|
||
// invisible to the reader — acknowledge rewrote it, returned `ok`, and the
|
||
// item stayed outstanding forever. Measured against a `next` build: `*`, `+`
|
||
// and `1.` all resolved on base and stopped resolving here, so it was a
|
||
// regression, not a gap in new behaviour. The hyphen form of the same shape
|
||
// (` - status:`) was already broken on `next`; it is fixed here too, since
|
||
// one classifier cannot be right for three markers and wrong for the fourth.
|
||
//
|
||
// A line the reader skips falls through to the INSERT branch, which writes a
|
||
// line the reader does read — the fail-safe direction. That covers a bare
|
||
// capitalised `Status:` (the reader stores it under `Status`, not `status`)
|
||
// and a `status:` line inside a fenced block, both of which the writer must
|
||
// NOT rewrite in place. Selecting either one produced an entry that could not
|
||
// be acknowledged at all; that is why the selection goes through
|
||
// `entryFieldLines` rather than the classifier directly.
|
||
const statusLineIdx = entryFieldLines(matchedLines, DEFERRED_BULLET_MARKERS)
|
||
.findIndex((field) => field?.key === 'status');
|
||
|
||
// Per-line CRLF preservation is the honest in-memory contract. The lines
|
||
// here are RAW — `audit acknowledge` hands this function the `readFileSync`
|
||
// content of an on-disk `deferred-items.md`, and `_normalizeMd` runs only on
|
||
// WRITE — so on a CRLF document every line but the span's last still carries
|
||
// its `\r`. The write path's whole-file normalization still decides what
|
||
// reaches disk; this function does not duplicate that decision, and it no
|
||
// longer silently drops the `\r` either. (Round 2's comment here argued the
|
||
// opposite contract. It is withdrawn: #3773 documents per-line preservation
|
||
// in this same function, and two opposite contracts in one function was a
|
||
// round-3 blocker in its own right.)
|
||
let newMatchedLines: string[];
|
||
if (statusLineIdx === -1) {
|
||
const bulletIndentMatch = matchedLines[0].replace(/\r$/, '').match(DEFERRED_BULLET_MARKERS.strip);
|
||
// The entry's own indent CHARACTERS, never a count of them — a tab counted
|
||
// as one column and re-emitted as one space puts a 3-space continuation
|
||
// under a tab-indented bullet. Identical output for all-space indents.
|
||
const continuationIndent = `${bulletIndentMatch ? bulletIndentMatch[1] : ''} `;
|
||
// The new line goes right after line 0, so line 0 stops being the span's
|
||
// last line. Under CRLF the span's last line is the one WITHOUT a `\r`
|
||
// (the file's own `\r\n` follows the span), so the ending is read from the
|
||
// entry's OWN boundary and never sniffed from the whole document — a
|
||
// mixed-ending file must keep its LF opener. At end-of-file there is no
|
||
// following separator, so the boundary immediately PRECEDING the entry is
|
||
// the remaining local evidence; an entry at offset 0 has neither and stays
|
||
// LF rather than inventing an ending from nothing.
|
||
const line0HadCr = matchedLines[0].endsWith('\r');
|
||
// A single-line entry's line 0 IS the span's last line, so its own
|
||
// terminator sits OUTSIDE the span and the separator FOLLOWING the span is
|
||
// the evidence. At end-of-file there is no such separator, and reading its
|
||
// absence as "not CRLF" is what joined a CRLF entry to its inserted line
|
||
// with a bare `\n`. `crlfAtEof` answers the weaker question that remains,
|
||
// over the entry's own SECTION when one is delimited and the entry list
|
||
// alone when none is — widening to everything before the insertion point
|
||
// only where that region is empty, which is #3773's B4. See its doc comment.
|
||
const spanEnd = matchIndexInContent + (end - start);
|
||
const eofScope = sectionBody.slice(deferredSection ? 0 : entries[0].start, start)
|
||
|| sectionBody.slice(0, start);
|
||
const crlf = matchedLines.length > 1
|
||
? line0HadCr
|
||
: content.startsWith('\r\n', spanEnd)
|
||
|| (spanEnd >= content.length && crlfAtEof(eofScope));
|
||
newMatchedLines = [
|
||
crlf ? `${matchedLines[0].replace(/\r$/, '')}\r` : matchedLines[0],
|
||
`${continuationIndent}status: acknowledged${line0HadCr ? '\r' : ''}`,
|
||
...matchedLines.slice(1),
|
||
];
|
||
} else {
|
||
// Rewrite at the offset the CLASSIFIER reported, rather than through a
|
||
// second regex of the writer's own. This is what makes the selection and
|
||
// the rewrite structurally incapable of disagreeing: a status line the
|
||
// classifier can select is one whose value offset it has already
|
||
// computed, so there is no shape it selects and then fails to rewrite.
|
||
// (A widened classifier over a hyphen-only rewrite regex is exactly that
|
||
// failure — it would select `* status: open` and hand back the line
|
||
// untouched.) The key's own spelling and any `**bold**` wrapper survive
|
||
// because only the value is replaced.
|
||
const raw = matchedLines[statusLineIdx];
|
||
const cr = raw.endsWith('\r') ? '\r' : '';
|
||
const line = raw.slice(0, raw.length - cr.length);
|
||
const field = parseGapEntryFieldLine(line, DEFERRED_BULLET_MARKERS, statusLineIdx === 0)!;
|
||
const prefix = line.slice(0, field.valueStart);
|
||
// `status:acknowledged` reads back fine, but a bare colon with no
|
||
// separator is not what this file's convention looks like; supply one only
|
||
// when the source had none.
|
||
const sep = /[ \t]$/.test(prefix) ? '' : ' ';
|
||
newMatchedLines = matchedLines.slice();
|
||
newMatchedLines[statusLineIdx] = `${prefix}${sep}acknowledged${cr}`;
|
||
}
|
||
|
||
// NO post-write read-back guard here, deliberately (round 4, B3). Round 3
|
||
// added one — `rewrite_not_readable` — after a fenced `status:` line proved
|
||
// the writer could select a line the reader would not read back. Round 3
|
||
// then closed that divergence STRUCTURALLY, by routing the writer's line
|
||
// selection and the reader's field extraction through the one
|
||
// `entryFieldLines` seam above, and the guard became unreachable from the
|
||
// public API: 21 document shapes were driven against it (fence openers on
|
||
// the bullet line for every marker, duplicate and triplicate `status:`
|
||
// lines, bolded and nested variants, fences between duplicates) and none
|
||
// reached it.
|
||
//
|
||
// An unreachable branch is not free here. This repo's own
|
||
// `RULESET.TESTS.mutation-score` runs Stryker incrementally over changed
|
||
// files at an 80% threshold and says to "treat surviving mutant as a failing
|
||
// test specification"; an undriven `if` is exactly that. The only seam that
|
||
// would drive it is routing this call through the module's exports so a test
|
||
// could stub it — production surface reshaped for a test, which is a worse
|
||
// trade than the guard is worth now that construction, not assertion,
|
||
// enforces the invariant.
|
||
//
|
||
// What that gives up, stated plainly rather than hidden: if a future change
|
||
// re-splits the writer's selection from the reader's extraction, this
|
||
// function returns `ok` over an item that stays outstanding — the original
|
||
// #3702 defect class. `match_verification_failed` does NOT backfill it; that
|
||
// check runs BEFORE the write and compares the matched span to the target,
|
||
// so it cannot see a post-write read-back failure. The protection against
|
||
// re-splitting is the shared seam plus the round-3 tests that pin it, not a
|
||
// runtime assertion.
|
||
|
||
const newContent = content.slice(0, matchIndexInContent) + newMatchedLines.join('\n') + content.slice(matchIndexInContent + (end - start));
|
||
return { content: newContent, status: 'ok' };
|
||
}
|
||
|
||
/**
|
||
* #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 — 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.
|
||
*
|
||
* Every question about an entry is asked of the READER'S OWN answer (#3702
|
||
* round 3, B1/B3 — restated here rather than re-implemented): identity is
|
||
* `rawGapEntryText` over the walk's lines and opener flags, exactly as
|
||
* `parseDeferredItemsWithStatus` names the entry; the status line is whichever
|
||
* line `entryFieldLines` classifies as `status`, so a fenced `status:` or a
|
||
* rejected-ordinal prose line is never selected; and the rewrite lands at the
|
||
* offset the classifier reported. Upstream's #3781 carried its own
|
||
* hyphen-only walk and its own status regexes for this shape; under the
|
||
* widened marker grammar those would have read a different entry set than
|
||
* the reader and re-opened the writer/reader drift this PR closes.
|
||
*/
|
||
function acknowledgeHeadingShapedEntry({ content, sectionBody, deferredSection, headingEntries, targetText }: {
|
||
content: string;
|
||
sectionBody: string;
|
||
deferredSection: { body: string; bodyStart: number } | null;
|
||
headingEntries: DeferredHeadingEntry[];
|
||
targetText: string;
|
||
}): AcknowledgeDeferredItemResult {
|
||
const matches = headingEntries.filter(
|
||
(e) => rawGapEntryText(e.lines, DEFERRED_BULLET_MARKERS, e.opener) === targetText,
|
||
);
|
||
if (matches.length === 0) return { content, status: 'not_found' };
|
||
if (matches.length > 1) return { content, status: 'ambiguous' };
|
||
const entry = matches[0];
|
||
// A table row inside the span: the walk skipped it, so `lines` is not 1:1
|
||
// with the raw slice and no write can be anchored. Refuse, as before #3781.
|
||
if (entry.embeddedTable) return { content, status: 'unsupported_heading_shape' };
|
||
const fields = extractGapEntryFields(entry.lines, DEFERRED_BULLET_MARKERS, entry.opener);
|
||
if (fields.status && fields.status.toLowerCase() === 'resolved') {
|
||
return { content, status: 'already_resolved' };
|
||
}
|
||
|
||
const sectionOffset = deferredSection ? deferredSection.bodyStart : 0;
|
||
const rawLines = sectionBody.slice(entry.start, entry.end).split('\n');
|
||
// The reader-form of the raw slice, index-aligned with it: a leaf's line 0
|
||
// is the heading TEXT (re-derived from the span's own bytes, not copied
|
||
// from the walk, so the verification below is genuine), every other line
|
||
// CR-stripped. Markers stay on the lines — the classifier strips them per
|
||
// the opener flags, exactly as the reader does.
|
||
const readerLines = rawLines.map((raw, i) => {
|
||
const line = raw.replace(/\r$/, '');
|
||
return i === 0 && entry.kind === 'leaf' ? (stripAtxPrefix(line) ?? line) : line;
|
||
});
|
||
// Genuine invariant re-verification: the identity re-derived from the
|
||
// span's bytes must be the identity that selected the entry — the span was
|
||
// recorded by offset bookkeeping independent of that comparison.
|
||
if (readerLines.length !== entry.lines.length
|
||
|| rawGapEntryText(readerLines, DEFERRED_BULLET_MARKERS, entry.opener) !== targetText) {
|
||
return { content, status: 'match_verification_failed' };
|
||
}
|
||
|
||
const statusLineIdx = entryFieldLines(readerLines, DEFERRED_BULLET_MARKERS, entry.opener)
|
||
.findIndex((field) => field?.key === 'status');
|
||
|
||
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.
|
||
// …and never INSIDE a fence (round 5, RV6.5 review): an entry whose body
|
||
// ends in a fenced block — closed or, worse, unclosed and so running to
|
||
// the entry's end — would otherwise receive its marker as fence content,
|
||
// a line the reader never reads: `ok` returned, item still outstanding.
|
||
// Walk back over blank and fenced lines alike, classified exactly as the
|
||
// reader classifies them, so the marker lands on a line the reader reads.
|
||
const fencedInEntry = entryFencedLines(readerLines, DEFERRED_BULLET_MARKERS, entry.opener);
|
||
let last = rawLines.length - 1;
|
||
while (last > 0 && (readerLines[last].trim() === '' || fencedInEntry.has(last))) last--;
|
||
// A pending entry's continuation sits two columns inside its own marker
|
||
// indent (the entry's indent CHARACTERS, as the headless path does); a
|
||
// leaf's body lines are sibling bullets, and an indented bare field line
|
||
// among them is what the reader reads on that shape.
|
||
const indent = entry.kind === 'pending'
|
||
? `${rawLines[0].replace(/\r$/, '').match(DEFERRED_BULLET_MARKERS.strip)?.[1] ?? ''} `
|
||
: ' ';
|
||
// The inserted line copies the ending of the line it follows. When that
|
||
// line is the span's LAST, its terminator sits outside the span: the
|
||
// separator following the span decides, else (end of file) the section's
|
||
// own evidence — the same rule the headless path applies to line 0.
|
||
const followsLast = last === rawLines.length - 1;
|
||
const spanEnd = sectionOffset + entry.end;
|
||
const prevCr = followsLast
|
||
? content.startsWith('\r\n', spanEnd) || (spanEnd >= content.length && crlfAtEof(sectionBody.slice(0, entry.start)))
|
||
: rawLines[last].endsWith('\r');
|
||
newRawLines = rawLines.slice();
|
||
if (followsLast && prevCr) newRawLines[last] = `${rawLines[last].replace(/\r$/, '')}\r`;
|
||
newRawLines.splice(last + 1, 0, `${indent}status: acknowledged${!followsLast && prevCr ? '\r' : ''}`);
|
||
} else {
|
||
// Rewrite at the offset the CLASSIFIER reported, on the RAW line — the
|
||
// marker, the indent, the key's spelling and any `**bold**` wrapper all
|
||
// survive because only the value is replaced. A leaf's line 0 is the
|
||
// heading line, so its ATX prefix is put back in front of the rewritten
|
||
// text (the reader reads the heading text itself as the field there).
|
||
const raw = rawLines[statusLineIdx];
|
||
const cr = raw.endsWith('\r') ? '\r' : '';
|
||
const line = raw.slice(0, raw.length - cr.length);
|
||
const reader = readerLines[statusLineIdx];
|
||
const field = parseGapEntryFieldLine(reader, DEFERRED_BULLET_MARKERS, stripsMarkerAt(statusLineIdx, entry.opener))!;
|
||
const prefix = reader.slice(0, field.valueStart);
|
||
const sep = /[ \t]$/.test(prefix) ? '' : ' ';
|
||
const leafLine0 = statusLineIdx === 0 && entry.kind === 'leaf';
|
||
const atx = leafLine0 ? (/^( {0,3}#{1,6}[ \t]+)/.exec(line)?.[1] ?? '') : '';
|
||
// A closing `#` sequence is Markdown the reader ignores; keep it (RV6.5).
|
||
const closing = leafLine0 ? (/[ \t]+#+[ \t]*$/.exec(line)?.[0] ?? '') : '';
|
||
newRawLines = rawLines.slice();
|
||
newRawLines[statusLineIdx] = `${atx}${prefix}${sep}acknowledged${closing}${cr}`;
|
||
}
|
||
|
||
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' };
|
||
}
|
||
|
||
/**
|
||
* The two regex shapes a bullet-aware walk needs over one marker set: `open`
|
||
* detects an entry-opening marker, `strip` additionally captures the line's
|
||
* remaining content. Carrying both in ONE object is what keeps a detection
|
||
* site and its matching strip site from drifting to different marker sets —
|
||
* the asymmetry #3702 warns about (widen what OPENS an entry without widening
|
||
* what is STRIPPED before field extraction, and a status field written under
|
||
* the new marker never resolves its entry).
|
||
*/
|
||
interface BulletMarkers {
|
||
/** `^(indent)(<marker>)\s` — group 1 is the indent, group 2 the marker token. */
|
||
open: RegExp;
|
||
/** `^(indent)<marker>\s+(rest)$` — group 1 indent, group 2 rest-of-line. */
|
||
strip: RegExp;
|
||
/**
|
||
* Honour Markdown block structure — thematic breaks close the list, fenced
|
||
* code neither opens an entry nor carries a field (#3702 round 2, M1/M2),
|
||
* and indents are measured in CommonMark columns rather than raw characters.
|
||
* `false` for the Gaps grammar, which #3702's ruling keeps byte-for-byte on
|
||
* its `next` behaviour; `true` for the deferred-items grammar. Carried on the
|
||
* marker set rather than as a second parameter so a caller cannot widen one
|
||
* without the other.
|
||
*/
|
||
blockStructure: boolean;
|
||
}
|
||
|
||
/**
|
||
* Hyphen-only markers — the `## Gaps` form, unchanged by #3702. Gaps entries
|
||
* come from a template that mandates the hyphen YAML-lite shape, so widening
|
||
* that section's grammar is not what the deferred-items ruling required; the
|
||
* shared splitting seam is parameterised rather than widened wholesale so the
|
||
* Gaps path stays byte-for-byte on its existing behaviour.
|
||
*/
|
||
const HYPHEN_BULLET_MARKERS: BulletMarkers = {
|
||
open: /^(\s*)(-)\s/,
|
||
strip: /^(\s*)-\s+(.*)$/,
|
||
blockStructure: false,
|
||
};
|
||
|
||
/**
|
||
* Deferred-items markers (#3702): the standard Markdown list markers, not the
|
||
* hyphen alone. `deferred-items.md` has NO template and no mandated shape —
|
||
* executors write it by hand (the same premise that justified the #2766 table
|
||
* union) — so an author reaching for `*`, `+` or `1.` wrote a list by every
|
||
* Markdown definition while this parser contributed ZERO entries for it. The
|
||
* hyphen restriction was a regex literal inherited from the Gaps seam, never a
|
||
* stated decision: measured in the wild, non-empty records parsed to a clean
|
||
* zero, and a MIXED file dropped its non-hyphen entries while keeping their
|
||
* hyphenated siblings — under-reporting without ever looking empty.
|
||
*
|
||
* Deliberately NOT widened to prose: "prose is not an item" is this parser's
|
||
* pre-existing, test-asserted contract (the `# Notes` case) and is untouched
|
||
* here. An asterisk bullet is not prose, and a `|` row is not a list marker —
|
||
* table lines are still skipped before the body-bullet flag can be set, so
|
||
* `parseDeferredTableItems` keeps sole ownership of table bodies and the
|
||
* #2766 anti-double-count property holds unchanged.
|
||
*
|
||
* The paren-terminated ordered form (`1)`) is out of scope for this fix: the
|
||
* #3702 ruling scopes the widening to `*`, `+` and the dot-terminated ordered
|
||
* marker.
|
||
*
|
||
* `DEFERRED_MARKER_ALT` is THE source every deferred-items marker regex is
|
||
* built from (#3702 round 2, M3). Since round 3 that is the splitter's
|
||
* `open`/`strip` pair here and nothing else: `acknowledgeDeferredItem`'s two
|
||
* status-line shapes used to be derived from it too, and are now deleted in
|
||
* favour of the reader's classifier. CommonMark
|
||
* §5.2: bullet markers `-`, `*`, `+`; an ordered marker is 1-9 digits and a
|
||
* `.` (round-1's `\d+` was uncapped). The marker is followed by a space or a
|
||
* tab — `[ \t]`, where round 1 wrote `\s`, which also accepted `\r`.
|
||
* `markdown-sectionizer`'s `iterateBullets` is the repo's other list-marker
|
||
* grammar; the `#3702 round 2: marker-grammar parity` test pins this one to
|
||
* it on the shared vocabulary and names the two points they deliberately
|
||
* differ (tab after the marker, the 9-digit cap).
|
||
*/
|
||
const DEFERRED_MARKER_ALT = '(?:[-*+]|\\d{1,9}\\.)';
|
||
const DEFERRED_BULLET_MARKERS: BulletMarkers = {
|
||
open: new RegExp(`^(\\s*)(${DEFERRED_MARKER_ALT})[ \\t]`),
|
||
strip: new RegExp(`^(\\s*)${DEFERRED_MARKER_ALT}[ \\t]+(.*?)\\r?$`),
|
||
blockStructure: true,
|
||
};
|
||
|
||
// `acknowledgeDeferredItem` carries NO status-line regex of its own (#3702
|
||
// round 3, B1/B3). It used to hold two — a finder and a rewrite — derived
|
||
// from `DEFERRED_MARKER_ALT` so the two WRITER shapes could not drift from
|
||
// each other. That kept the wrong pair in step: the finder's peer is the
|
||
// READER, and widening detection without widening the read is what made a
|
||
// nested ` * status:` line selectable by the writer and invisible to
|
||
// `extractGapEntryFields`. Both are gone; the writer now locates its line
|
||
// through `parseGapEntryFieldLine`, the reader's own classifier, and rewrites
|
||
// at the offset that classifier reports. See `parseGapEntryFieldLine`.
|
||
|
||
/**
|
||
* CommonMark §4.1 thematic break: up to 3 spaces of indent, then three or
|
||
* more of the SAME `-`, `*` or `_`, optionally space/tab-separated, and
|
||
* nothing else. `- - -`, `* * *` and `+ + +` all also match a list opener —
|
||
* `- - -` was a phantom `"- -"` entry on base, and #3702's widening added the
|
||
* other two (#3702 round 2, M1). `+ + +` is not a CommonMark break, but it is
|
||
* the same authoring gesture and no less garbage as an entry name, so the
|
||
* class here is "three-or-more of one marker character, nothing else". The
|
||
* indent is unbounded, not CommonMark's `{0,3}`: this parser reads a list at
|
||
* any indent (see `#3702 round 2` m1), so a separator drawn at any indent is
|
||
* a separator too — otherwise ` * * *` is a phantom entry named `* *`.
|
||
*/
|
||
const THEMATIC_BREAK_RE = /^[ \t]*([-*+_])(?:[ \t]*\1){2,}[ \t]*$/;
|
||
|
||
/**
|
||
* The deferred grammar's line view for fence classification: the SAME lines,
|
||
* with leading whitespace removed (#3702 round 4, M2).
|
||
*
|
||
* `scanFencedBlocks` is CommonMark, and CommonMark caps a fence delimiter's
|
||
* indent at three spaces — a fourth makes it an indented code block instead.
|
||
* The deferred grammar deliberately opted out of that cliff everywhere else:
|
||
* an entry opener is `[ \t]*`-indented and `THEMATIC_BREAK_RE` is
|
||
* `^[ \t]*`. Leaving the fence rule at CommonMark's cap while items and
|
||
* breaks are unbounded is not a conservative choice, it is an inconsistent
|
||
* one, and it is REACHED BY ORDINARY DOCUMENTS: a fenced block written under a
|
||
* nested bullet sits at four spaces, so its `status: resolved` line resolved
|
||
* the entry containing it. That is the #3702 silent-resolution defect class in
|
||
* a new place — driven, at indents 4, 5, 8 and a leading tab, before this fix.
|
||
*
|
||
* Still NO second fence dialect (the rule `blankIndentedFenceDelimiters`
|
||
* states): the classification is done by `scanFencedBlocks`, the one exported
|
||
* CommonMark state machine, over a de-indented view. Run lengths, backtick
|
||
* vs tilde, closer-must-match-and-not-trail and info-string rules are all
|
||
* still that engine's answers, not re-derived here; the unterminated case is
|
||
* its answer too, bounded by the walk (round 5, B1 — see `scanFencesFrom`). Indent is the only dimension this hides from it, and it is
|
||
* the exact dimension the deferred grammar has already declared it does not
|
||
* measure. Index alignment is 1:1 by construction — `map` preserves length —
|
||
* so every line index the engine returns still addresses the original line.
|
||
*
|
||
* Scope: the deferred grammar only. Both marker-parameterised call sites gate
|
||
* on `markers.blockStructure`, which the `## Gaps` set does not set, so Gaps
|
||
* reaches an empty set and is untouched by this — the same opt-out
|
||
* `indentWidth` documents for the indent half.
|
||
*/
|
||
function deindentedForFences(lines: string[]): string[] {
|
||
return lines.map((line) => line.replace(/^[ \t]+/, ''));
|
||
}
|
||
|
||
/**
|
||
* Indices (into `lines`) of every line that sits inside a fenced code block,
|
||
* delimiters included — by the sectionizer's own fence state machine, so a
|
||
* `~~~` fence, an indented fence and an unterminated fence (runs to the end)
|
||
* are classified exactly as `stripFencedCode` would (#3702 round 2, M2), at
|
||
* ANY indent (round 4, M2 — see `deindentedForFences`).
|
||
* #3702's wild records carry reproduction blocks; `+`-prefixed diff lines and
|
||
* `1.`-numbered steps are their normal content, not entries.
|
||
*
|
||
* ENTRY-scoped: `lines` are ONE entry's lines (`entryFieldLines`), so an
|
||
* unterminated fence "running to the end" runs to the end of that entry —
|
||
* exactly the bound the section-level walks give it (round 5, B1; see
|
||
* `scanFencesFrom`). The two classifications agree by construction.
|
||
*/
|
||
function fencedLineSet(lines: string[]): Set<number> {
|
||
const fenced = new Set<number>();
|
||
for (const block of scanFencedBlocks(deindentedForFences(lines))) {
|
||
const last = block.closeLineIdx === -1 ? lines.length - 1 : block.closeLineIdx;
|
||
for (let i = block.openLineIdx; i <= last; i++) fenced.add(i);
|
||
}
|
||
return fenced;
|
||
}
|
||
|
||
/**
|
||
* Fence classification for a SECTION-level walk (round 5, B1). Terminated
|
||
* fences are classified wholesale, as `fencedLineSet` does. The UNTERMINATED
|
||
* fence is the difference: `scanFencedBlocks` runs it to the end of the
|
||
* document, and so did round 4 — at any indent, since round 4's M2 — so one
|
||
* stray delimiter swallowed every entry after it into the entry before it.
|
||
* `- a` / blank / four-space ``` / blank / `- b` yielded ONE entry where
|
||
* `next` yields two: a widening that made an already-counted item vanish,
|
||
* in the direction of silent data loss, on the exact mixed-file shape #3702
|
||
* exists to close.
|
||
*
|
||
* The rule now: an unterminated fence runs to the end of its ENTRY, never
|
||
* past it. That is CommonMark's own answer for a fence inside a list item —
|
||
* the fence closes when its container does, and the container closes at the
|
||
* next item at its level — extended to a document-level stray delimiter,
|
||
* where CommonMark would swallow to end-of-file and this parser's fail-safe
|
||
* rule (surface a questionable entry rather than drop a real one) will not.
|
||
* The scan therefore reports the unterminated opener's index and leaves the
|
||
* bound to the walk, which knows what a top-level item is: on reaching one,
|
||
* the walk RESCANS from that line, so a delimiter after the bound is read on
|
||
* its own terms rather than as the content of a fence that has ended. Still
|
||
* no second fence dialect — every block boundary is `scanFencedBlocks`'
|
||
* answer; the walk contributes only the bound.
|
||
*/
|
||
interface FenceScan {
|
||
/** Lines inside a TERMINATED fence, delimiters included. */
|
||
fenced: Set<number>;
|
||
/** Every fence opener, terminated or not — an opener ends the list runs at its level, as a paragraph does. */
|
||
openers: Set<number>;
|
||
/** Opener index of the one unterminated fence in scope, else -1. Lines from it onward are fenced until the walk bounds it. */
|
||
unterminatedFrom: number;
|
||
}
|
||
|
||
function scanFencesFrom(lines: string[], from: number): FenceScan {
|
||
const scan: FenceScan = { fenced: new Set<number>(), openers: new Set<number>(), unterminatedFrom: -1 };
|
||
for (const block of scanFencedBlocks(deindentedForFences(lines.slice(from)))) {
|
||
const open = block.openLineIdx + from;
|
||
scan.openers.add(open);
|
||
if (block.closeLineIdx === -1) {
|
||
scan.unterminatedFrom = open; // always the scan's last block
|
||
break;
|
||
}
|
||
for (let i = open; i <= block.closeLineIdx + from; i++) scan.fenced.add(i);
|
||
}
|
||
return scan;
|
||
}
|
||
|
||
/** The scan a grammar without block structure (`## Gaps`) walks under: nothing is fenced. Never mutated. */
|
||
const NO_FENCES: FenceScan = { fenced: new Set<number>(), openers: new Set<number>(), unterminatedFrom: -1 };
|
||
|
||
/**
|
||
* Does `line` LOOK like a top-level list item under `markers` — a marker at
|
||
* or above the base indent, start value ignored? The bound an unterminated
|
||
* fence runs to (round 5, B1; see `scanFencesFrom`). Shape rather than the
|
||
* ordered-start rule, because the list memory inside a fence is not evidence
|
||
* of anything, and closing a stray fence one line early errs in the
|
||
* surfacing direction.
|
||
*/
|
||
function topLevelItemShape(line: string, markers: BulletMarkers, baseIndent: number | null): boolean {
|
||
const m = line.match(markers.open);
|
||
return m !== null && (baseIndent === null || indentWidth(m[1], markers) <= baseIndent);
|
||
}
|
||
|
||
/**
|
||
* Per-indent LIST memory (#3702 round 2, round review; widened round 5, M2):
|
||
* each list level remembers whether a list is OPEN there, so an ordered
|
||
* marker that does not start at `0.`/`1.` is an item when it continues or
|
||
* follows a list at its level, and prose otherwise. A new opener at indent
|
||
* `d` resets every deeper level (a new item starts new sub-lists); a
|
||
* paragraph after a blank at indent `d` ends the lists at `d` and deeper; a
|
||
* thematic break or a heading clears everything.
|
||
*
|
||
* Round 2 keyed this on whether the previous opener was ORDERED, so a bullet
|
||
* item closed the run and `1. a` / `- b` / `5. c` folded `5. c` into `b` —
|
||
* the mixed-file under-report #3702 names as the shape that bites. In
|
||
* CommonMark `5. c` there opens a fresh ordered list (`start=5`): a non-1
|
||
* ordinal is refused only where it would INTERRUPT A PARAGRAPH (§5.3), and
|
||
* after a list item it interrupts nothing. Keying on "a list is open here"
|
||
* is that rule as far as this parser can state it without a paragraph model.
|
||
*/
|
||
class ListRuns {
|
||
private readonly byIndent = new Set<number>();
|
||
at(indent: number): boolean { return this.byIndent.has(indent); }
|
||
opened(indent: number): void {
|
||
for (const d of [...this.byIndent]) if (d > indent) this.byIndent.delete(d);
|
||
this.byIndent.add(indent);
|
||
}
|
||
endedAt(indent: number): void {
|
||
for (const d of [...this.byIndent]) if (d >= indent) this.byIndent.delete(d);
|
||
}
|
||
clear(): void { this.byIndent.clear(); }
|
||
}
|
||
|
||
/**
|
||
* Leading-whitespace width of a line in CommonMark COLUMNS (§2.2: a tab
|
||
* advances to the next multiple of 4), so `\t` and ` ` are different levels
|
||
* and `\t` equals four spaces — character counting aliased them.
|
||
*/
|
||
/**
|
||
* Indent WIDTH under a grammar (#3702 round 2, review round 6). The deferred
|
||
* grammar measures CommonMark columns; the Gaps grammar keeps `next`'s raw
|
||
* character count, because its `blockStructure: false` opt-out promises
|
||
* byte-for-byte parity and a column measure silently breaks it — a
|
||
* tab-indented Gaps item followed by a two-space one split into two entries
|
||
* where `next` folded them into one, and the reverse pair folded where `next`
|
||
* split. The opt-out now covers indent semantics, not only fences and breaks.
|
||
*/
|
||
function indentWidth(indent: string, markers: BulletMarkers): number {
|
||
return markers.blockStructure ? indentOf(indent) : indent.length;
|
||
}
|
||
|
||
function indentOf(line: string): number {
|
||
let col = 0;
|
||
for (const ch of line) {
|
||
if (ch === ' ') col += 1;
|
||
else if (ch === '\t') col += 4 - (col % 4);
|
||
else break;
|
||
}
|
||
return col;
|
||
}
|
||
|
||
/** A line classified as a list-item opener by `matchListOpener`. */
|
||
interface ListOpener {
|
||
/** Width of the indent before the marker. */
|
||
indent: number;
|
||
}
|
||
|
||
/**
|
||
* Classify `line` as a list-item opener under `markers`, applying the
|
||
* ORDERED-START rule (#3702 round 2, B2; round 5, M1/M2): a dot-terminated
|
||
* ordered marker opens an item when it starts at `0.` or `1.` (`01.`
|
||
* included), or when a list is already open at its level (`inList` — the
|
||
* caller's per-indent memory).
|
||
*
|
||
* Why: `\d{1,9}\.` alone reads ordinary prose as a list. "2026. was a bad
|
||
* year for this module" and, under a `### Notes` heading, "3. is the number
|
||
* of retries we settled on." are both sentences, and both opened an entry on
|
||
* round 1 — the second one straight through the "prose is not an item"
|
||
* contract that round claimed to preserve. CommonMark §5.3 faces the same
|
||
* ambiguity when an ordered list would interrupt a paragraph and resolves it
|
||
* the same way: the list must start with 1. This parser has no paragraph
|
||
* model, so it applies that rule wherever NO list is open at the line's
|
||
* level — the positions a sentence can occupy. Where a list IS open,
|
||
* CommonMark accepts any start (a list item interrupts no paragraph), and so
|
||
* does this. Numbers after the first are ignored, as CommonMark ignores
|
||
* them, so `1. / 3. / 7.` is a three-item run.
|
||
*
|
||
* `0.` is accepted as a start (round 5, M1): CommonMark §5.2 permits any
|
||
* 1-9-digit start number and a `0.`-numbered list is ordinary; refusing it
|
||
* dropped ONLY the first item, since the run then started at `1.` — the
|
||
* under-report that looks like a clean parse. A sentence opening with "0."
|
||
* is not a shape anyone writes.
|
||
*
|
||
* Stated cost, pinned by test: a list whose first ordinal is 2 or more, at a
|
||
* paragraph position, reads as prose UNTIL its first `0.`/`1.` line — the
|
||
* loss is that prefix, not the whole list. Every ordered record the #3702
|
||
* scan found starts at 1, and the hyphen-style `- ` alternative loses
|
||
* nothing, so the trade buys the prose contract back at no measured cost.
|
||
*
|
||
* Bullet markers carry no rule — an asterisk bullet is not prose.
|
||
*/
|
||
function matchListOpener(line: string, markers: BulletMarkers, inList: boolean): ListOpener | null {
|
||
const m = line.match(markers.open);
|
||
if (!m) return null;
|
||
const token = m[2];
|
||
if (/^\d/.test(token) && !inList && parseInt(token, 10) > 1) return null;
|
||
return { indent: indentWidth(m[1], markers) };
|
||
}
|
||
|
||
/**
|
||
* 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 list item
|
||
* — any of `-`, `*`, `+` or a dot-terminated ordered marker (#3702; the
|
||
* hyphen-only form silently dropped `*`/`+`/ordered bodies), where an
|
||
* ordered marker counts only under `matchListOpener`'s ordered-start rule:
|
||
* - 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.
|
||
*/
|
||
/**
|
||
* A heading-path entry: its lines, the splitter's per-line opener verdict,
|
||
* and — since #3781 — where it sits in the section body, so the writer can
|
||
* anchor a rewrite without a second walk.
|
||
*/
|
||
interface DeferredHeadingEntry {
|
||
lines: string[];
|
||
/** `opener[i]` — line `i` opened a list item under `matchListOpener`'s rules. */
|
||
opener: boolean[];
|
||
/** `leaf` — a childless heading's entry, `lines[0]` its heading TEXT; `pending` — a headless bullet in the preamble or directly under a container heading. */
|
||
kind: 'leaf' | 'pending';
|
||
/** Character span within the section body — `sectionBody.slice(start, end)` is the entry's own raw text, CRLF intact, its lines 1:1 with `lines`. Both -1 when `embeddedTable`. */
|
||
start: number;
|
||
end: number;
|
||
/** A GFM table row sat inside the span. Table lines belong to `parseDeferredTableItems` and are never entry lines, so the raw slice and `lines` disagree and no write can be anchored (#3781). */
|
||
embeddedTable: boolean;
|
||
}
|
||
|
||
/** Character offset of each line's start and end within the text they were split from (on `\n`). */
|
||
function lineOffsets(lines: string[]): { lineStarts: number[]; lineEnds: number[] } {
|
||
const lineStarts: number[] = [];
|
||
const lineEnds: number[] = [];
|
||
let cursor = 0;
|
||
for (const line of lines) {
|
||
lineStarts.push(cursor);
|
||
cursor += line.length;
|
||
lineEnds.push(cursor);
|
||
cursor += 1; // the '\n' separator — absent after the final line, but nothing reads past it
|
||
}
|
||
return { lineStarts, lineEnds };
|
||
}
|
||
|
||
/**
|
||
* The heading-delimited split, carrying the per-line opener flags the deferred
|
||
* field-extraction path needs (#3702 round 2, round review): the heading path
|
||
* strips the marker off EVERY body line before field extraction (#3457), and a
|
||
* line whose ordinal `matchListOpener` REJECTED must not be stripped — or
|
||
* "3. status: resolved" as prose loses its `3. ` and reads as a resolved field.
|
||
*
|
||
* Since #3781 it also records each entry's character span (see
|
||
* `DeferredHeadingEntry`) in this same pass — the reader's walk IS the
|
||
* writer's walk, so there is no second copy of the grouping rules to drift.
|
||
*/
|
||
function splitDeferredHeadingEntriesDetailed(sectionBody: string): DeferredHeadingEntry[] | null {
|
||
const headings = tokenizeHeadings(sectionBody);
|
||
if (headings.length === 0) return null;
|
||
|
||
const lines = sectionBody.split('\n');
|
||
const { lineStarts, lineEnds } = lineOffsets(lines);
|
||
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: DeferredHeadingEntry[] = [];
|
||
// The leaf entry being accumulated, with the raw line range it spans.
|
||
let current: { lines: string[]; opener: boolean[]; startLine: number; endLine: number } | null = null;
|
||
let currentHasBullet = false;
|
||
// The headless-shaped region being accumulated (preamble / a container
|
||
// heading's direct lines): the reader's table-filtered, CR-stripped view,
|
||
// plus the raw line range it spans.
|
||
let pending: string[] = [];
|
||
let pendingStartLine = -1;
|
||
let pendingEndLine = -1;
|
||
// Table lines are never entry lines; where one sits INSIDE an entry's raw
|
||
// range, that entry's span is non-contiguous (#3781, `embeddedTable`).
|
||
const tableLines: number[] = [];
|
||
const tableWithin = (from: number, to: number): boolean => tableLines.some((t) => t >= from && t <= to);
|
||
// List memory for the leaf body being accumulated (#3702 round 2, B2) —
|
||
// reset at every heading, so `### Notes` + "3. is the number…" is prose
|
||
// while `### Steps` + "1. do / 2. then" is a list. A blank line then a
|
||
// non-indented non-list line is a PARAGRAPH, which ends the list
|
||
// (CommonMark §5.3); a non-indented line with no blank before it is lazy
|
||
// continuation and keeps it open.
|
||
const runs = new ListRuns();
|
||
let blankSeen = false;
|
||
// Same level rule as the headless splitter: the first opener in a leaf body
|
||
// sets the base, and every indent at or shallower than it is one level.
|
||
let bodyBase: number | null = null;
|
||
const levelOf = (line: string): number => {
|
||
const ind = indentOf(line);
|
||
return bodyBase !== null && ind <= bodyBase ? bodyBase : ind;
|
||
};
|
||
let scan = scanFencesFrom(lines, 0);
|
||
|
||
const flushCurrent = (): void => {
|
||
// Keep the leaf entry only when its body carries a list item; the heading
|
||
// text line itself (element 0) never counts as one.
|
||
if (current !== null && currentHasBullet) {
|
||
const table = tableWithin(current.startLine, current.endLine);
|
||
entries.push({
|
||
lines: current.lines,
|
||
opener: current.opener,
|
||
kind: 'leaf',
|
||
start: table ? -1 : lineStarts[current.startLine],
|
||
end: table ? -1 : lineEnds[current.endLine],
|
||
embeddedTable: table,
|
||
});
|
||
}
|
||
current = null;
|
||
currentHasBullet = false;
|
||
};
|
||
const flushPending = (): void => {
|
||
if (pendingStartLine !== -1) {
|
||
// Headless-region entries carry the splitter's own opener flags — the
|
||
// same run state (ordered start, paragraph reset) that split them. The
|
||
// region is contiguous (a heading flushes it), so the core's
|
||
// region-relative spans translate by the region's own offset — unless a
|
||
// table row was skipped inside it, where the reader's view and the raw
|
||
// region disagree and no span is claimed. Both views split identically
|
||
// otherwise: the core CR-strips per line, and a table row is the only
|
||
// line the reader's view omits.
|
||
const table = tableWithin(pendingStartLine, pendingEndLine);
|
||
const region = table ? pending.join('\n') : lines.slice(pendingStartLine, pendingEndLine + 1).join('\n');
|
||
const base = lineStarts[pendingStartLine];
|
||
for (const { lines: entryLines, opener, start, end } of splitGapsEntriesCore(region, DEFERRED_BULLET_MARKERS)) {
|
||
entries.push({
|
||
lines: entryLines,
|
||
opener,
|
||
kind: 'pending',
|
||
start: table ? -1 : base + start,
|
||
end: table ? -1 : base + end,
|
||
embeddedTable: table,
|
||
});
|
||
}
|
||
}
|
||
pending = [];
|
||
pendingStartLine = -1;
|
||
pendingEndLine = -1;
|
||
};
|
||
const push = (line: string, i: number, opener: boolean): void => {
|
||
if (current !== null) {
|
||
current.lines.push(line);
|
||
current.opener.push(opener);
|
||
current.endLine = i;
|
||
} else {
|
||
pending.push(line);
|
||
if (pendingStartLine === -1) pendingStartLine = i;
|
||
pendingEndLine = i;
|
||
}
|
||
};
|
||
|
||
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();
|
||
runs.clear();
|
||
blankSeen = false;
|
||
bodyBase = null;
|
||
// A heading ends the entry, and with it any unterminated fence (B1).
|
||
scan = scanFencesFrom(lines, i + 1);
|
||
if (!heading.isContainer) {
|
||
// Leaf heading: open an entry with the heading text as line 0.
|
||
current = { lines: [heading.text], opener: [false], startLine: i, endLine: i };
|
||
currentHasBullet = false;
|
||
}
|
||
continue;
|
||
}
|
||
// CR-strip ONCE and carry the stripped line everywhere below — into the
|
||
// entry itself included (#3702 round 2, B1). `collectSection` slices raw
|
||
// `\n`-split lines, so on a CRLF file every body line but the last still
|
||
// carries its `\r`; the per-line marker strip feeding field extraction is
|
||
// `$`-anchored and fails on such a line, the marker survives into
|
||
// `extractGapEntryFields`, and the field is silently lost — a `**Status:**`
|
||
// that is not the file's final line then resurfaces its entry as open.
|
||
// The headless path (`splitGapsEntriesCore`) already stores stripped lines.
|
||
const line = lines[i].replace(/\r$/, '');
|
||
// B1: an unterminated fence runs to the end of its entry. The next line
|
||
// shaped like a top-level item ends it — rescan from there, so a later
|
||
// delimiter is read on its own terms (see `scanFencesFrom`).
|
||
if (scan.unterminatedFrom !== -1 && i > scan.unterminatedFrom && topLevelItemShape(line, DEFERRED_BULLET_MARKERS, bodyBase)) {
|
||
scan = scanFencesFrom(lines, i);
|
||
}
|
||
if (scan.fenced.has(i) || (scan.unterminatedFrom !== -1 && i >= scan.unterminatedFrom)) {
|
||
// Fence content is body text, never list-item evidence (M2) — and
|
||
// never an opener, so it is never marker-stripped for fields either.
|
||
// Its opener ends the runs at its level and deeper, as a paragraph does.
|
||
if (scan.openers.has(i)) runs.endedAt(levelOf(line));
|
||
push(line, i, false);
|
||
continue;
|
||
}
|
||
// A thematic break is a separator: not evidence, and it clears the list
|
||
// memory (M1). It stays a BODY line — the entry's span must stay
|
||
// contiguous for the writer, and the entry's name stays what `next`
|
||
// reported for a body containing one (round 5, m3).
|
||
if (THEMATIC_BREAK_RE.test(line)) {
|
||
runs.clear();
|
||
blankSeen = false;
|
||
push(line, i, false);
|
||
continue;
|
||
}
|
||
// Table lines belong to parseDeferredTableItems, never to a heading entry.
|
||
if (/^\s*\|/.test(line)) {
|
||
tableLines.push(i);
|
||
continue;
|
||
}
|
||
if (current !== null) {
|
||
const opener = matchListOpener(line, DEFERRED_BULLET_MARKERS, runs.at(levelOf(line)));
|
||
push(line, i, opener !== null);
|
||
if (opener !== null) {
|
||
currentHasBullet = true;
|
||
if (bodyBase === null) bodyBase = opener.indent;
|
||
runs.opened(levelOf(line));
|
||
} else if (blankSeen && line.trim() !== '') {
|
||
runs.endedAt(levelOf(line)); // a paragraph after a blank line ends the lists at its level and deeper
|
||
}
|
||
blankSeen = line.trim() === '';
|
||
} else {
|
||
push(line, i, false); // the core derives its own opener verdicts for the region
|
||
}
|
||
}
|
||
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[];
|
||
/** `opener[i]` — line `i` was an ACCEPTED list opener (line 0 always; a nested one when accepted). */
|
||
opener: boolean[];
|
||
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.
|
||
*
|
||
* `markers` selects the marker set an entry may OPEN with (#3702). It defaults
|
||
* to the hyphen-only Gaps form, so every pre-existing caller is unaffected;
|
||
* the deferred-items callers pass `DEFERRED_BULLET_MARKERS`. Parameterising
|
||
* the shared seam — rather than widening it in place — is what keeps the
|
||
* template-mandated Gaps grammar out of the deferred-items ruling's blast
|
||
* radius while still leaving exactly ONE grouping pass in the module.
|
||
*/
|
||
function splitGapsEntriesCore(
|
||
sectionBody: string,
|
||
markers: BulletMarkers = HYPHEN_BULLET_MARKERS,
|
||
): GapsEntrySpan[] {
|
||
const rawLines = sectionBody.split('\n');
|
||
const { lineStarts, lineEnds } = lineOffsets(rawLines);
|
||
|
||
const entries: GapsEntrySpan[] = [];
|
||
let current: string[] | null = null;
|
||
let currentStartLine = -1;
|
||
let currentEndLine = -1;
|
||
let baseIndent: number | null = null;
|
||
// List memory per indent (#3702 round 2, B2 + round review; round 5, M2) —
|
||
// the top level decides entry boundaries; nested levels decide only which
|
||
// continuation lines count as accepted openers for field stripping.
|
||
const runs = new ListRuns();
|
||
// Per-line opener flags for `current`, recorded HERE — the one place the
|
||
// run state is known — so the heading path's strip-only-openers rule reads
|
||
// the splitter's own verdict instead of re-deriving it (round review: a
|
||
// re-derivation without the paragraph reset re-accepted a rejected ordinal).
|
||
let currentOpeners: boolean[] = [];
|
||
|
||
const flush = (): void => {
|
||
if (current !== null) {
|
||
entries.push({ lines: current, opener: currentOpeners, start: lineStarts[currentStartLine], end: lineEnds[currentEndLine] });
|
||
}
|
||
};
|
||
|
||
// #3898 (from `next`): a spaced-hyphen thematic break (`- - -`, `- -`,
|
||
// `- - -`, …) in `## Gaps` is a SEPARATOR, not an entry. The hyphen opener
|
||
// matches it (hyphen + whitespace), which fabricated a gap named `- -` with
|
||
// result 'unknown' — an item no edit can clear, because there is no entry,
|
||
// only the separator the author wrote deliberately. A line whose content
|
||
// after the opening marker is solely hyphens and spaces (with at least one
|
||
// further hyphen) is skipped: it neither opens an entry nor folds into the
|
||
// current one. Deliberately NOT a full thematic-break concept (option 2 in
|
||
// the issue): a break does not close the Gaps list — entries after it keep
|
||
// parsing. The deferred grammar (`blockStructure`) has its own, CommonMark
|
||
// reading of the same line through THEMATIC_BREAK_RE below, where a break
|
||
// CLOSES the list; this helper is consulted only for the Gaps set.
|
||
const isSeparatorShaped = (line: string, bulletPrefixLen: number): boolean => {
|
||
const remainder = line.slice(bulletPrefixLen);
|
||
return /^[-\s]*$/.test(remainder) && remainder.includes('-');
|
||
};
|
||
|
||
// Block structure (M1/M2 + column indents) is a property of the GRAMMAR,
|
||
// not of this seam: the Gaps set opts out and stays byte-for-byte on its
|
||
// `next` behaviour — see `indentWidth` for the indent half of that opt-out.
|
||
let scan = markers.blockStructure ? scanFencesFrom(rawLines, 0) : NO_FENCES;
|
||
let blankSeen = false;
|
||
// The run LEVEL of a line: every indent at or shallower than the list's
|
||
// base is the one top level (a dedenting list keeps its entry boundaries);
|
||
// deeper indents are their own nested levels.
|
||
const levelOf = (line: string): number => {
|
||
const ind = indentWidth(line.match(/^[ \t]*/)![0], markers);
|
||
return baseIndent !== null && ind <= baseIndent ? baseIndent : ind;
|
||
};
|
||
rawLines.forEach((rawLine, idx) => {
|
||
const line = rawLine.replace(/\r$/, '');
|
||
// B1: an unterminated fence runs to the end of its entry — the next line
|
||
// shaped like a top-level item ends it; rescan from there so a later
|
||
// delimiter is read on its own terms (see `scanFencesFrom`).
|
||
if (scan.unterminatedFrom !== -1 && idx > scan.unterminatedFrom && topLevelItemShape(line, markers, baseIndent)) {
|
||
scan = scanFencesFrom(rawLines, idx);
|
||
}
|
||
if (scan.fenced.has(idx) || (scan.unterminatedFrom !== -1 && idx >= scan.unterminatedFrom)) {
|
||
// Fence content never opens an entry (M2). Inside an open entry it is
|
||
// continuation — pushed, so the span invariant `acknowledgeDeferredItem`
|
||
// re-verifies still holds; before the first entry it is discarded. A
|
||
// fence is a non-list block: its opener ends the runs at its level and
|
||
// deeper, exactly as a paragraph does.
|
||
if (scan.openers.has(idx)) runs.endedAt(levelOf(line));
|
||
if (current !== null) {
|
||
current.push(line);
|
||
currentOpeners.push(false);
|
||
currentEndLine = idx;
|
||
}
|
||
return;
|
||
}
|
||
if (markers.blockStructure && THEMATIC_BREAK_RE.test(line)) {
|
||
// A thematic break closes the list (M1): the open entry ends here, the
|
||
// break itself is neither an item nor a continuation, and nothing after
|
||
// it joins the closed entry — the next opener starts fresh.
|
||
flush();
|
||
current = null;
|
||
runs.clear();
|
||
blankSeen = false;
|
||
return;
|
||
}
|
||
// #3898 narrowed skip (review disposition a), Gaps set only: a
|
||
// separator-shaped line is skipped when it sits BETWEEN entries (nothing
|
||
// open yet, or it would open a top-level entry — where the phantom came
|
||
// from). One landing strictly INSIDE a live entry (indent > baseIndent)
|
||
// folds back as a continuation line, so the entry's GapsEntrySpan stays
|
||
// byte-contiguous — the span invariant below and the ack writer's identity
|
||
// re-verification both hold. The indent compare is the raw character
|
||
// count, which is what `indentWidth` measures for the Gaps set.
|
||
if (!markers.blockStructure) {
|
||
const bulletMatch = line.match(/^(\s*)-\s/);
|
||
if (
|
||
bulletMatch && isSeparatorShaped(line, bulletMatch[0].length) &&
|
||
(current === null || bulletMatch[1].length <= (baseIndent ?? 0))
|
||
) {
|
||
return; // separator line between entries — neither an opener nor a continuation
|
||
}
|
||
}
|
||
const opener = matchListOpener(line, markers, runs.at(levelOf(line)));
|
||
if (opener !== null) {
|
||
const { indent } = opener;
|
||
if (baseIndent === null) baseIndent = indent;
|
||
runs.opened(levelOf(line));
|
||
if (indent <= baseIndent) {
|
||
flush();
|
||
current = [line];
|
||
currentOpeners = [true];
|
||
currentStartLine = idx;
|
||
currentEndLine = idx;
|
||
blankSeen = false; // an opener is not blank — the memory must not survive it
|
||
return;
|
||
}
|
||
}
|
||
if (current !== null) {
|
||
current.push(line);
|
||
currentOpeners.push(opener !== null);
|
||
currentEndLine = idx;
|
||
// A blank line then a top-level non-list line is a PARAGRAPH: the list
|
||
// is over (CommonMark §5.3) and a later `5. x` is prose. Without the
|
||
// blank it is lazy continuation and the run stays open.
|
||
const blank = line.trim() === '';
|
||
if (!blank && blankSeen && opener === null) runs.endedAt(levelOf(line));
|
||
blankSeen = opener === null && blank;
|
||
}
|
||
// 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 — `- ` for Gaps, or whichever set `markers` names (#3702:
|
||
* the deferred-items callers pass the widened CommonMark set).
|
||
*
|
||
* 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 `[]`. Fenced code never opens an
|
||
* entry and a thematic break closes the open one (#3702 round 2, M1/M2).
|
||
*/
|
||
function splitGapsEntries(
|
||
sectionBody: string,
|
||
markers: BulletMarkers = HYPHEN_BULLET_MARKERS,
|
||
): string[][] {
|
||
return splitGapsEntriesCore(sectionBody, markers).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`,
|
||
* `splitDeferredHeadingEntriesDetailed`'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,
|
||
markers: BulletMarkers = HYPHEN_BULLET_MARKERS,
|
||
): GapsEntrySpan[] {
|
||
return splitGapsEntriesCore(sectionBody, markers);
|
||
}
|
||
|
||
/**
|
||
* 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[],
|
||
markers: BulletMarkers = HYPHEN_BULLET_MARKERS,
|
||
openerFlags?: boolean[],
|
||
): Record<string, string> {
|
||
const fields: Record<string, string> = {};
|
||
// A fenced line is content, not a field (#3702 round 2, round review): the
|
||
// splitters already keep fence lines from OPENING an entry, and a
|
||
// `status: resolved` quoted inside a code block must not resolve one either.
|
||
// An entry is a contiguous slice and a fence never spans two entries (the
|
||
// opener of the next entry would be fence content), so scanning the entry's
|
||
// own lines classifies exactly what the splitter classified.
|
||
//
|
||
// RAW lines, and that is the fix for #3702 round 3, m7. The heading path
|
||
// used to marker-strip its lines BEFORE calling this function, so the scan
|
||
// below ran over text the splitter never saw: `- ```sh` is an ordinary
|
||
// bullet to the splitter, but strips to ```` ```sh ````, which opens a
|
||
// fence here that exists in no other pass. A `**Status:** resolved` line
|
||
// after it was then suppressed as fence content and its resolved entry
|
||
// resurfaced as open. The stripping now happens INSIDE this function, after
|
||
// the fence scan, driven by the splitter's own per-line opener verdict.
|
||
entryFieldLines(entryLines, markers, openerFlags).forEach((field) => {
|
||
if (!field) return;
|
||
if (!(field.key in fields)) fields[field.key] = field.value;
|
||
});
|
||
|
||
return fields;
|
||
}
|
||
|
||
/**
|
||
* Per line of an entry, the field it declares — or `null` where it declares
|
||
* none, INCLUDING because it is fenced.
|
||
*
|
||
* This is the seam, and it exists because `parseGapEntryFieldLine` alone was
|
||
* not it (#3702 round 3, pre-push review). The reader applied the fence gate
|
||
* before classifying and the acknowledge writer did not, so a `status:` line
|
||
* inside a fenced block was selected by the writer and skipped by the reader:
|
||
* the write produced a line nothing reads, the read-back guard refused it, and
|
||
* the entry became impossible to acknowledge at all — `audit acknowledge`
|
||
* surfaced an internal error and `complete-milestone` halted on it. That shape
|
||
* acknowledged cleanly on `next`, so it was a regression introduced by the fix
|
||
* for the nested-marker one, and the claim "the writer cannot select a line the
|
||
* reader will not read back" was false while the fence gate lived on one side.
|
||
*
|
||
* Both sides call this now, so the claim is structural rather than asserted.
|
||
*/
|
||
function entryFieldLines(
|
||
entryLines: string[],
|
||
markers: BulletMarkers = HYPHEN_BULLET_MARKERS,
|
||
openerFlags?: boolean[],
|
||
): ({ key: string; value: string; valueStart: number } | null)[] {
|
||
const fenced = entryFencedLines(entryLines, markers, openerFlags);
|
||
return entryLines.map((rawLine, idx) => (
|
||
fenced.has(idx) ? null : parseGapEntryFieldLine(rawLine, markers, stripsMarkerAt(idx, openerFlags))
|
||
));
|
||
}
|
||
|
||
/**
|
||
* The fenced lines of ONE entry, as the reader and the writer both see them.
|
||
* A leaf's line 0 is its heading TEXT, not a Markdown line: a heading that
|
||
* reads ``` or ~~~ is a heading, and must not open a fence over the body
|
||
* beneath it (round 5, RV6.5 — it fenced every field line, so the reader
|
||
* read nothing and the writer's marker landed on a line nothing reads).
|
||
* `openerFlags[0] === false` is the leaf tell: a pending or headless entry's
|
||
* line 0 is an accepted opener, and a marker line is never a delimiter.
|
||
*/
|
||
function entryFencedLines(entryLines: string[], markers: BulletMarkers, openerFlags?: boolean[]): Set<number> {
|
||
if (!markers.blockStructure) return new Set<number>();
|
||
const leaf = openerFlags !== undefined && openerFlags[0] === false;
|
||
return fencedLineSet(leaf ? ['', ...entryLines.slice(1)] : entryLines);
|
||
}
|
||
|
||
/**
|
||
* Which lines of an entry carry an entry-opening marker to be stripped before
|
||
* the line is read as a field.
|
||
*
|
||
* Without flags — the headless and `## Gaps` shapes — that is line 0 alone: a
|
||
* marker on a later line belongs to a nested sub-list (`splitGapsEntries`
|
||
* already folded it in) and is not a field line unless it independently
|
||
* matches `key: value` after a plain trim.
|
||
*
|
||
* With flags — the heading shape — it is whichever lines the SPLITTER accepted
|
||
* as list openers, because there every body line may be a sibling bullet
|
||
* carrying a field (#3457) while line 0 is the heading TEXT and carries no
|
||
* marker at all. Reading the splitter's verdict rather than re-deriving it is
|
||
* what keeps a rejected ordinal (`3. status: resolved` as prose) from being
|
||
* stripped into a field.
|
||
*/
|
||
function stripsMarkerAt(idx: number, openerFlags?: boolean[]): boolean {
|
||
return openerFlags ? openerFlags[idx] === true : idx === 0;
|
||
}
|
||
|
||
/**
|
||
* The ONE place an entry line is classified as a `key: value` field line.
|
||
* `extractGapEntryFields` reads through it, and `acknowledgeDeferredItem`
|
||
* locates the line it will rewrite through it.
|
||
*
|
||
* Sharing the classifier is what makes the writer structurally unable to
|
||
* select a line the reader will not read back (#3702 round 3, B1; the shape
|
||
* is #3773's, parameterised here by `markers` per the round-3 review's
|
||
* prescribed end state). The writer used to carry its own marker-widened
|
||
* status regex, so a nested ` * status: pending` was selectable by the
|
||
* writer and invisible to this reader: acknowledge rewrote it in place,
|
||
* returned `ok`, and the item stayed outstanding forever. A single classifier
|
||
* has no second copy to drift from.
|
||
*
|
||
* `valueStart` is the offset, in the CR-stripped line, at which the VALUE
|
||
* begins — so a rewrite can replace the value without a second regex of its
|
||
* own. The bolded-key unwrap below is a PREFIX rewrite, so the tail of the
|
||
* rewritten content is byte-identical to the tail of the original and the
|
||
* offset maps back directly.
|
||
*
|
||
* Returns `null` for a non-field line.
|
||
*/
|
||
function parseGapEntryFieldLine(
|
||
rawLine: string,
|
||
markers: BulletMarkers = HYPHEN_BULLET_MARKERS,
|
||
stripMarker = true,
|
||
): { key: string; value: string; valueStart: number } | null {
|
||
const fieldLineRe = /^([A-Za-z_][A-Za-z0-9_-]*):\s*(.*)$/;
|
||
const boldedKeyRe = /^\*+([A-Za-z_][A-Za-z0-9_-]*):\*+/;
|
||
|
||
const line = rawLine.replace(/\r$/, '');
|
||
const bulletStripped = stripMarker ? line.match(markers.strip) : null;
|
||
const bare = bulletStripped ? bulletStripped[2] : line.trim();
|
||
// Where `bare` begins in `line`. The two branches differ: the marker strip's
|
||
// group 2 runs to end-of-line, so it is a plain suffix; `trim()` also cuts
|
||
// the tail, so its offset is the LEADING run alone. Computing one from the
|
||
// other's shape under-counts by the trailing whitespace.
|
||
const headLen = bulletStripped ? line.length - bare.length : line.length - line.trimStart().length;
|
||
const content = bare.replace(boldedKeyRe, (_m, key: string) => `${key.toLowerCase()}:`);
|
||
|
||
const m = fieldLineRe.exec(content);
|
||
if (!m) return null;
|
||
let value = m[2].trim();
|
||
if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
|
||
value = value.slice(1, -1);
|
||
}
|
||
return { key: m[1], value, valueStart: headLen + (bare.length - m[2].length) };
|
||
}
|
||
|
||
/**
|
||
* Fallback display text for a Gaps entry with no parseable `truth:` field.
|
||
*
|
||
* `markers` selects which opening marker is stripped (#3702) — hyphen-only by
|
||
* default, the widened set for deferred-items callers, so a `*`-opened entry
|
||
* renders the same name its hyphen twin would. That name is the key
|
||
* `acknowledgeDeferredItem` matches on, so the two MUST use the same set:
|
||
* rendering `* alpha` where the parse surfaced `alpha` would make the entry
|
||
* un-acknowledgeable.
|
||
*
|
||
* `openerFlags` decides WHICH lines are stripped, and on the heading shape
|
||
* line 0 is not one of them (#3702 round 3, m8). There line 0 is the heading
|
||
* TEXT, so an unconditional strip renamed `### 1. Race in the writer` to
|
||
* `Race in the writer` and `### * starred title` to `starred title` — both
|
||
* silent renames of the very key acknowledge matches on, and both a change
|
||
* from this parser's behaviour on `next`.
|
||
*/
|
||
function rawGapEntryText(
|
||
entryLines: string[],
|
||
markers: BulletMarkers = HYPHEN_BULLET_MARKERS,
|
||
openerFlags?: boolean[],
|
||
): string {
|
||
return entryLines
|
||
// Line 0 ONLY, and only if the splitter accepted it as an opener. The
|
||
// opener flags say which lines carry a marker; the entry's NAME is a
|
||
// different question, and stripping a body line's marker out of it changes
|
||
// the key `acknowledgeDeferredItem` matches on.
|
||
.map((l, i) => (i === 0 && stripsMarkerAt(0, openerFlags) ? l.replace(markers.strip, '$2') : l.trim()))
|
||
.join(' ')
|
||
.trim();
|
||
}
|
||
|
||
// ─── parseVerificationItems ───────────────────────────────────────────────────
|
||
|
||
/**
|
||
* The entry's `status:`, lowercased, or undefined when absent/blank/non-scalar.
|
||
*
|
||
* The entry is a PARSED OBJECT, so this reads a named field rather than
|
||
* matching prose. That distinction is the whole point: against the
|
||
* display-flattened string, a `truth:` whose text mentions "status: resolved"
|
||
* is indistinguishable from an entry that carries the field.
|
||
*/
|
||
function frontmatterEntryStatus(entry: Record<string, unknown>): string | undefined {
|
||
const status = entry['status'];
|
||
if (typeof status !== 'string' || status.trim() === '') return undefined;
|
||
return status.trim().toLowerCase();
|
||
}
|
||
|
||
/**
|
||
* Is this `gaps:` frontmatter entry already closed? (#3850)
|
||
*
|
||
* `status: resolved`, and nothing else. Byte-identical to the rule
|
||
* `parseGapsItems` applies to a `## Gaps` markdown section, deliberately: the
|
||
* two readers see the SAME authored vocabulary in two places, and a closure
|
||
* rule that differed between them would let one entry read closed in one
|
||
* reader and open in the other. `parseVerificationGapsItems`' docstring claims
|
||
* it mirrors `parseGapsItems`' fail-safe status handling; this is the line
|
||
* that makes that claim true rather than approximately true.
|
||
*
|
||
* So a `gaps:` entry carrying `resolution:` and no `status:` SURFACES, via the
|
||
* same 'unknown'-status fallback `parseGapsItems` already gives it (#3879
|
||
* review round 4, Major).
|
||
*/
|
||
function isGapsEntryResolved(entry: Record<string, unknown> | undefined): boolean {
|
||
if (!entry) return false;
|
||
return frontmatterEntryStatus(entry) === 'resolved';
|
||
}
|
||
|
||
/**
|
||
* Is this `human_verification:` frontmatter entry already closed? (#3850)
|
||
*
|
||
* Verifier-written entries record closure as a `resolution:` field with no
|
||
* `status:` at all, so `resolution:` closes — but ONLY when no `status:`
|
||
* contradicts it. `status:` is authoritative wherever it is readable.
|
||
*
|
||
* The contradiction guard is the #3879 round-4 Major fix. Without it,
|
||
* `status: failed` + `resolution: "attempted retry, still failing"` — a
|
||
* plausible informational note, not a closure assertion — is silently dropped
|
||
* from the report, which is the exact silently-vanishing-item defect class
|
||
* #3850 exists to close, reached by field COMBINATION instead of file STATUS.
|
||
*
|
||
* This is not a judgment call about YAML: it is the rule this codebase already
|
||
* applies to the same field pair one module over. `validateResolution`
|
||
* (`probe-core.cts`) rejects a populated `resolution:` on a non-resolved status
|
||
* outright — "a populated payload is an authoring mistake (the author meant
|
||
* resolved/dismissed) that would otherwise be silently dropped into the
|
||
* unresolved count with no error pointing at it. Reject it so the mistake
|
||
* surfaces." A reporter cannot throw, so the fail-safe equivalent of surfacing
|
||
* the mistake is to surface the ITEM.
|
||
*
|
||
* #3850's suggested fix (2) states the skip unconditionally — "Skip entries
|
||
* carrying a `resolution:` field" — and its named scenario (one file with 14 of
|
||
* 16 entries resolved) is unaffected by the guard: those entries close either
|
||
* on `resolution:` with no contradicting status, or on `status: resolved`.
|
||
* Both still skip. The guard only changes entries whose own two fields
|
||
* disagree, and for those the fail-safe direction on a false-NEGATIVE bug is to
|
||
* report, not to drop.
|
||
*/
|
||
function isHumanVerificationEntryResolved(entry: Record<string, unknown> | undefined): boolean {
|
||
if (!entry) return false;
|
||
const status = frontmatterEntryStatus(entry);
|
||
if (status !== undefined) return status === 'resolved';
|
||
const resolution = entry['resolution'];
|
||
return typeof resolution === 'string' && resolution.trim() !== '';
|
||
}
|
||
|
||
/**
|
||
* A named string field of a parsed entry, or undefined when absent/non-scalar.
|
||
*
|
||
* A whitespace-only value counts as absent, but a present value is returned
|
||
* VERBATIM — trimming it here would silently rewrite an author's `truth:` on
|
||
* its way to becoming the item's display name, which is a different string from
|
||
* the one in the file.
|
||
*/
|
||
function isFrontmatterObjectEntry(entry: unknown): entry is Record<string, unknown> {
|
||
return !!entry && typeof entry === 'object' && !Array.isArray(entry);
|
||
}
|
||
|
||
/**
|
||
* The PARSED object behind each element of a frontmatter array, positionally
|
||
* aligned with that array's DISPLAY renderings — `null` at any index whose
|
||
* entry is not an object (#3850).
|
||
*
|
||
* Both frontmatter readers below need the same two things about one array: the
|
||
* string each entry has always displayed as, and the fields it actually
|
||
* carries. `extractFrontmatter` gives the first, `frontmatterListEntries` the
|
||
* second, and the ONLY safe way to use them together is by index — so the
|
||
* pairing is done once, here, rather than open-coded twice.
|
||
*
|
||
* Alignment is checked, not assumed. Both arrays come from one parse of one
|
||
* region (they share a fence parser), so they agree in practice; if they ever
|
||
* did not, an index would name a DIFFERENT entry's fields and the resolved-skip
|
||
* would close the wrong row. All-`null` is the correct degradation: no entry is
|
||
* skipped as closed, which over-reports rather than mis-attributes.
|
||
*
|
||
* The length check is UNREACHABLE through content today and is kept anyway
|
||
* (#3879 review round 4, Minor 2). It was verified unreachable rather than
|
||
* assumed: both readers enter through `frontmatterRegion`, `extractFrontmatter`'s
|
||
* only extra argument (`sourcePath`) gates a warning and nothing else, and the
|
||
* display step — `normalizeParsedValue`'s `value.map(...)` — is 1:1 and drops no
|
||
* element. So the guard is a drift alarm for a future edit to either parser, not
|
||
* a live branch. That makes it untestable through the two readers, which is why
|
||
* this helper is exported for tests: the degradation is asserted against the
|
||
* function directly rather than left as the one unpinned branch in the family.
|
||
*/
|
||
function parsedEntriesFor(
|
||
content: string,
|
||
key: string,
|
||
flattened: unknown[],
|
||
): Array<Record<string, unknown> | null> {
|
||
const parsed = frontmatterListEntries(content, key);
|
||
if (!parsed || parsed.length !== flattened.length) return flattened.map(() => null);
|
||
return parsed.map((entry) => (isFrontmatterObjectEntry(entry) ? entry : null));
|
||
}
|
||
|
||
function entryField(entry: Record<string, unknown>, key: string): string | undefined {
|
||
const v = entry[key];
|
||
if (typeof v === 'string') return v.trim() === '' ? undefined : v;
|
||
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
|
||
return undefined;
|
||
}
|
||
|
||
/**
|
||
* One parsed `gaps:` entry -> one `UatItem`.
|
||
*
|
||
* ONE call site, `parseVerificationGapsItems` (#3850 review round 3, Minor 1 —
|
||
* an earlier revision's comment claimed both frontmatter readers shared this,
|
||
* and a dead `forcedResult` option existed to serve the second one; neither was
|
||
* ever true, and the claim made a deliberate difference read as an accident).
|
||
*
|
||
* WHY the two frontmatter readers derive fields differently, since they sit
|
||
* side by side and it is a fair question: each mirrors its OWN established
|
||
* sibling rather than each other.
|
||
*
|
||
* - This one mirrors `parseGapsItems`, the `## Gaps` markdown reader, field
|
||
* for field: `status:` supplies `result` with the module's documented
|
||
* fail-safe `'unknown'` when absent (surface a questionable entry rather
|
||
* than drop a real one), `test` is taken ONLY when the entry declares one,
|
||
* and `reason` passes through. A `gaps:` entry carries its own status, so
|
||
* inventing one would be a lie.
|
||
* - `parseHumanVerificationItems` mirrors #2286's `human_verification:`
|
||
* behaviour: the array IS the outstanding list, so every surviving entry is
|
||
* `human_needed` by construction and its `test` is its ROW, because those
|
||
* entries carry no number of their own.
|
||
*
|
||
* Converging them would mean changing one of those two established contracts
|
||
* for the convenience of symmetry. See `parseVerificationItems` for the one
|
||
* consequence that is genuinely open (a `test` number is unique per array, not
|
||
* per report).
|
||
*
|
||
* The display name falls back to `flattenObjectListItem` — the SAME renderer
|
||
* `extractFrontmatter` applies — so an entry with no `truth:` reads exactly as
|
||
* it always did, byte for byte.
|
||
*/
|
||
function frontmatterEntryToUatItem(entry: Record<string, unknown>): UatItem {
|
||
const status = entryField(entry, 'status') ?? 'unknown';
|
||
const reason = entryField(entry, 'reason');
|
||
const item: UatItem = {
|
||
name: entryField(entry, 'truth') || flattenObjectListItem(entry),
|
||
result: status,
|
||
category: categorizeItem(status, reason, undefined),
|
||
};
|
||
// No `test:` read (#3879 review round 4, Minor 4). A `gaps:` entry has no
|
||
// `test:` in its vocabulary — the verification template's entries carry
|
||
// `truth` / `status` / `reason` / `artifacts` / `missing` — so reading one was
|
||
// speculative support for a field this shape does not have. It also collided:
|
||
// `parseHumanVerificationItems` numbers its items 1..N by array POSITION,
|
||
// so a `gaps:` entry that did carry `test: 1` produced two items numbered 1
|
||
// in one file's combined list. Not reading it makes the collision impossible
|
||
// rather than unlikely, and does not renumber anything: an offset would have
|
||
// rewritten an authored value, which is the opposite of `entryField`'s
|
||
// verbatim contract.
|
||
if (reason) item.reason = reason;
|
||
return item;
|
||
}
|
||
|
||
/**
|
||
* Surface a `gaps_found` report's frontmatter `gaps:` array (#3850).
|
||
*
|
||
* Mirrors `parseGapsItems`' field vocabulary and fail-safe status handling, but
|
||
* reads the FRONTMATTER array rather than a `## Gaps` markdown section —
|
||
* `parseGapsItems` is reached only from `parseUatItems`, and the verification
|
||
* template puts gaps in frontmatter, so no existing reader covers this shape.
|
||
*/
|
||
function parseVerificationGapsItems(content: string): UatItem[] {
|
||
const flattened = extractFrontmatter(content)['gaps'];
|
||
if (!Array.isArray(flattened)) return [];
|
||
const parsed = parsedEntriesFor(content, 'gaps', flattened);
|
||
|
||
const items: UatItem[] = [];
|
||
flattened.forEach((display, idx) => {
|
||
const entry = parsed[idx];
|
||
// A non-object entry (a bare scalar, a null from a `- ` with nothing after
|
||
// it, a nested sequence) still surfaces, named by the SAME renderer every
|
||
// other frontmatter reader names it by. Dropping it would be this module's
|
||
// wrong direction on a false-NEGATIVE bug: `parseGapsItems`' own
|
||
// 'unknown'-status fallback exists to surface a questionable entry rather
|
||
// than lose a real one, and an entry with no readable status is exactly
|
||
// that. It carries no fields, so it can never be skipped as closed.
|
||
if (!entry) {
|
||
items.push({
|
||
name: normalizeHumanVerificationEntry(display),
|
||
result: 'unknown',
|
||
category: categorizeItem('unknown'),
|
||
});
|
||
return;
|
||
}
|
||
if (isGapsEntryResolved(entry)) return;
|
||
items.push(frontmatterEntryToUatItem(entry));
|
||
});
|
||
return items;
|
||
}
|
||
|
||
/**
|
||
* #3850: `gaps_found` is as outstanding as `human_needed`.
|
||
*
|
||
* `cmdAuditUat` admits BOTH statuses, then this function honoured only one and
|
||
* returned an empty array for the other. Because `cmdAuditUat` pushes a file
|
||
* into `results` only when `items.length > 0`, a `gaps_found` report did not
|
||
* merely under-report — it VANISHED, taking its phase's row out of `by_phase`
|
||
* with it, so a clean-looking total gave the reader no cue that anything was
|
||
* skipped. The trailing `plan-phase --gaps` note that stood in for a
|
||
* `gaps_found` branch pointed at a DIFFERENT command that `audit-uat` never
|
||
* reaches.
|
||
*
|
||
* Eligibility now has ONE owner — the caller — and this function reports what
|
||
* the file says.
|
||
*
|
||
* Resolved entries are skipped on BOTH statuses (#3850 review m8). An earlier
|
||
* revision skipped them only on `gaps_found`, citing an acceptance criterion
|
||
* that the issue does not contain: #3850 has no AC section, and its suggested
|
||
* fix (2) states the skip unconditionally — "Skip entries carrying a
|
||
* `resolution:` field, or the fix trades one wrong number for another — one
|
||
* file here has 14 of 16 entries resolved". That file is `human_needed`, so the
|
||
* asymmetry left the reporter's own named scenario over-reporting by 14. The
|
||
* SKIP applies on both paths.
|
||
*
|
||
* WHAT COUNTS AS RESOLVED is per-key, not universal (#3879 review round 4,
|
||
* Major): `isGapsEntryResolved` takes `parseGapsItems`' `status: resolved` rule
|
||
* verbatim so the two `gaps` readers cannot disagree, and
|
||
* `isHumanVerificationEntryResolved` honours the `resolution:`-only closure the
|
||
* issue names, guarded so a `status:` that contradicts it wins. The issue's
|
||
* "skip entries carrying a `resolution:` field" is quoted above as written; it
|
||
* holds for every entry whose fields agree, which is every entry the reporter's
|
||
* own scenario contains.
|
||
*/
|
||
function parseVerificationItems(content: string, status: string, sourcePath?: string): UatItem[] {
|
||
const items: UatItem[] = [];
|
||
if (status === 'gaps_found') {
|
||
items.push(...parseHumanVerificationItems(content, sourcePath));
|
||
items.push(...parseVerificationGapsItems(content));
|
||
return items;
|
||
}
|
||
if (status === 'human_needed') {
|
||
return parseHumanVerificationItems(content, sourcePath);
|
||
}
|
||
return items;
|
||
}
|
||
|
||
/**
|
||
* The `human_verification:` reader, extracted from `parseVerificationItems` so
|
||
* `gaps_found` and `human_needed` share ONE implementation rather than a second
|
||
* copy that drifts (ref `DEFECT.GENERATIVE-FIX`). Both statuses now take the
|
||
* identical path, resolved-entry skip included — see the dispatcher above.
|
||
*/
|
||
function parseHumanVerificationItems(content: string, sourcePath?: string): UatItem[] {
|
||
const items: UatItem[] = [];
|
||
// #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) {
|
||
// #3850: ONE source for both the display name and the sibling fields.
|
||
//
|
||
// `extractFrontmatter` renders each object entry for humans
|
||
// (`flattenObjectListItem`), which is right for printing and wrong for
|
||
// branching: `resolution:` is recoverable from that string only by matching
|
||
// prose, and prose cannot tell a real field from the same text quoted
|
||
// inside `truth:`. `frontmatterListEntries` returns the same entries one
|
||
// step earlier, off the same parse.
|
||
//
|
||
// The flattened array stays the #2286 GATE — a non-empty
|
||
// `human_verification:` fully bypasses the body-shape scan below — but the
|
||
// raw entries are the source of the items, so there is no second reader to
|
||
// desynchronise against.
|
||
//
|
||
// WALK THE FLATTENED ARRAY, and use the parsed one only to answer "is this
|
||
// entry closed?" (#3850 review round 3, Blocker).
|
||
//
|
||
// This is base's loop — every element, at its own index, named by the
|
||
// renderer it has always been named by — plus one skip. It is deliberately
|
||
// NOT "iterate the parsed entries": an earlier revision did that against an
|
||
// object-FILTERED array, which compacted it, so a list mixing object and
|
||
// non-object entries lost the non-object rows outright and renumbered the
|
||
// survivors. That is the silently-vanishing row this issue exists to close,
|
||
// reintroduced by entry SHAPE instead of file STATUS. Numbering off the
|
||
// flattened array cannot drift from what the file says, because that array
|
||
// is the one #2286 already gated on.
|
||
//
|
||
// The name therefore stays byte-identical to base for every entry shape,
|
||
// including the ones with no object to read: a YAML null renders `''`, a
|
||
// nested sequence renders `[nested]`. Re-deriving those from the parsed
|
||
// value would have printed `["nested"]` — a rendering nobody asked this
|
||
// change to alter.
|
||
//
|
||
// `parsedEntriesFor` owns the pairing and its alignment check.
|
||
const parsed = parsedEntriesFor(content, 'human_verification', humanVerification);
|
||
humanVerification.forEach((flattened, idx) => {
|
||
const object = parsed[idx];
|
||
if (object && isHumanVerificationEntryResolved(object)) return;
|
||
items.push({
|
||
// The entry's ORIGINAL 1-based position, so a surfaced item still
|
||
// names its row in the file when a closed sibling was skipped.
|
||
test: idx + 1,
|
||
name: normalizeHumanVerificationEntry(flattened),
|
||
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',
|
||
});
|
||
}
|
||
}
|
||
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') {
|
||
// #4546 — a deliberately deferred follow-up (the verify-work writer's
|
||
// "Deferred follow-up:" template, #1921) is its own category, checked
|
||
// BEFORE the keyword families so e.g. "…on the release build next
|
||
// version" is not misfiled as build_needed. Must agree with
|
||
// uat-predicate.cts's DEFERRED_REASON_RE (gate/audit agreement,
|
||
// #3078-CR), pinned by tests/uat-predicate.test.cjs.
|
||
if (reason && /^["']?deferred follow-up\b/i.test(reason)) return 'deferred';
|
||
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,
|
||
// #3702 round 2 (M3): exposed for the marker-grammar parity test only.
|
||
// Narrowed in round 3 (M6): the two status-line regexes are gone from the
|
||
// module, so nothing exports them, and the parity test they were widened for
|
||
// could not reach the defect it was meant to guard anyway — it asserted the
|
||
// four WRITER regexes shared a source string, which is true of a detect/read
|
||
// asymmetry too. The pair below is what the behavioural parity test against
|
||
// `iterateBullets` actually reads.
|
||
DEFERRED_MARKER_ALT,
|
||
DEFERRED_BULLET_MARKERS,
|
||
// #3850: exported so the `gaps_found` partition invariant is asserted
|
||
// against the parser itself rather than only through a CLI round-trip
|
||
// (RULESET.TESTS.property-based-testing).
|
||
parseVerificationItems,
|
||
// #3879 review round 4, Minor 2: exported for tests so the degrade-to-all-null
|
||
// branch is asserted directly. It cannot be reached through the two readers —
|
||
// see the alignment note on the function.
|
||
parsedEntriesFor,
|
||
};
|