Skip to content

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

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

ci: gate main on spec validity and requirement visibility#114
thecodedrift wants to merge 2 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 on lines +154 to +168
const errors = [];
const realError = console.error;
const realLog = console.log;
console.error = (message) => errors.push(String(message));
console.log = () => {};
try {
assert.equal(main({ argv: [directory] }).ok, false);
assert.match(
errors.join("\n"),
/gamma[/\\]spec\.md: a code fence is still open/
);
} finally {
console.error = realError;
console.log = realLog;
rmSync(directory, { recursive: true, force: true });
@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 on lines +93 to +100
if (line.startsWith("## ")) {
inRequirements = line.trim() === REQUIREMENTS_HEADING;
} else if (line.startsWith(REQUIREMENT_PREFIX)) {
total += 1;
if (inRequirements) {
visible += 1;
}
}

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 ## Requirements boundary check requires the heading to be flush-left:

if (line.startsWith("## ")) {
  inRequirements = line.trim() === REQUIREMENTS_HEADING;

CommonMark (and most Markdown parsers, plausibly including OpenSpec's own) allow up to 3 leading spaces before an ATX heading and still treat it as a real heading. If a stray ## heading is ever written with 1–3 leading spaces inside a ## Requirements section (e.g. from a copy/paste or auto-indent), and OpenSpec's actual parser still treats it as heading that ends the section, this script won't: startsWith("## ") fails on the indented line, so inRequirements never flips to false, and every requirement below stays counted as visible.

That's the exact failure mode this check exists to catch (silently-hidden requirements, passing green) reproduced by a case this specific implementation can't see. Worth confirming against how OpenSpec's own markdown parser recognizes headings — if it also requires column-0, this is moot; if it follows CommonMark's leniency, there's a gap here. Might be worth widening the match to /^ {0,3}##\s/ (mirroring the leading-space tolerance already used in fenceOf) to close it defensively either way.

const realError = console.error;
const realLog = console.log;
console.error = (message) => errors.push(String(message));
console.log = () => {};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Minor, and Copilot already flagged this: these tests reassign the global console.error/console.log for the duration of the test, restoring them in finally. With top-level tests in a node --test file running sequentially by default this is safe today, but it's a latent flakiness trap — if concurrency is ever turned on for this file (or a future test is added with { concurrency: true }), one test's captured output can silently swallow or corrupt another's. Since main() already returns { results, ok }, consider having it optionally return the log/error lines it would have printed (or accept an injected logger) instead of relying on global mutation, so assertions don't depend on execution order.

@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

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