Files
msd-core/src/spec-section.cts

125 lines
5.2 KiB
TypeScript

/**
* SPEC section-status helper — the SINGLE source of truth for "did the phase SPEC supply
* section X (with at least one resolved row)?".
*
* Consumed by `plan-phase` step 7.95 (the spec-less probe fallback) to decide, per section,
* whether to run the fallback. Previously this lived as ad-hoc `awk` in the workflow body, which
* (1) hard-coded the section header strings at the call site and (2) hand-rolled markdown-table
* row counting — a brittleness that produced two bugs: an exact `^## Prohibitions$` anchor that
* missed the canonical `## Prohibitions (must-NOT)` heading, and a single-table row-counting
* assumption. Centralising the header matchers + the counting here makes the knowledge testable
* and shared (any future SPEC-section consumer reuses it instead of re-parsing markdown).
*
* Authored as strict TypeScript (`src/spec-section.cts`) and compiled by
* `tsc -p tsconfig.build.json` to the gitignored runtime artifact
* `gsd-core/bin/lib/spec-section.cjs`. Do NOT hand-write the `.cjs`; it is emitted. Tests
* `require()` the built artifact; `pretest` runs `build:lib`.
*
* Pure and dependency-free.
*/
import fs from 'node:fs';
/** The logical SPEC sections the spec-less probe fallback cares about. */
export type SpecSectionKey = 'edges' | 'prohibitions';
/**
* Canonical SPEC section header matchers — the SINGLE source of truth for the headings
* `templates/spec.md` renders. Suffix-tolerant by design: `prohibitions` matches BOTH
* `## Prohibitions` and `## Prohibitions (must-NOT)`; `edges` matches `## Edge Coverage`
* (and any future parenthetical suffix). If spec-phase renames a heading, update HERE and the
* spec.md template together — the contract is pinned by `tests/spec-section.test.cjs`.
*/
export const SECTION_HEADERS: Record<SpecSectionKey, RegExp> = {
edges: /^##[ \t]+Edge Coverage([ \t]|\(|$)/,
prohibitions: /^##[ \t]+Prohibitions([ \t]|\(|$)/,
};
export interface SectionStatus {
key: SpecSectionKey;
/** A matching `## …` header line was found. */
present: boolean;
/** Markdown table DATA rows under the header (excludes the table header row and the `|---|` separator). */
dataRows: number;
/** `present` AND `dataRows > 0`. A present-but-empty section is NOT supplied (it triggers the fallback). */
supplied: boolean;
}
const ANY_H2 = /^##[ \t]/;
const TABLE_LINE = /^\|/;
// Separator row of a markdown table: |---|, |:--|, | :---: |, etc.
const SEPARATOR = /^\|[ \t]*:?-{2,}/;
/**
* Count markdown table DATA rows inside the named section. A data row is a `|…|` line that
* follows the table's `|---|` separator; the `|…|` line BEFORE the separator is the table header
* row and is not counted. Blank or prose lines reset the per-table state, so a section containing
* multiple tables (or prose between tables) counts every table's data rows without miscounting a
* second table's header — more robust than the single-table awk it replaces.
*/
export function countSectionDataRows(
specText: string,
key: SpecSectionKey,
): { present: boolean; dataRows: number } {
const header = SECTION_HEADERS[key];
let inSection = false;
let present = false;
let afterSeparator = false;
let dataRows = 0;
for (const line of specText.split(/\r?\n/)) {
if (ANY_H2.test(line)) {
inSection = header.test(line);
if (inSection) present = true;
afterSeparator = false;
continue;
}
if (!inSection) continue;
if (TABLE_LINE.test(line)) {
if (SEPARATOR.test(line)) {
afterSeparator = true; // the next |…| lines are data rows
} else if (afterSeparator) {
dataRows++;
}
// a |…| line before any separator is the table header row -> not counted
} else {
// blank or prose line ends the current table; a later table re-arms on its own separator
afterSeparator = false;
}
}
return { present, dataRows };
}
/**
* Resolve a SPEC file's section status from disk. A missing/unreadable file is reported as
* not present (so the fallback fires) rather than throwing.
*/
export function specSectionStatus(specFile: string, key: SpecSectionKey): SectionStatus {
let text: string;
try {
text = fs.readFileSync(specFile, 'utf8');
} catch {
return { key, present: false, dataRows: 0, supplied: false };
}
const { present, dataRows } = countSectionDataRows(text, key);
return { key, present, dataRows, supplied: present && dataRows > 0 };
}
const VALID_KEYS: readonly SpecSectionKey[] = ['edges', 'prohibitions'];
// CLI: `node spec-section.cjs <specFile> <edges|prohibitions>` -> prints SectionStatus JSON.
// Exit 0 on success (even when the file is absent — that is a valid "not supplied" answer); exit
// 2 only on a usage error (missing args / bad key). `require.main === module` so it runs only when
// the compiled `.cjs` is executed directly, never when imported by tests.
if (require.main === module) {
const specFile = process.argv[2];
const key = process.argv[3] as SpecSectionKey | undefined;
if (!specFile || !key || !VALID_KEYS.includes(key)) {
process.stderr.write('usage: spec-section.cjs <specFile> <edges|prohibitions>\n');
process.exit(2);
}
process.stdout.write(JSON.stringify(specSectionStatus(specFile, key)) + '\n');
}