Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
258 changes: 258 additions & 0 deletions .github/scripts/openspec-visibility.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
// SPDX-License-Identifier: MIT
"use strict";

/**
* Requirement visibility check — catch specs the OpenSpec parser silently
* stops reading part-way through.
*
* WHY THIS EXISTS. `openspec validate --all --strict` answers "is what I read
* well-formed", not "did I read the whole file". A spec's requirements live
* under `## Requirements`, and a second `##` heading inside that section ends
* it: every `### Requirement:` below the intruding heading is no longer a
* requirement to the parser, it is prose. Those requirements are not invalid,
* they are INVISIBLE — so `--strict` reports success having never looked at
* them. Measured before this check existed, `infrastructure` carried 20
* requirements with 1 visible and `skills` carried 7 with 1, both green the
* whole time. A passing gate is an active claim that the spec was read, which
* makes this failure mode worse than a red check rather than milder.
*
* WHAT IT CHECKS. For each `openspec/specs/<capability>/spec.md`: the number of
* `### Requirement:` headings in the file must equal the number the parser can
* reach. Any difference fails, naming the spec and the count.
*
* FENCED CODE BLOCKS ARE NOT SPEC CONTENT. Lines inside a ``` or ~~~ fence are
* skipped entirely — they neither count as requirements nor open or close the
* requirements section. Spec files do contain fenced examples, and a fenced
* `### Requirement:` is documentation ABOUT the format, not a requirement the
* parser was ever meant to see; counting it would report a hidden requirement
* that does not exist and push authors to mangle their examples. Skipping
* fences costs nothing in detection power: the `cli-help` defect this family of
* bugs comes from was a LOST OPENING fence, which turns the block's `##` lines
* into real headings — and real headings are exactly what this check reads.
*
* AN UNCLOSED FENCE IS ITSELF A FAILURE. Skipping fenced content has one edge:
* a lost opening fence leaves the block's CLOSING ``` dangling, and a dangling
* fence opens a region that runs to end of file, hiding everything after it
* from this check as well as from the parser. So a fence still open at EOF
* fails outright rather than being tolerated — the one case where "I could not
* read the rest of the file" is the finding.
*
* Usage:
* node .github/scripts/openspec-visibility.cjs [specsDirectory]
*
* Exits non-zero when any spec has requirements the parser cannot see.
*/
const { readdirSync, readFileSync, existsSync } = require("node:fs");
const { join, relative, resolve } = require("node:path");

const REPO_ROOT = join(__dirname, "..", "..");
const DEFAULT_SPECS_DIRECTORY = join(REPO_ROOT, "openspec", "specs");

/**
* HEADING RECOGNITION MIRRORS OPENSPEC'S, DELIBERATELY. This check is only
* meaningful where it agrees with the parser it is speaking for — and the
* parser it speaks for is the one `openspec validate` runs: `MarkdownParser`
* (core/validation/validator.js constructs it, core/parsers/markdown-parser.js
* defines it). Its rules are:
*
* /^(#{1,6})\s+(.+)$/ what counts as a heading at all
* findSection(..., 'Requirements') the section, matched case-insensitively
* getContentUntilNextHeader(i, 2) any heading of level <= 2 ends it
* parseRequirements(section) EVERY `###` child is a requirement
*
* `requirement-blocks.js` carries a second, stricter set of patterns
* (`/^###\s*Requirement:\s*(.+)\s*$/` and friends). Those serve the delta/edit
* path, not validation, and where the two disagree this check follows the
* validating parser. Three consequences are worth stating because they look
* like bugs.
*
* A `#` HEADING ENDS THE SECTION TOO. `getContentUntilNextHeader` breaks on any
* heading whose level is <= the section's, so inside `## Requirements` a
* level-1 `# Something` truncates it exactly as a `##` does. Matching only
* `^##\s` here would report every requirement below such a heading as visible
* while the parser never reads one of them — the silent truncation this file
* exists to catch, reproduced in the checker.
*
* NO LEADING-SPACE TOLERANCE, ON PURPOSE. CommonMark accepts up to three
* spaces before an ATX heading; OpenSpec does not — every one of its patterns
* is anchored at column 0. So an indented ` ## Grouping` is prose to the
* parser and does NOT end the requirements section. Widening this to `^ {0,3}##`
* would make the check stricter than the thing it checks and report hidden
* requirements that are, in fact, read — the opposite of the failure it exists
* to catch. `fenceOf` tolerates leading spaces because CommonMark's fence rule
* is what governs fences here; headings are governed by OpenSpec's rule.
*
* WHITESPACE AFTER THE HASHES IS TOLERATED, AND SO IS A MISSING TITLE. OpenSpec
* matches `\s`, not a literal space, so `##\tGrouping` really does end the
* section and `### Requirement:` really is a requirement. And because
* `parseRequirements` promotes every `###` child of the section, a title-less
* `### Requirement:` is a requirement to the validating parser as well —
* demanding a non-empty title (`\s*\S`) would stop counting a heading the
* parser does read.
*/
const REQUIREMENTS_HEADING = /^##\s+Requirements\s*$/i;
const SECTION_HEADING = /^#{1,2}\s/;
const REQUIREMENT_HEADING = /^###\s*Requirement:/;
Comment thread
thecodedrift marked this conversation as resolved.

