-
Notifications
You must be signed in to change notification settings - Fork 0
ci: gate main on spec validity and requirement visibility #114
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
0aeb833
ci: gate main on spec validity and requirement visibility
thecodedrift 3e0d781
docs(openspec): correct the Node version the CI spec requires
thecodedrift 3fd2301
fix(ci): match OpenSpec's own heading patterns in the visibility check
thecodedrift 71f4a57
fix(ci): end the requirements section on a '#' heading, as the parser…
thecodedrift File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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:/; | ||
|
|
||
| /** | ||
| * 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; | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.