Skip to content

ci: gate main on spec validity and requirement visibility - #114

Open
thecodedrift wants to merge 3 commits into
mainfrom
ci/gate-openspec-validation
Open

ci: gate main on spec validity and requirement visibility#114
thecodedrift wants to merge 3 commits into
mainfrom
ci/gate-openspec-validation

Conversation

@thecodedrift

Copy link
Copy Markdown
Member

Nothing in CI validated the specs, so spec rot accumulated silently and was only ever found by hand. This adds two checks to the Validate job.

Two checks, because the first is structurally blind

openspec validate --all --strict — repo-wide, deliberately. The failures this exists to catch live in specs a given PR does not touch, so a changed-files-only check would never surface them.

Every ### Requirement: sits under ## Requirements — a new .github/scripts/openspec-visibility.cjs, with a unit-test sibling picked up by the existing node --test .github/scripts/*.test.cjs step.

The second is not redundant. A second ## heading inside the requirements section ends it, and everything below stops being parsed as a requirement — not invalid, invisible. Measured before the check existed: infrastructure had 20 requirements with 1 visible and skills had 7 with 1, both passing --strict the whole time. A passing gate is an active claim that the spec was read, which makes a silently truncated spec worse than a red one.

The check nearly shipped with the bug it exists to catch

Fenced code blocks are skipped, because a ### Requirement: inside a fence is documentation about the format rather than a requirement.

Writing the test for the lost-opening-fence case — the real defect found in cli-help during #106 — exposed that a surviving closing fence opens a region to EOF, hiding the rest of the file from the check as well as the parser. hidden came back 0. An unclosed fence now fails on its own terms with its own message.

Both gates are green on day one

--strict reports 23 passed / 0 failed, and the visibility check reports all 23 specs ok. #106 cleared the backlog that previously blocked this. Turning a gate on red is how a check gets ignored.

Deliberate-failure proof: injecting a stray ## Stray Grouping into a copy of skills/spec.md exits 1 with 6 requirement(s) hidden by a '##' heading inside '## Requirements' (1 of 7 visible) — reproducing the historical 7→1 figure exactly.

Spec changes

Two edits to openspec/specs/infrastructure/spec.md, made directly rather than through a change proposal:

  • Added a requirement recording that CI validates the specs, with scenarios for each failure mode.
  • Corrected the trigger requirement. It claimed the workflow triggers on "pull requests targeting the main branch"; that stopped being true in 1c181e2, which removed the branches: filter after it silently stopped reaching stacked PRs.
  • Corrected the Node requirement — the spec said 22, ci.yml pins 24.

Worth stating the limit plainly: neither new check would have caught either correction. Both requirements were perfectly well-formed and simply false. This raises the floor from "unparseable" to "structurally sound" and says nothing about whether a spec describes reality.

Verification