/**
* A fence opens on ``` or ~~~ (up to three leading spaces, per CommonMark) and
* closes on a run of the same character at least as long. Tracking the opener's
* length and character is what lets a ```` ```` ```` block contain a ``` line
* without the check losing its place.
*/
function fenceOf(line) {
const match = /^ {0,3}(`{3,}|~{3,})/.exec(line);
return match ? { char: match[1][0], length: match[1].length } : null;
}

/**
* Count requirements written in a spec against requirements the parser reaches.
* Pure and string-in, so the interesting cases are testable without a fixture
* tree on disk.
*/
function countRequirements(source) {
let total = 0;
let visible = 0;
let inRequirements = false;
let openFence = null;

for (const line of source.split("\n")) {
const fence = fenceOf(line);
if (openFence) {
if (
fence &&
fence.char === openFence.char &&
fence.length >= openFence.length
) {
openFence = null;
}
continue;
}
if (fence) {
openFence = fence;
continue;
}

if (SECTION_HEADING.test(line)) {
inRequirements = REQUIREMENTS_HEADING.test(line);
} else if (REQUIREMENT_HEADING.test(line)) {
total += 1;
if (inRequirements) {
visible += 1;
}
}
}

return {
total,
visible,
hidden: total - visible,
unclosedFence: openFence !== null,
};
}

/** Every `<capability>/spec.md` under the specs directory, sorted for stable output. */
function findSpecs(specsDirectory) {
if (!existsSync(specsDirectory)) {
return [];
}
return readdirSync(specsDirectory, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => join(specsDirectory, entry.name, "spec.md"))
.filter((path) => existsSync(path))
.sort();
}

/** Repo-relative when the spec lives in the repo, absolute otherwise (tests, ad-hoc runs). */
function displayPath(path) {
const relativePath = relative(REPO_ROOT, path);
return relativePath.startsWith("..") ? path : relativePath;
}

function checkSpecs(specsDirectory = DEFAULT_SPECS_DIRECTORY) {
return findSpecs(specsDirectory).map((path) => ({
path,
...countRequirements(readFileSync(path, "utf8")),
}));
}

/**
* `log`/`error` are injectable so tests can assert on the report without
* reassigning the global console — a mutation that is only safe while
* `node --test` runs this file's tests sequentially, and would start losing or
* crossing output the day anything here gains concurrency.
*/
function main({
argv = process.argv.slice(2),
log = console.log,
error = console.error,
} = {}) {
const specsDirectory = argv[0]
? resolve(process.cwd(), argv[0])
: DEFAULT_SPECS_DIRECTORY;
const results = checkSpecs(specsDirectory);

if (results.length === 0) {
error(`No specs found under ${specsDirectory}`);
return { results, ok: false };
}

for (const result of results) {
const name = displayPath(result.path);
const status =
result.hidden > 0
? "HIDDEN "
: result.unclosedFence
? "UNCLOSED"
: "ok ";
log(
` ${status} ${name} ${result.visible}/${result.total} requirement(s) visible`
);
}

const broken = results.filter(
(result) => result.hidden > 0 || result.unclosedFence
);
if (broken.length === 0) {
log(
`\nEvery requirement in ${results.length} spec(s) is visible to the parser.`
);
return { results, ok: true };
}

error("");
for (const result of broken) {
const name = displayPath(result.path);
if (result.hidden > 0) {
error(
`${name}: ${result.hidden} requirement(s) hidden by a '##' heading inside '## Requirements' (${result.visible} of ${result.total} visible)`
);
}
if (result.unclosedFence) {
error(
`${name}: a code fence is still open at end of file, so everything after it is unreadable to this check and to the parser (likely a lost opening fence leaving its closer dangling)`
);
}
}
if (broken.some((result) => result.hidden > 0)) {
error(
"\nA '##' heading ends the requirements section. Use a bold lead-in line for topical grouping instead, so the requirements below it stay readable to the parser."
);
}
return { results, ok: false };
}

// Exported so openspec-visibility.test.cjs can drive the counter over inline
// sources and main() over a temporary spec tree, with nothing on disk to keep.
module.exports = { countRequirements, findSpecs, checkSpecs, main };

if (require.main === module) {
try {
if (!main().ok) {
process.exitCode = 1;
}
} catch (error) {
console.error(`\nopenspec-visibility failed: ${error.message}`);
process.exitCode = 1;
}
}
Loading
Loading