import { buildCodeFenceMask, SCENARIO_HEADER } from './requirement-text.js'; export interface RequirementBlock { headerLine: string; // e.g., '### Requirement: Something' name: string; // e.g., 'Something' raw: string; // full block including headerLine and following content } export interface RequirementsSectionParts { before: string; headerLine: string; // the '## Requirements' line preamble: string; // content between headerLine and first requirement block bodyBlocks: RequirementBlock[]; // parsed requirement blocks in order after: string; } export function normalizeRequirementName(name: string): string { // An ATX heading may end in a closing run of `#`s: `### Requirement: Foo ###` // renders as `Foo`, so the run is not part of the name. As for scenario names, // only a run preceded by a space or tab closes the heading, so `C#` keeps its // `#`, and `[ \t]` rather than `\s` keeps an NBSP-separated run in the name. return name.replace(/[ \t]+#+[ \t]*$/, '').trim(); } /** * A stable ID written in square brackets at the start of a requirement or * scenario name (#2167): a word with a hyphen that ends in a number, such as * `[AUTH-3]`, `[AUTH-3.1]` or `[cap-012]`. The brackets are required: a bare * `SHA-256 checksum` or `HTTP-2 support` is an ordinary name, so specs without * IDs keep matching exactly as before. The ID stays part of the name as well: * matching by name is unchanged, and the ID only adds a match that survives a * rename. */ const HEADING_ID = /^\[([A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*-\d+(?:\.\d+)*)\](?=\s|$)/; /** The ID at the start of a requirement or scenario name, if it has one. */ export function headingId(name: string): string | undefined { return name.match(HEADING_ID)?.[1]; } /** The name with its leading ID removed; the name itself when it has none. */ function nameWithoutId(name: string): string { return name.replace(HEADING_ID, '').trim(); } /** Every ID that more than one of the given names carries, in first-seen order. */ export function findDuplicateIds(names: readonly string[]): string[] { const seen = new Set(); const duplicates = new Set(); for (const name of names) { const id = headingId(name); if (id === undefined) continue; if (seen.has(id)) duplicates.add(id); seen.add(id); } return [...duplicates]; } /** * The key of the current requirement a MODIFIED block targets by ID, for a * block whose exact name is not in the spec: the one requirement with the same * ID (a rename), or, when no requirement has that ID yet, the one without an ID * whose name matches the block's name minus its ID (an ID being added). * * Undefined when the block has no ID, nothing matches, or the spec's IDs are * not unique - callers then keep today's name lookup, so a spec that already * holds a duplicate never matches the wrong requirement. */ export function findRequirementKeyById( current: ReadonlyMap, incoming: RequirementBlock ): string | undefined { const id = headingId(incoming.name); if (id === undefined) return undefined; const entries = [...current.entries()]; if (findDuplicateIds(entries.map(([, block]) => block.name)).length > 0) return undefined; const sameId = entries.find(([, block]) => headingId(block.name) === id); if (sameId) return sameId[0]; const bare = nameWithoutId(incoming.name); const adopted = entries.filter( ([, block]) => headingId(block.name) === undefined && normalizeRequirementName(block.name) === bare ); return adopted.length === 1 ? adopted[0][0] : undefined; } /** * Case- and whitespace-insensitive fold of a requirement name. Requirement * matching itself is case-sensitive (normalizeRequirementName); this fold * exists only for typo detection - near-miss REMOVED, ADDED and RENAMED * headers and the RENAMED+REMOVED cross-section conflict - where two spellings * that differ only in case or interior whitespace mean a mistake, never two * requirements. */ export function foldRequirementName(name: string): string { return normalizeRequirementName(name).toLowerCase().replace(/\s+/g, ' '); } /** The canonical requirement header the delta reader recognizes. */ const REQUIREMENT_HEADER_REGEX = /^###\s*Requirement:\s*(.+)\s*$/i; /** * Extracts the Requirements section from a spec file and parses requirement blocks. */ export function extractRequirementsSection(content: string): RequirementsSectionParts { const normalized = normalizeLineEndings(content); const lines = normalized.split('\n'); const fenceMask = buildCodeFenceMask(lines); const reqHeaderIndex = lines.findIndex((l, i) => !fenceMask[i] && /^##\s+Requirements\s*$/i.test(l)); if (reqHeaderIndex === -1) { // No requirements section; create an empty one at the end const before = content.trimEnd(); const headerLine = '## Requirements'; return { before: before ? before + '\n\n' : '', headerLine, preamble: '', bodyBlocks: [], after: '\n', }; } // Find end of this section: next line that starts with '## ' at same or higher level let endIndex = lines.length; for (let i = reqHeaderIndex + 1; i < lines.length; i++) { if (!fenceMask[i] && /^##\s+/.test(lines[i])) { endIndex = i; break; } } const before = lines.slice(0, reqHeaderIndex).join('\n'); const headerLine = lines[reqHeaderIndex]; const sectionBodyLines = lines.slice(reqHeaderIndex + 1, endIndex); const sectionBodyMask = fenceMask.slice(reqHeaderIndex + 1, endIndex); const isRequirementHeader = (cursor: number): boolean => !sectionBodyMask[cursor] && REQUIREMENT_HEADER_REGEX.test(sectionBodyLines[cursor]); const isTopLevelHeader = (cursor: number): boolean => !sectionBodyMask[cursor] && /^##\s+/.test(sectionBodyLines[cursor]); // Parse requirement blocks within section body const blocks: RequirementBlock[] = []; let cursor = 0; let preambleLines: string[] = []; // Collect preamble lines until first requirement header while (cursor < sectionBodyLines.length && !isRequirementHeader(cursor)) { preambleLines.push(sectionBodyLines[cursor]); cursor++; } while (cursor < sectionBodyLines.length) { const headerLineCandidate = sectionBodyLines[cursor]; if (!isRequirementHeader(cursor)) { // Not a requirement header; skip line defensively cursor++; continue; } const headerMatch = headerLineCandidate.match(REQUIREMENT_HEADER_REGEX)!; const name = normalizeRequirementName(headerMatch[1]); cursor++; // Gather lines until next requirement header or end of section const bodyLines: string[] = [headerLineCandidate]; while (cursor < sectionBodyLines.length && !isRequirementHeader(cursor) && !isTopLevelHeader(cursor)) { bodyLines.push(sectionBodyLines[cursor]); cursor++; } const raw = bodyLines.join('\n').trimEnd(); blocks.push({ headerLine: headerLineCandidate, name, raw }); } const after = lines.slice(endIndex).join('\n'); const preamble = preambleLines.join('\n').trimEnd(); return { before: before.trimEnd() ? before + '\n' : before, headerLine, preamble, bodyBlocks: blocks, after: after.startsWith('\n') ? after : '\n' + after, }; } /** * A level-3 header inside `## ADDED`/`## MODIFIED Requirements` that is not a * canonical `### Requirement:` header, recorded at the moment the delta reader * skips over it. Surfaced as an INFO note by `validate ` (#498). */ export interface SkippedHeader { header: string; // header text without the leading ### section: string; // the ## section title as written line: number; // 1-based line number in the delta file } /** * A `FROM:` or `TO:` line in `## RENAMED Requirements` that never formed a pair, * recorded at the moment the reader steps over it. * * The pair reader used to carry one mutable `{ from, to }` and drop whatever did * not fit: a second `FROM:` overwrote an unpaired first, a `TO:` with no pending * `FROM:` vanished, and a trailing `FROM:` was forgotten at the end of the * section. Nothing counted any of it, so a rename the author asked for could * silently not happen - or, when the lines interleaved, a DIFFERENT requirement * could be renamed under a name meant for another one. * * Recording them is what lets `validate` report the problem and `buildUpdatedSpec` * refuse, rather than guess a pairing and rewrite the spec from it. */ export interface UnpairedRename { side: 'FROM' | 'TO'; name: string; // requirement name as written line: number; // 1-based line number in the delta file } /** * A canonical `### Requirement:` block that sits outside every delta section - * under `## Notes`, under a misspelled `## Add Requirements`, or above the * first `## ` header entirely. * * The delta reader only ever looks inside the four delta sections, so a block * written anywhere else was dropped with no error, no warning and no note - * even though it is well formed and reads exactly like one that would apply. * That was the inconsistency worth closing: the ADJACENT mistake, a * non-canonical `###` header INSIDE a delta section, has been reported as INFO * since #498 (`skippedHeaders`), while the costlier one said nothing at all. */ export interface OrphanedRequirement { name: string; // requirement name as written section: string | null; // the `## ` section it sits under, or null above the first one line: number; // 1-based line number in the delta file } /** * Authored text archive does not carry into the main spec: a non-delta `## ` * section, text above the first `## `, or prose before the first requirement * in ADDED/MODIFIED. `section` is null for text above the first `## `. */ export interface UnappliedContent { section: string | null; line: number; // 1-based: the section header, or the first line of leading prose } export interface DeltaPlan { added: RequirementBlock[]; modified: RequirementBlock[]; removed: string[]; // requirement names // Raw `### Requirement:` blocks from the REMOVED section, when the delta used // the header form. Names alone drop the authored Reason/Migration text that a // reader of the removal needs. Empty for the bullet-list form, which has none. removedBlocks: RequirementBlock[]; renamed: Array<{ from: string; to: string }>; /** FROM:/TO: lines in RENAMED that never formed a pair. */ unpairedRenames: UnpairedRename[]; /** Canonical requirement blocks written outside every delta section. */ orphanedRequirements: OrphanedRequirement[]; unappliedContent: UnappliedContent[]; skippedHeaders: SkippedHeader[]; // non-canonical ### headers the reader skipped sectionPresence: { added: boolean; modified: boolean; removed: boolean; renamed: boolean; }; } function normalizeLineEndings(content: string): string { // Strip a UTF-8 BOM: Windows editors and PowerShell redirects prepend one, // and it would keep the first line's `## ADDED Requirements` from matching. return content.replace(/^/, '').replace(/\r\n?/g, '\n'); } /** * A slice of a document represented as its lines plus a parallel mask marking * lines that live inside fenced code blocks (which must be ignored when * detecting Markdown structure). */ interface SectionBody { lines: string[]; fenceMask: boolean[]; bodyStartLine: number; } /** * Parse a delta-formatted spec change file content into a DeltaPlan with raw blocks. */ export function parseDeltaSpec(content: string): DeltaPlan { const normalized = normalizeLineEndings(content); const lines = normalized.split('\n'); const fenceMask = buildCodeFenceMask(lines); const sections = splitTopLevelSections(lines, fenceMask); const addedLookup = getSectionsCaseInsensitive(sections, 'ADDED Requirements'); const modifiedLookup = getSectionsCaseInsensitive(sections, 'MODIFIED Requirements'); const removedLookup = getSectionsCaseInsensitive(sections, 'REMOVED Requirements'); const renamedLookup = getSectionsCaseInsensitive(sections, 'RENAMED Requirements'); const skippedHeaders: SkippedHeader[] = []; const added = addedLookup.bodies.flatMap((body) => parseRequirementBlocksFromSection(body, { section: addedLookup.title, bodyStartLine: body.bodyStartLine, sink: skippedHeaders, }) ); const modified = modifiedLookup.bodies.flatMap((body) => parseRequirementBlocksFromSection(body, { section: modifiedLookup.title, bodyStartLine: body.bodyStartLine, sink: skippedHeaders, }) ); const removedNames = removedLookup.bodies.flatMap((body) => parseRemovedNames(body)); const removedBlocks = removedLookup.bodies.flatMap((body) => parseRequirementBlocksFromSection(body) ); // Pairs are read per section, so a FROM in one copy of the header can never // pair with a TO in another: a FROM left pending at the end of one copy is // reported as unpaired rather than carried into the next. const unpairedRenames: UnpairedRename[] = []; const renamedPairs = renamedLookup.bodies.flatMap((body) => parseRenamedPairs(body, unpairedRenames) ); unpairedRenames.sort((a, b) => a.line - b.line); skippedHeaders.sort((a, b) => a.line - b.line); return { added, modified, removed: removedNames, removedBlocks, renamed: renamedPairs, unpairedRenames, orphanedRequirements: findOrphanedRequirements(lines, fenceMask), unappliedContent: findUnappliedContent(lines, fenceMask), skippedHeaders, sectionPresence: { added: addedLookup.found, modified: modifiedLookup.found, removed: removedLookup.found, renamed: renamedLookup.found, }, }; } /** * The four section titles the delta reader acts on, folded the way * `getSectionsCaseInsensitive` folds them. Matching the reader exactly matters: * a looser test (say, any run of whitespace) would treat `## ADDED Requirements` * as a delta section here while the reader ignores it, and the requirements * under it would be dropped without this warning. */ const DELTA_SECTION_TITLES = new Set( ['ADDED Requirements', 'MODIFIED Requirements', 'REMOVED Requirements', 'RENAMED Requirements'].map( (title) => title.toLowerCase() ) ); /** * Every canonical `### Requirement:` header that is not inside a delta section, * in document order. * * Walks the whole file rather than the parsed sections so a requirement written * ABOVE the first `## ` header is reported too - it is dropped just as silently * as one under `## Notes`. Fenced lines are skipped, so a requirement shown * inside a markdown example is not mistaken for an authored one. */ function findOrphanedRequirements( lines: string[], fenceMask: boolean[] ): OrphanedRequirement[] { const orphans: OrphanedRequirement[] = []; let section: string | null = null; for (let i = 0; i < lines.length; i++) { if (fenceMask[i]) continue; // The same `## ` test splitTopLevelSections uses, so both agree on sections. const sectionMatch = lines[i].match(/^(##)\s+(.+)$/); if (sectionMatch) { section = sectionMatch[2].trim(); continue; } if (section !== null && DELTA_SECTION_TITLES.has(section.toLowerCase())) continue; const header = lines[i].match(REQUIREMENT_HEADER_REGEX); if (header) { orphans.push({ name: normalizeRequirementName(header[1]), section, line: i + 1, }); } } return orphans; } export function describeUnappliedContent(content: UnappliedContent, sentenceStart = false): string { const text = content.section === null ? 'text above the first "## " section' : DELTA_SECTION_TITLES.has(content.section.toLowerCase()) ? `text before the first requirement in "## ${content.section}"` : `section "## ${content.section}"`; return sentenceStart ? text.charAt(0).toUpperCase() + text.slice(1) : text; } /** * Where text the merge never reads begins. HTML comments, `# ` titles, Purpose * (warned about separately), REMOVED/RENAMED bodies, and stray `###` headers * before the first requirement (INFO since #498) are not reported. */ function findUnappliedContent(lines: string[], fenceMask: boolean[]): UnappliedContent[] { // Blank out comments outside fences, keeping line count; an unterminated one // runs to EOF. Comments only decide whether a line holds text: sections come // from the same split the merge reads, where a `## ` line inside a comment is // still a boundary. Fenced lines keep their text, so a `