pnpm lint clean · pnpm typecheck 1/1 · pnpm test 602 passing · openspec validate --all --strict 23/0 · node --test .github/scripts/*.test.cjs 124/0

No changeset: this touches CI, a workflow script, and a spec. Nothing ships, so the PR carries skip-changeset rather than a release note that would describe nothing.

Fixes #105

thecodedrift and others added 2 commits August 19, 2026 00:07
Nothing in CI validated `openspec/specs/`, so spec rot accumulated
silently. Add two checks to the Validate job.

`pnpm openspec validate --all --strict` runs repo-wide rather than over
changed files: the rot lives in the specs a PR does not touch.

`--strict` is structurally blind to a second class of failure. A `##`
heading inside `## Requirements` ends the section, and every
`### Requirement:` below it stops being a requirement to the parser —
not invalid, unread. `infrastructure` sat at 1 of 20 visible and
`skills` at 1 of 7, both green throughout. So a second check compares
requirements written against requirements the parser reaches.

Fenced content is skipped: a fenced `### Requirement:` documents the
format rather than declaring a requirement. The one edge that opens —
a lost opening fence leaving its closer dangling, which swallows the
rest of the file — is caught by failing on a fence still open at EOF.

Both checks pass on main today: 23 specs, 0 failures, 0 hidden.

Also correct the CI trigger requirement in the infrastructure spec. It
claimed pull requests targeting `main`; 1c181e2 removed that
`branches:` filter because it did not reliably reach stacked PRs.

Fixes #105

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
`ci.yml` pins `node-version: 24`; the spec required 22. Neither of the
checks this change adds would ever catch it — the requirement is
perfectly well-formed, it is just false. Worth noting as the limit of
the gate: it raises the floor from "unparseable" to "structurally
sound" and says nothing about whether a spec describes reality.

Found while adding the gate, and folded in here because it is one line
in the file this change already edits.
Copilot AI lite review requested due to automatic review settings August 19, 2026 08:00
@thecodedrift thecodedrift added the skip-changeset PR intentionally ships no release note (bypasses the changeset requirement) label Aug 19, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds CI enforcement to prevent OpenSpec drift by gating main on both strict OpenSpec validation and a structural “requirement visibility” check that detects truncated ## Requirements sections (a failure mode --strict can’t detect). It also updates the infrastructure spec to document the new CI gates and correct existing workflow requirements (trigger semantics and Node version).

Changes:

  • Extend the Validate job to run pnpm openspec validate --all --strict repo-wide and a new visibility checker script.
  • Add .github/scripts/openspec-visibility.cjs plus unit tests to detect hidden requirements caused by stray ## headings and to fail on unclosed code fences.
  • Update openspec/specs/infrastructure/spec.md to reflect CI trigger behavior, spec-validation requirements, and Node 24.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

File Description
openspec/specs/infrastructure/spec.md Documents CI triggers more precisely and adds requirements/scenarios for the new spec validation + visibility gates; updates Node requirement to 24.
.github/workflows/ci.yml Adds two new gating steps (strict OpenSpec validation + visibility check) to the Validate job.
.github/scripts/openspec-visibility.cjs New script to count total vs. parser-visible requirements per spec, ignoring fenced blocks and failing on unclosed fences.
.github/scripts/openspec-visibility.test.cjs New unit tests covering visible/hidden counting, fence handling, and main() error reporting.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread .github/scripts/openspec-visibility.test.cjs Outdated
@thecodedrift

Copy link
Copy Markdown
Member Author

@claude /review

@claude

claude Bot commented Aug 19, 2026

Copy link
Copy Markdown

Claude finished @thecodedrift's task in 5m 45s —— View job


Review: ci: gate main on spec validity and requirement visibility

Overall assessment: solid, well-tested change. No blocking issues — one plausible correctness gap and one minor test-hygiene note posted inline.

What I checked

  • .github/workflows/ci.yml: the two new Validate steps (pnpm openspec validate --all --strict, then node .github/scripts/openspec-visibility.cjs) are correctly wired — openspec script and @fission-ai/openspec dependency both exist in package.json, the new .test.cjs file is picked up by the existing node --test .github/scripts/*.test.cjs glob, and the job already has checkout/pnpm/node set up before these steps run.
  • .github/scripts/openspec-visibility.cjs: traced the fence-tracking and heading/requirement counting state machine line by line against the test suite's edge cases (nested/mismatched fence chars and lengths, lost-opening-fence-vs-dangling-closer, fenced example lines, bold lead-in grouping). The logic is internally consistent and each documented edge case has a matching test. findSpecs correctly assumes a flat openspec/specs/<capability>/spec.md layout, which matches the repo's actual structure (verified all 23 capability dirs are one level deep, matching the PR's "23 specs" figure). REPO_ROOT-based path resolution avoids any dependence on the CI step's cwd.
  • openspec/specs/infrastructure/spec.md: cross-checked the corrected requirements against the actual ci.yml — the pull_request trigger genuinely has no branches: filter and includes ready_for_review, and Node is genuinely pinned to 24. Both corrections are accurate, not just plausible-sounding.
  • Confirmed only the root CLAUDE.md / .conventions/* apply to the changed paths (no nested CLAUDE.md under .github/ or openspec/); didn't find a clear-cut, quotable violation of either style guide in this diff.
  • Did not run pnpm lint/typecheck/test/openspec validate myself — leaving that to CI per the review instructions.

Inline comments posted

  1. .github/scripts/openspec-visibility.cjs:93-100 (plausible correctness gap) — the ## Requirements section boundary check requires the heading to start at column 0 (line.startsWith("## ")). CommonMark tolerates up to 3 leading spaces before an ATX heading. If OpenSpec's own parser follows that leniency but this script doesn't, an indented stray ## heading would truncate the real Requirements section while this check still reports every requirement visible — reproducing, undetected, the exact silent-truncation bug this PR exists to catch. Worth confirming against how OpenSpec's parser actually recognizes headings; fenceOf already tolerates 0-3 leading spaces, so mirroring that for the heading check would close the gap defensively regardless.
  2. .github/scripts/openspec-visibility.test.cjs:158 (minor/already flagged by Copilot) — several tests mutate global console.error/console.log to capture output, restoring them in finally. Safe under node --test's default sequential execution, but a latent flakiness trap if concurrency is ever introduced for this file. main() already returns a structured { results, ok }; returning captured log/error lines (or accepting an injected logger) would avoid the global mutation entirely.

Neither issue blocks merge; the first is worth a quick confidence check against OpenSpec's actual heading parsing, the second is a nice-to-have.
· branch ci/gate-openspec-validation

Comment thread .github/scripts/openspec-visibility.cjs Outdated
Comment thread .github/scripts/openspec-visibility.test.cjs Outdated

@thecodedrift thecodedrift left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As an aside, this whole script may go away once we have Vale rules in place, designed to specifically handle this kind of stuff. It's worth noting that when we look at how deep this fix is

Or if we want to do this fix at all

Review asked whether the '## Requirements' boundary should tolerate up to
three leading spaces, per CommonMark. It should not: every heading pattern
in OpenSpec is anchored at column 0 (markdown-parser's /^(#{1,6})\s+(.+)$/,
requirement-blocks' /^##\s+Requirements\s*$/i and /^###\s*Requirement:/),
so an indented '##' is prose to the parser and widening the match would
report requirements hidden that are read fine.

Checking that did surface three real divergences, all in the direction that
matters — the check being laxer than the parser, so a hidden requirement
reads as visible:

- OpenSpec matches \s after the hashes, not a literal space, so a tab-led
  '##\tGrouping' truncates the section while 'startsWith("## ")' missed it.
- '###Requirement:' and '###  Requirement:' both parse, but only the
  single-space form was counted toward the total.
- The requirements heading is matched case-insensitively by the parser;
  '## requirements' would have reported every requirement hidden.

Also injects log/error into main() instead of reassigning the global console
in tests, per Copilot and Claude — safe today under node --test's sequential
default, a flakiness trap the moment anything here gains concurrency.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
@thecodedrift

Copy link
Copy Markdown
Member Author

Re: @claude[bot] — "Claude finished @thecodedrift's task in 5m 45s — Review: ci: gate main on spec validity and requirement visibility…"
#114 (comment)

Both inline findings are answered and resolved on their threads, and 019aa46 addresses them: the heading-leniency gap is declined (OpenSpec anchors every heading pattern at column 0, so tolerating leading spaces would report requirements hidden that the parser reads), but checking it surfaced three real divergences in the laxer direction — tab-after-##, loose ### Requirement: spacing, and case-insensitive ## Requirements — now matched and tested. The console-mutation note is fixed by injectable log/error on main().

— AI Coding Agent

@thecodedrift

Copy link
Copy Markdown
Member Author

Re: @thecodedrift — "As an aside, this whole script may go away once we have Vale rules in place, designed to specifically handle this kind of stuff…"

Noted, and worth keeping in view — but Vale would be a different check rather than the same one relocated. Vale reads prose against style rules; the thing this script asserts is agreement with OpenSpec's own parser (its heading regexes are now mirrored line for line, see 019aa46), i.e. "did the parser read the whole file". A Vale rule could enforce "no ## inside ## Requirements" as a convention, which is a good rule to have, and at that point this script becomes redundant and should go. Leaving it in for now since the gate is what makes the 20-of-21 and 7-of-1 cases loud; happy to drop it in the Vale change instead if you would rather not carry it.

— AI Coding Agent

@thecodedrift

Copy link
Copy Markdown
Member Author

@claude /review

@claude

claude Bot commented Aug 19, 2026

Copy link
Copy Markdown

Claude finished @thecodedrift's task in 4m 13s —— View job


Review: ci: gate main on spec validity and requirement visibility

Overall assessment: solid, well-tested change with good design reasoning (and good responsiveness to the earlier review round — 019aa46 closed three real gaps found while checking the leading-space question). One concrete but narrow-trigger correctness gap posted inline; the rest are non-blocking design/efficiency notes.

What I checked

  • Full diff of all 4 files vs. origin/main, including the prior review round (Copilot + earlier Claude pass) and the author's replies/fixes in 019aa46.
  • Hand-traced countRequirements's fence-tracking and heading/requirement counting state machine against the test suite's edge cases, including the trickier ones (lost-opening-fence, tilde-fence-contains-backtick-fence, fenced examples).
  • Verified findSpecs's flat-directory assumption against the real openspec/specs/ tree (all 23 capability dirs are one level deep — matches today).
  • Confirmed the ci.yml trigger config the spec now documents (no branches: filter, ready_for_review present) already existed on origin/main before this PR — the spec.md rewrite is prose catch-up, not new YAML.
  • Cross-checked the corrected requirements in infrastructure/spec.md (trigger semantics, Node 24) against actual ci.yml content — both accurate.

Inline comment posted

.github/scripts/openspec-visibility.cjs:79REQUIREMENT_HEADING is /^###\s*Requirement:/, but the file's own comment two lines above documents OpenSpec's actual pattern as /^###\s*Requirement:\s*(.+)\s*$/, which requires a non-empty title after the colon. A bare ### Requirement: heading with no title, sitting outside ## Requirements, is valid markdown OpenSpec's real parser doesn't treat as a requirement — but this script still counts it toward total, producing a false hidden > 0 and failing the gate on a spec that validates cleanly. Narrow trigger (needs a title-less heading specifically), but it cuts against the PR's own stated principle that the check is "only meaningful where it agrees with the parser," and isn't covered by the existing spacing tests. Details and a suggested fix (\s*\S) are on the inline thread.

Non-blocking notes (design/efficiency, not correctness bugs)

  • Shadow parser vs. reuse: openspec-visibility.cjs hand-mirrors three of @fission-ai/openspec's own regexes rather than importing the package (already a dependencies entry, currently only invoked as a CLI). Nothing forces the copy to track a future version bump of the real patterns — the whole point of this check is "do I agree with the parser," and there's no test importing the real parser to pin that agreement. Given the amount of care already spent getting these three regexes to match (see the PR's own note about the three divergences found and fixed), this is worth keeping in view as a maintenance risk, independent of whether the script itself later gets replaced by Vale (per the PR-comment discussion).
  • Step ordering in ci.yml: both new steps are placed after Lint/Typecheck/Build/Test. The visibility check needs only node:fs/node:path (no install at all), and openspec validate needs only the install step — both could run earlier to fail fast on a spec problem instead of waiting out the full build+test cycle. Minor, since this is a single-job workflow and the main cost is time-to-first-failure, not correctness.
  • Two independent research passes also looked for removed/altered behavior (nothing dropped in ci.yml — it's a pure append) and cross-file consistency (Node version is also stated as "22" in an archived change spec that findSpecs() doesn't scan by design — pre-existing, not something this PR's gate claims to cover, so not a new issue).

Nothing else risked being a security, performance, or test-adequacy issue — the fence-skip/fence-still-open interplay, the REPO_ROOT/displayPath path handling, and the deliberately-declined leading-space leniency (already justified and pinned by a test per the PR-comment thread) all check out.
· branch ci/gate-openspec-validation

*/
const REQUIREMENTS_HEADING = /^##\s+Requirements\s*$/i;
const SECTION_HEADING = /^##\s/;
const REQUIREMENT_HEADING = /^###\s*Requirement:/;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The block comment directly above (lines 56-59) cites OpenSpec's own requirement-heading pattern as /^###\s*Requirement:\s*(.+)\s*$/ — which requires at least one character of title text after the colon. The implemented REQUIREMENT_HEADING drops that suffix:

const REQUIREMENT_HEADING = /^###\s*Requirement:/;

Tracing both regexes by hand against a bare ### Requirement: line with nothing after the colon: the local pattern matches (nothing after Requirement: is required), but the cited upstream pattern does not (\s* can match empty, but (.+) then has no characters left to consume). Any variant with even one trailing character does match both, so the divergence is narrow — exactly the title-less case.

Failure scenario: a title-less ### Requirement: heading appearing outside ## Requirements (e.g. an incidental heading in a "Design" or prose section) is perfectly valid markdown that openspec validate --all --strict won't flag, since OpenSpec's own parser — per the pattern this file cites for itself — doesn't recognize it as a requirement at all. This script still increments total for it without incrementing visible, producing hidden > 0 and failing this new gate on a spec the real parser reads cleanly. Inside ## Requirements the gap is harmless (total and visible move together), so this only bites as a false-positive failure mode.

Given the PR's own framing — "this check is only meaningful where it agrees with the parser" — this seems worth closing, e.g. /^###\s*Requirement:\s*\S/. Not covered by any existing test in openspec-visibility.test.cjs (the spacing tests around line 110-123 cover missing/extra space before "Requirement", not a missing title).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

skip-changeset PR intentionally ships no release note (bypasses the changeset requirement)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CI: gate main on openspec validate --all --strict

2 participants