* test(#3412): failing-first suite for the pattern-construction seam Phase 1 of epic #3212 (ADR-3212 §1/§2/§7). Tests only — src/pattern.cts and eslint-rules/no-adhoc-regex-escape.cjs do not exist yet, so both suites fail with MODULE_NOT_FOUND, which is the intended RED. Locks the measured behavior rather than the assumed behavior: RegExp.escape hex-escapes the leading character of nearly every string ("abc" -> "\x61bc"), so the suite asserts match-equivalence against an inlined historical oracle (the implementation being deleted) rather than byte-equivalence of pattern text — 200 seeded fast-check runs plus a fixed corpus, 0 mismatches. Also locks the latent character-class range bug this phase fixes as a side effect: a hyphen-bearing value interpolated into [...] currently forms a real range and matches an unintended character; post-migration it must not. * chore(#3412): src/pattern.cts owns runtime-value regex construction Phase 1 of epic #3212 (ADR-3212 §1/§2/§6/§7). Adds the pattern seam delegating to the built-in RegExp.escape, deletes every hand-rolled copy, and raises the Node floor to the Active LTS line. The census was low, three times over. ADR-3212 counted 10 copies; a graph query found 12; the new lint rule — once live — found 27 more. The difference is that the census counted named helper FUNCTIONS while the rule counts the escape SHAPE, so inline .replace(<class>, '\$&') copies were never in scope. ADR §1's actual requirement is that no module outside the seam escapes a value for regex use, so all of them are, and CLAUDE.md's no-defer rule makes them this change's work. Fourth consecutive epic here whose copy count was low — the argument for ADR-3180 Amendment 3's "state N found by the guard" rule. Also corrected mid-implementation: the survey reported phase-id.cts's escapeRegex had 0 external importers. It had 8 production importers, making its removal a public-surface change to an ADR-2121-owned module and requiring an update to that ADR's locked-surface test. Blast radius revised Medium-High -> High. RegExp.escape is match-equivalent but NOT text-equivalent: it hex-escapes the leading char of nearly every string ("abc" -> "\x61bc"). Equivalence is proven by a seeded fast-check property test against the deleted implementation as oracle. It also fixes a latent bug: a hyphen-bearing value interpolated into a character class previously formed a real range and matched an unintended character. Node floor 22 -> 24 (RegExp.escape is Node 24+), across engines, .nvmrc, package-lock, 9 CI matrix entries, and 5 docs. The aggregate `required-tests` context is unchanged and no job was added or removed, so branch protection cannot be orphaned by the dropped lanes. Enforced by eslint-rules/no-adhoc-regex-escape.cjs (shape-matched, with structural provenance for reviewed pattern-fragment constants rather than a name heuristic) plus a whole-tree companion guard covering the directories ESLint's globs miss. * fix(#3412): close the _SOURCE guard evasion, correct two false claims Three findings from the orthogonal review pass, all fixed. 1. The ESLint rule's `_SOURCE` provenance fallback was pure identifier- name matching with no binding check, so `new RegExp(userInput_SOURCE)` — a function parameter — sailed past the guard. That is the same rename-evasion class issue #3410 documents, reopened by the very fallback meant to complement the structural check. Now bound to the identifier's actual binding kind: import, require-derived const, or module-scope const; parameters, `let`/`var`, and unresolvable bindings fail closed. Four RuleTester cases cover the evasion and prove the legitimate cross-module case still passes. 2. src/pattern.cts's own header carried the stale pre-correction counts (12 copies / 17 call sites) while CONTEXT.md and the design doc carried the corrected ones (~39 / ~44) — a self-contradiction inside the PR whose entire purpose is deleting divergent copies. Rewritten, preserving the durable lesson: a named-function census cannot see inline copies; only a shape-matching guard can. 3. The claim that all deleted copies threw TypeError on non-string was false. phase-id.cts's copy — the one with 8 external importers — did String(value).replace(...) and never threw. The seam's locked signature does not coerce, so this is a real, now-disclosed behavior change rather than the pure preservation the tests asserted. Audited all 32 invocations across the 8 importers and 6 in-file callers: every one is safe by construction (upstream truthy guard or a string-producing derivation), verified by runtime probe against the compiled modules rather than by TS compilation, which cannot see a runtime undefined. Corrected the false claim in both the test comment and the design doc, and added it to Known limits. * docs(#3412): add Changed changeset for the Node 24 floor The only user-visible break in this phase. The escape-behavior change is internal and match-equivalent, so it carries no user-facing note. * fix(#3412): resolve the seam's require graph in script fixtures and packaging Checkpoint 2 came back red with 90 failures on the node24 lane. Three distinct defects, all introduced by routing scripts/ through the new pattern seam, none reproducible by any local gate: 1. ~82 failures — tests/adr-index-gate.test.cjs and tests/removed-but-needed-lint.test.cjs copy a scripts/*.cjs into an mkdtemp fixture and spawn it there (necessary: those scripts resolve their scan root from __dirname/.., so running the real script would scan the real repo). Each harness hand-listed the dependencies to copy alongside. Adding require('../gsd-core/bin/lib/pattern.cjs') to gen-adr-index.cjs made both lists silently incomplete -> MODULE_NOT_FOUND, plus 17 downstream 'did not emit parseable JSON' failures from the same crash. Fixed as a class, not an instance: new tests/helpers/copy-script- fixture.cjs walks a script's transitive static relative-require graph and copies it, so dependencies are derived and never re-declared. It throws (naming the unbuilt artifact) instead of letting the child die with a bare MODULE_NOT_FOUND. Verified for all four seam-consuming scripts: gen-adr-index, lint-removed-but-needed, gen-loop-host- contract, sync-runtime-launcher. 2. 2 failures — scripts/ ships wholesale but eslint-rules/ does not, so the new scripts/lint-no-adhoc-regex-escape.cjs would be MODULE_NOT_FOUND in a published install (#2858 guard). Excluded from the tarball, matching the existing precedent for gen-emitted- baseline.cjs, which is excluded for the identical reason, and locked with a test modeled on that one. Confirmed against a real npm pack: 890 files, 0 from eslint-rules/, and gsd-core/bin/lib/pattern.cjs present (so the other four scripts' requires are legitimate). 3. 6 failures — tests/phase-id.test.cjs asserted the literal escaped source text ('0*29', 'PROJ-42'). RegExp.escape is match-equivalent to the retired hand-rolled escaper but NOT text-equivalent: it hex- escapes the leading character and all hyphens ('0*\x329', '\x50ROJ\x2d42'). Verified NOT a behavior change — 576 match decisions across all three real interpolation prefixes, zero divergence. Those tests now compile each source into the same heading regex src/roadmap.cts's searchPhaseInContent builds and assert what matches and what does not, including the 'i'-flag canonicalization the hex escape has to preserve. Re-pinning the new literals would have rebuilt the same brittleness one layer down. Adds a test for the property the escape exists for: a dot in '1.2' must not act as a wildcard. Also shares one definition of 'a require' between the packaging guard and the fixture copier, so the two cannot disagree about what they scan. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3412): refuse to copy a fixture dependency outside the fixture root copyScriptWithDeps resolved each relative require and joined the repo-relative result onto fixtureRoot. A require resolving OUTSIDE the repo yields a '../'-prefixed relative path, so path.join climbed out of the fixture and wrote into the surrounding temp dir (verified: repoRoot=/repo + depAbs=/etc/passwd wrote /tmp/etc/passwd). No script in the tree does this today, so this closes an available escape rather than an active one. Refuses via the existing unresolved- require path so the failure names the offending specifier. Covered by a negative proof that the guard fires and that nothing lands outside the fixture. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3412): parse requires instead of pattern-matching them; restore the foreign-prefix contract Applies all findings from the second orthogonal review round, re-run because real code changed after round 1. HIGH (security) — extractRequires stripped BLOCK comments before LINE comments, so a '//' comment containing '/*' opened a phantom block comment, and a '//' inside a string literal truncated the line. Both hid real requires: 'const u="http://x"; require("./real.cjs")' returned [], and four real requires in gsd-core/bin/gsd-tools.cjs were invisible. Replaced with a real AST parse via espree. This is ADR-3212's own Decision 4 — tokenizer-first for stateful grammars — applied to the case it describes; comment/string/regex nesting is exactly such a grammar, which is why the regex version was wrong. The function was moved byte-identical out of the #2858 packaging guard, so the bug PRE-DATES this branch and has been a live blind spot there: a shipped script could have required an unshipped path undetected. Fixing it makes that guard strictly stronger than on next. espree is promoted from a transitive eslint dependency to an explicit devDependency rather than relying on hoisting. The script parse attempt sets ecmaFeatures.globalReturn because Node wraps CommonJS bodies in a function, making a top-level return legal — scripts/check-coverage-gate .cjs relies on it, and without the flag the guard throws on a file it is supposed to scan. Verified 0 unparseable across all 324 .cjs/.js under scripts/, bin/, and gsd-core/bin/, and 0 new violations against a real npm pack, so the exact extractor does not newly fail the guard. MEDIUM (security) — the repo-containment check guarded dependencies but not the entry path. One escapesContainment predicate now guards both. LOW (security) — containment was lexical while fs follows symlinks, and a directory symlink could mint a fresh dedupe key per level. realpath now resolves both repoRoot and each dependency before the decision, and the realpath-derived path is the dedupe key. Destination layout still uses the original repo-relative path, so copied trees are unchanged. MAJOR (standards) — the round-1 behavioral rewrite of phase-id tests lost the foreign-prefix contract: every assertion was satisfied by an impl returning [A-Z]+\x2d42, i.e. ANY project code — the exact #3599 bug class the exact-source prevents. The literal assertions it replaced were catching this. Now asserts the compiled regex REJECTS a different prefix with the same number. MAJOR (standards) — the test hand-duplicated production's heading regex with no parity guard (CLAUDE.md's 'Generative Fix Divergence'). Removed the parallel surface instead of policing it: src/roadmap.cts exports buildPhaseHeadingRegex, searchPhaseInContent calls it, the test imports it. Byte-identical .source and .flags verified for both escaped forms. MINOR — '..foo' no longer false-flagged as an escape; the inverted spurious-vs-missing doc claim corrected; the dead allow-test-rule header removed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#3412): backfill changeset pr number to 3416 * fix(#3412): make the escape guard's own regex linear, reword an injection-scan collision Two CI failures on PR #3416, both in code this branch added. CodeQL js/redos (high) — REPLACE_CALL_RE's outer alternation let a bracket run be consumed EITHER by the character-class branch OR one character at a time by the trailing catch-all, so a failing match explored both parses of every pair. Measured on the real regex: n=26 -> 204ms, n=28 -> 791ms, n=30 -> 3475ms, a clean 2^n. This script scans repo source, so a file with a long bracket run after '.replace(/' would hang CI outright — a guard against undisciplined pattern construction was itself the worst pattern in the diff. Fixed the way ADR-3212 already prescribes: the catch-all branch now excludes '[' and ']' so a bracket can only be consumed by the class branch (this is what makes it linear), and every quantifier is bounded (the locked bounded-quantifiers decision) as a second line of defense. Now 0ms at n=2000. Disclosed coverage tradeoff, recorded at the constant: a regex literal with a BARE unescaped ']' outside a class is no longer matched by this backstop. No census shape has that form, and the AST rule remains the primary detector. Verified the guard did not go blind doing it: a real census-shape violation is still reported, and an allow-adhoc-regex-escape suppression comment is still honored. Regression test drives the exported findViolations on a 2000-repetition adversarial input and asserts the RESULT. It makes no wall-clock assertion — elapsed-time tests are forbidden — so a regression surfaces as a harness timeout, which is the correct signal. Prompt injection scan — 'must not act as a regex wildcard' in a test comment matched the scanner's jailbreak pattern act\s+as\s+(a|an|if| my). Reworded to 'behave as'. Deliberately NOT allowlisted: silencing a whole test file over one phrase would blunt the scanner permanently, and the comment has nothing to do with injection. Neither failure was reachable from the remote runner — CodeQL and the injection scan are not in that matrix, so the sha it passed was green and still wrong. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
71 KiB
Contributing to GSD Core
Getting Started
# Clone the repo
git clone https://github.com/open-gsd/gsd-core.git
cd gsd-core
# Activate the pinned Node version from .nvmrc
nvm use
# Validate your environment
npm run check:env
# Install dependencies (reproducible, lockfile-driven)
npm ci
# Run tests
npm test
npm ci is required over npm install. It installs exactly what package-lock.json
specifies and fails fast if the lockfile is out of sync — this is intentional.
docs/contributing/bootstrap.md is the source of truth for setup. See it for Node version managers other than nvm (fnm, asdf, mise), the environment validator, daily commands, and troubleshooting.
Types of Contributions
GSD accepts three types of contributions. Each type has a different process and a different bar for acceptance. Read this section before opening anything.
🐛 Fix (Bug Report)
A fix corrects something that is broken, crashes, produces wrong output, or behaves contrary to documented behavior.
Process:
- Open a Bug Report issue — fill it out completely.
- Wait for a maintainer to confirm it is a bug (label:
confirmed-bug). For obvious, reproducible bugs this is typically fast. - Fix it. Write a test that would have caught the bug.
- Open a PR using the Fix PR template — link the confirmed issue.
Rejection reasons: Not reproducible, works-as-designed, duplicate of an existing issue.
⚡ Enhancement
An enhancement improves an existing feature — better output, faster execution, cleaner UX, expanded edge-case handling. It does not add new commands, new workflows, or new concepts.
The bar: Enhancements must have a scoped written proposal approved by a maintainer before any code is written. A PR for an enhancement will be closed without review if the linked issue does not carry the approved-enhancement label.
Process:
- Open an Enhancement issue with the full proposal. The issue template requires: the problem being solved, the concrete benefit, the scope of changes, and alternatives considered.
- Wait for maintainer approval. A maintainer must label the issue
approved-enhancementbefore you write a single line of code. Do not open a PR against an unapproved enhancement issue — it will be closed. - Write the code. Keep the scope exactly as approved. If scope creep occurs, comment on the issue and get re-approval before continuing.
- Open a PR using the Enhancement PR template — link the approved issue.
Rejection reasons: Issue not labeled approved-enhancement, scope exceeds what was approved, no written proposal, duplicate of existing behavior.
✨ Feature
A feature adds something new — a new command, a new workflow, a new concept, a new integration. Features have the highest bar because they add permanent maintenance burden to a solo-developer tool maintained by a small team.
The bar: Features require a complete written specification approved by a maintainer before any code is written. A PR for a feature will be closed without review if the linked issue does not carry the approved-feature label. Incomplete specs are closed, not revised by maintainers.
Process:
- Discuss first — check Discussions to see if the idea has been raised. If it has and was declined, don't open a new issue.
- Open a Feature Request issue with the complete spec. The template requires: the solo-developer problem being solved, what is being added, full scope of affected files and systems, user stories, acceptance criteria, and assessment of maintenance burden.
- Wait for maintainer approval. A maintainer must label the issue
approved-featurebefore you write a single line of code. Approval is not guaranteed — GSD is intentionally lean and many valid ideas are declined because they conflict with the project's design philosophy. - Write the code. Implement exactly the approved spec. Changes to scope require re-approval.
- Open a PR using the Feature PR template — link the approved issue.
Rejection reasons: Issue not labeled approved-feature, spec is incomplete, scope exceeds what was approved, feature conflicts with GSD's solo-developer focus, maintenance burden too high.
📐 Proposing an ADR or PRD
An ADR (Architecture Decision Record) documents a significant architectural decision. A PRD (Product Requirements Document) captures the what and why of a feature before implementation. Both are governed by the same issue-first rule as everything else.
Process:
- Open an issue of the appropriate type (enhancement for an ADR revisiting an existing area, feature for a new architectural surface, chore for policy/docs decisions). Fill it out completely.
- Wait for maintainer approval. A maintainer must label the issue
approved-enhancement,approved-feature, or confirm the chore before any file is created. - The GitHub-assigned issue number becomes your filename prefix. Create the file on a branch named after the issue:
docs/adr/<issue#>-<slug>.mdfor ADRsdocs/prd/<issue#>-<slug>.mdfor PRDs- Branch:
docs/<issue#>-<slug>
- Open a PR using the appropriate template and close the issue with
Closes #<issue#>in the PR body.
One issue = one ADR-or-PRD = one PR. Do not batch multiple decisions into one file or one PR.
Do not compute a "next number" locally. Any PR that uses the legacy NNNN-* sequential pattern for a new ADR or PRD will be asked to rename the file to the <issue#>-<slug>.md format before merge.
Example: Issue #2264 was opened, approved, and its number became the prefix: docs/adr/2264-golden-parity-redesign.md.
Rejection reasons: Issue not approved before file was created, filename uses local-compute sequential number instead of issue#, multiple decisions bundled in one PR, file placed in wrong directory (docs/adr/ vs docs/prd/).
The Issue-First Rule — No Exceptions
No code before approval.
For fixes: open the issue, confirm it's a bug, then fix it.
For enhancements: open the issue, get approved-enhancement, then code.
For features: open the issue, get approved-feature, then code.
PRs that arrive without a properly-labeled linked issue are closed automatically. This is not a bureaucratic hurdle — it protects you from spending time on work that will be rejected, and it protects maintainers from reviewing code for changes that were never agreed to.
Where Do I Open My PR? (Branching Model)
GSD uses two long-lived branches: main (production, what's on npm @latest)
and next (integration for the upcoming release). Almost every PR targets
next. Full guide: docs/branching.md.
| Your branch | PR target | Notes |
|---|---|---|
feat/NNN-slug |
next |
Default for all new features |
fix/NNN-slug |
next |
Default for all bug fixes; ships in next minor or via hotfix cherry-pick |
chore/, docs/, refactor/, test/, perf/, ci/, revert/ |
next |
All routine work |
fix/critical-NNN-slug |
main |
Production-down emergencies only; auto-back-merges to next |
release/X.Y.0 |
main |
Created by release.yml — don't make these by hand |
hotfix/X.Y.Z |
main |
Created by release.yml (dispatch with a patch version X.Y.Z) — don't make these by hand |
| Stabilization PR for an in-flight release | release/X.Y.0 |
Fix a regression found during the RC cycle |
Day-to-day commands:
git fetch origin
git checkout next
git pull --ff-only origin next
git checkout -b fix/3187-config-corruption
# ... commit, push
gh pr create --base next --repo open-gsd/gsd-core
If you target the wrong branch by accident, the PR Target Validator
workflow will post a comment with the one-line fix (click "Edit" by the PR
title and change the base branch — no need to recreate the PR).
Why this matters: Under the old single-branch model, every PR rebased onto
main, which moved on every merge. next moves far less often — only when
another PR to next lands — so in practice you rebase much less.
But next does still require "up-to-date before merging". Branch
protection has required_status_checks.strict = true; check it yourself with
gh api repos/open-gsd/gsd-core/branches/next/protection --jq '.required_status_checks.strict'.
If another PR lands while yours is open, yours goes BEHIND and must be
rebased before it can merge.
Budget for that, because the rebase is not free here: it changes your HEAD sha, which invalidates the sha-bound pass marker the push gate reads, so a rebase means re-running the full remote verification and another CI cycle before the gate clears again. Rebase last — immediately before you push for review — rather than paying for a verification you are about to discard.
Pull Request Guidelines
Architecture & Domain Standards (Maintainer-Defined)
The following files are maintainer-owned coding standards and must be treated as canonical when contributing:
CONTEXT.md— domain language and module naming standardsdocs/adr/— Architecture Decision Records (ADRs) for accepted architectural decisions
Full contributor requirements — including CONTEXT.md format, ADR governance, and AI-agent-assisted work standards — are in docs/contributor-standards.md.
Contributor requirements (summary):
- Read
CONTEXT.mdbefore naming or refactoring modules/interfaces/seams. - Use
CONTEXT.mdvocabulary consistently in code comments, tests, issue/PR text, and docs for the touched area. - Check relevant ADRs in
docs/adr/before proposing or implementing architectural changes. - If a change intentionally revisits an ADR decision, call it out explicitly in the linked issue and PR rationale.
- Do not rewrite maintainer intent in
CONTEXT.md/ADRs as part of drive-by cleanup; propose focused updates tied to approved scope. - If using an AI assistant, prompt it to read
CONTEXT.mdand the relevant ADRs before writing any code or docs, and verify it used the correct vocabulary before opening the PR.
Every PR must link to an approved issue. PRs without a linked issue are closed without review, no exceptions.
- No draft PRs — draft PRs are automatically closed. Only open a PR when it is complete, tested, and ready for review. If your work is not finished, keep it on your local branch until it is.
- Use the correct PR template — there are separate templates for Fix, Enhancement, and Feature. Using the wrong template or using the default template for a feature is a rejection reason.
- Link with a closing keyword — use
Closes #123,Fixes #123, orResolves #123in the PR body. The CI check will fail and the PR will be auto-closed if no valid issue reference is found.- Test-only and docs-only follow-up PRs may reference without closing. If your PR is documentation or regression coverage only — say, a repo-wide guard for a fix that already shipped — and there is no open issue for it to close, use a non-closing reference instead:
Refs #123.Ref,Refs,References,Relates to,Related to, andFollow-up toare all accepted in that position. Do not write a closing keyword against an already-closed issue to satisfy the check; on merge it closes nothing, and it trains readers to treat closing keywords as decorative. - Qualifying diff shape: every changed file must be under
tests/, underdocs/, or a root-level*.md(README.md,CONTRIBUTING.md, …). This mirrors the doc-only classification the push gate already uses, and it is deliberately root-only — markdown under a subdirectory (gsd-core/workflows/*.md,agents/*.md,commands/**/*.md) is runtime-loaded text, not documentation, so it still requires a closing keyword.CHANGELOG.mdis excluded too: edit it through a.changeset/fragment, never directly. - This weaker form is accepted only for that diff shape. A PR touching anything else still needs a closing keyword, and a PR with no issue reference at all still fails. On a very large PR (more than 100 changed files) the check cannot confirm the diff shape and falls back to requiring a closing keyword.
- Test-only and docs-only follow-up PRs may reference without closing. If your PR is documentation or regression coverage only — say, a repo-wide guard for a fix that already shipped — and there is no open issue for it to close, use a non-closing reference instead:
- One concern per PR — bug fixes, enhancements, and features must be separate PRs
- No drive-by formatting — don't reformat code unrelated to your change
- Don't bundle test-fixture updates into
docs:or unrelated commits — when a production change makes an existing test assertion stale, the test correction MUST land as its owntest:(orfix:) commit, not bundled into adocs:commit that also updates the explanation. The release-sdk hotfix cherry-pick filter routes by commit-subject prefix (fix:,chore:,test:); a test-fixture correction packed under adocs:prefix is invisible to the picker and ships a half-state to the hotfix branch — production code changed, test assertion stale. v1.42.3 hit this exact mode (#3621). The fix is upstream: keep the test-fixture commit separate. - CI must pass — all configured matrix jobs must be green. Node 24 is the compatibility floor and primary target; Node 26 compatibility must be preserved for code and tests even when a Node 26 CI lane is not yet available.
- Scope matches the approved issue — if your PR does more than what the issue describes, the extra changes will be asked to be removed or moved to a new issue
CHANGELOG Entries — Drop a Fragment
Do not edit CHANGELOG.md directly. Two PRs that both append to a ### Fixed block always conflict on merge — git can't pick a serialization order without a human. Instead, every PR with user-facing changes drops a fragment file in .changeset/.
npm run changeset -- --type Fixed --pr <YOUR_PR_NUMBER> \
--body "**\`/gsd-foo\` no longer drops trailing slashes** — explain the user-visible change."
This writes .changeset/<adjective>-<noun>-<noun>.md. Three random words → concurrent PRs never collide. Allowed type: values follow Keep a Changelog: Added, Changed, Deprecated, Removed, Fixed, Security.
Fragments are consolidated into CHANGELOG.md at release time by the release workflow. See .changeset/README.md for the format spec and #2975 for the rationale.
CI enforcement: the Changeset Required workflow (scripts/changeset/lint.cjs) fails any PR that touches bin/, gsd-core/, src/, agents/, commands/, hooks/, or sdk/src/ without a .changeset/*.md fragment. (src/ is the TypeScript source of truth compiled into gsd-core/bin/lib/*.cjs, so editing it is a user-facing change even though the generated .cjs is gitignored and never appears in the diff.)
Running it locally. The lint derives its changed-file set from
GITHUB_BASE_REF, which only CI sets.node scripts/changeset/lint.cjson a developer machine therefore does not evaluate your branch and can report success on a PR that CI will fail. Pass the base explicitly to reproduce the CI result:GITHUB_BASE_REF=next node scripts/changeset/lint.cjs ``` The gate also **validates the content** of every changed fragment: a fragment whose frontmatter does not parse (e.g. a `pr: 0` placeholder that was never backfilled to the real PR number) fails the gate with `fail_invalid_fragment`, naming the offending file. This stops a malformed fragment from merging to `next` and only detonating later in the release job's CHANGELOG render.
Opt-out: PRs with no user-facing impact (test refactors, lint config changes, CI tweaks, formatting-only changes) can add the no-changelog label. The lint honors it. When unsure whether a change is user-facing, add the fragment.
Release notes formatting
GitHub release notes are generated automatically. The release and hotfix
workflows first create the release with gh release create --generate-notes,
then run scripts/release-notes/format-github-release-notes.cjs --apply to
rewrite the body into the project's curated format: an Install block,
followed by What's Changed grouped into Feature / Enhancement /
Fix sections (classified by each PR's conventional-commit title prefix —
feat → Feature, fix → Fix, non-user-facing types test/chore/ci/docs/refactor/perf/revert → omitted from the user-facing notes, everything else → Enhancement), then
New Contributors and the Full Changelog link.
To re-format an existing release by hand (e.g. backfilling an older release):
node scripts/release-notes/format-github-release-notes.cjs \
--tag vX.Y.Z --repo open-gsd/gsd-core --apply
Omit --apply to print the reformatted body to stdout for review without
publishing.
PR title convention (enforced at open time)
Because the changelog is built from PR titles, your PR title must follow:
type(#<issue>): short summary
- Start with the type —
feat,fix, or any other conventional type (chore,docs,refactor, …). No leading tags or prefixes: a title like[security] fix(config): …defeats the^fixbucket anchor and silently files the entry under the wrong changelog section. - Put the linked issue ref in the scope —
(#<digits>). This is what renders as a link to the issue in the changelog line.fix(core): …buckets correctly but produces a changelog entry with no issue link. - A breaking-change marker is fine:
feat(#42)!: ….
Examples: fix(#1542): roadmap rollback, feat(#39): milestone-prefixed phase IDs,
enhance(#1549): add PR-title validator.
CI enforcement: pr-title-validator.yml checks the title on open/edit and
fails with the required format if it doesn't conform. It reuses the same matcher
the changelog classifier uses (scripts/release-notes/conventional-title.cjs), so a title
that passes the check is guaranteed to bucket and link correctly. Fix a flagged
title by editing it in place — the check re-runs on edit, no need to recreate
the PR.
Documentation Updates — Update the Relevant Docs
If your PR adds, changes, deprecates, or removes user-visible behavior, you must update the relevant documentation in docs/. CI will fail any PR whose changeset fragment is typed Added, Changed, Deprecated, or Removed without also modifying at least one file under docs/ (#3213).
Fixed and Security fragments do not trigger this lint — bug fixes restore documented behavior, they do not introduce new behavior to document. (Edit the docs anyway if a fix corrects something the docs got wrong.)
Which docs to update
| Change type | Required doc updates |
|---|---|
| New command or flag | docs/COMMANDS.md, docs/FEATURES.md |
| Changed command behavior or output | docs/USER-GUIDE.md, docs/COMMANDS.md |
| Configuration / schema change | docs/CONFIGURATION.md |
| Architectural change | docs/ARCHITECTURE.md, docs/adr/ |
| Agent or skill change | docs/AGENTS.md |
| Removed command, flag, or workflow | All docs that referenced it |
Language policy
All content in docs/ and the root README.md must be written in English. English is the canonical source. The translated READMEs (README.pt-BR.md, README.zh-CN.md, README.ja-JP.md, README.ko-KR.md) are community-maintained translations and do not need to be updated by every PR.
CI enforcement
The Docs Required workflow (scripts/lint-docs-required.cjs) reads the changeset fragments touched in the PR diff. If any has type Added / Changed / Deprecated / Removed, it requires at least one file under docs/ to also appear in the diff.
Opt-outs (with paper trail)
When a change genuinely has no user-facing documentation impact (infrastructure rewrite, internal refactor, test-only addition, CI fix), use one of:
- Label: add the
no-docslabel to the PR. Leave a comment explaining why no docs update was needed. - Per-fragment marker: add
<!-- docs-exempt: <reason> -->on its own line inside the body of each triggering changeset fragment (typically at the end). The reason is required and must be non-empty — a bare<!-- docs-exempt -->or<!-- docs-exempt: -->is rejected (no audit trail = no exemption). The marker is extracted at parse time byscripts/changeset/parse.cjsand stripped from the body before the CHANGELOG.md and GitHub release-notes serializers see it — it leaves a paper trail in the source fragment without leaking into published release notes. Inline mentions of the marker syntax (e.g. inside backticks) are intentionally ignored; the parser only acts on a marker that occupies its own line. Both routes leave a paper trail; the label is global, the marker is per-fragment for mixed PRs.
When unsure whether a change is user-facing, update the docs.
Testing Standards
All tests use Node.js built-in test runner (node:test) and assertion library (node:assert). Do not use Jest, Mocha, Chai, or any external test framework.
Suite grouping. Tests live in named suites (
unit,integration,install,security,slow) selected by filename suffix: a file namedfoo.security.test.cjsbelongs to thesecuritysuite; a file with no suffix (foo.test.cjs) belongs tounit. See docs/TESTING-SUITES.md for the full policy, CI matrix, and per-suite scripts (npm run test:unit,npm run test:security,npm run test:coverage:unit, …). Defaultnpm teststill runs every test — backwards compatible.
Required Imports
const { describe, it, test, beforeEach, afterEach, before, after, mock } = require('node:test');
const assert = require('node:assert/strict');
Setup and Cleanup
There are two approved cleanup patterns. Choose the one that fits the situation.
Pattern 1 — Shared fixtures (beforeEach/afterEach): Use when all tests in a describe block share identical setup and teardown. This is the most common case.
// GOOD — shared setup/teardown with hooks
describe('my feature', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('does the thing', () => {
assert.strictEqual(result, expected);
});
});
Pattern 2 — Per-test cleanup (t.after()): Use when individual tests require unique teardown that differs from other tests in the same block.
// GOOD — per-test cleanup when each test needs different teardown
test('does the thing with a custom setup', (t) => {
const tmpDir = createTempProject('custom-prefix');
t.after(() => cleanup(tmpDir));
assert.strictEqual(result, expected);
});
Never use try/finally inside test bodies. It is verbose, masks test failures, and is not an approved pattern in this project.
// BAD — try/finally inside a test body
test('does the thing', () => {
const tmpDir = createTempProject();
try {
assert.strictEqual(result, expected);
} finally {
cleanup(tmpDir); // masks failures — don't do this
}
});
try/finallyis only permitted inside standalone utility or helper functions that have no access to test context.
Use Centralized Test Helpers
Import helpers from tests/helpers.cjs instead of inlining temp directory creation:
const { createTempProject, createTempGitProject, createTempDir, cleanup, runGsdTools } = require('./helpers.cjs');
| Helper | Creates | Use When |
|---|---|---|
createTempProject(prefix?) |
tmpDir with .planning/phases/ |
Testing GSD tools that need planning structure |
createTempGitProject(prefix?) |
Same + git init + initial commit | Testing git-dependent features |
createTempDir(prefix?) |
Bare temp directory | Testing features that don't need .planning/ |
cleanup(tmpDir) |
Removes directory recursively | Always use in afterEach |
runGsdTools(args, cwd, env?) |
Executes gsd-tools.cjs | Testing CLI commands |
Spawning a subprocess: use the process seam
Anything that shells out goes through tests/helpers/process-seam.cjs — never a hand-rolled
spawnSync/execFileSync in your suite.
const { runNode, runGit, runHook, OUTCOME } = require('./helpers/process-seam.cjs');
const r = runHook(HOOK_PATH, [], { input: JSON.stringify(payload), timeoutMs: 5000 });
assert.equal(r.outcome, OUTCOME.EXITED);
assert.equal(r.exitCode, 0);
| Primitive | Spawns |
|---|---|
runNode(argv, opts) |
process.execPath |
runGit(argv, opts) |
git |
runHook(scriptPath, argv, opts) |
opts.interpreter (default process.execPath; pass 'bash' for a shell script) |
opts: { cwd, env, input, timeoutMs, killSignal, interpreter }.
Every call returns the same discriminated union — { outcome, exitCode, stdout, stderr, timedOut, signal, killed, code } — and never throws for a child's exit code, a timeout, a buffer
overflow, or a spawn failure. All four are data, so you assert on them:
assert.equal(r.outcome, OUTCOME.TIMED_OUT);
assert.equal(r.timedOut, true);
Two rules the seam enforces for you:
- Every call is timeout-bounded.
timeoutMsdefaults to 60s; there is no unbounded path. An unbounded subprocess is an indefinite hang, and it is how macOS CI silently stops reporting. outcomedistinguishes cases that look identical. A timeout and amaxBufferoverflow both reportexitCode: nullandsignal: 'SIGTERM', differing only incode(ETIMEDOUTvsENOBUFS). Branch onoutcome, never onsignal.
The seam is not a fault-injection surface — it cannot tell an injected timeout from a genuine
bench OOM. Inject faults in-process through a module's deps parameter instead.
Per-suite wrappers are still expected and encouraged: bind your fixture (cwd, env, payload) in a local helper and delegate the spawn to the seam.
Class-norm timeouts live in tests/helpers/timeouts.cjs — PROBE_TIMEOUT_MS,
GIT_TIMEOUT_MS, BUILD_TIMEOUT_MS, INSTALL_TIMEOUT_MS. These describe how long a whole CLASS
of subprocess call takes (a CLI probe, git plumbing on a fixture repo, a hooks build, a full
bin/install.js run), not a single suite's preference, so import them rather than re-declaring the
same literal with the same comment in yet another file. Only write a local constant when a site
genuinely differs from its class (a real tsc compile, a regen:derived run, ...) — and give that
local constant its own justifying comment explaining why it departs from the norm.
When you want git to throw: gitOrThrow
runGit never throws — that is the whole point of it. But execSync and execFileSync do
throw on a non-zero exit, and a lot of fixture setup relies on that: git commit failing should
stop the test right there, not hand back an empty string that produces a baffling assertion failure
twenty lines later.
For that case use tests/helpers/git-fixture.cjs:
const { gitOrThrow } = require('./helpers/git-fixture.cjs');
gitOrThrow(['init', '-b', 'main'], { cwd: dir });
gitOrThrow(['commit', '-m', 'seed'], { cwd: dir }); // throws if git exits non-zero
const branch = gitOrThrow(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: dir }).trim();
It returns stdout as a string on success. On any non-EXITED outcome, or a non-zero exit, it
throws an Error carrying status, exitCode, stdout, stderr, signal, timedOut and
outcome as own properties. status and exitCode are deliberate aliases: status is what the
legacy execSync idiom reads (catch (err) { assert.equal(err.status, 1) }), so a migrated call
site keeps working.
| You want | Use |
|---|---|
Every outcome as data; you branch on outcome |
runGit |
| Fixture setup that must abort loudly on failure | gitOrThrow |
process-seam.cjs itself is untouched by this — it still never throws.
If your per-suite wrapper spawns something that is not git — a node CLI via runNode, a bash
snippet via runHook — and its callers depend on a throw, call throwIfFailed(result, displayName)
directly instead of hand-rolling the same outcome !== EXITED || exitCode !== 0 check. gitOrThrow
is itself just throwIfFailed bound to runGit, so every thrown error — git or not — carries the
same status/exitCode/stdout/stderr/signal/timedOut/outcome shape:
const { throwIfFailed } = require('./helpers/git-fixture.cjs');
const r = runNode([BUILD_SCRIPT], { timeoutMs: BUILD_TIMEOUT_MS });
throwIfFailed(r, 'build-hooks.js (before install tests)');
When you want the legacy shape without a throw: toLegacyResult
Some call sites never wanted a throw in the first place — they already branch on exit status as
data, reading .status/.stdout/.stderr off the result themselves. Those still need the seam's
exitCode renamed to the legacy status field their assertions expect. Use toLegacyResult
instead of hand-rolling the three-line mapping — ~8 test files did exactly that independently
before this export existed (#3147):
const { toLegacyResult } = require('./helpers/git-fixture.cjs');
function runLint(args = []) {
const r = runNode([LINT_SCRIPT, ...args], { timeoutMs: PROBE_TIMEOUT_MS });
return toLegacyResult(r); // { status, stdout, stderr }
}
It is a bare mapping and nothing more. If your call site needs an extra field beyond that shape (a
parsed-JSON body, a fixture-specific path alongside the result), compose it rather than extending
the helper: { ...toLegacyResult(result), extra }. And if your site's return shape genuinely
diverges from { status, stdout, stderr } — e.g. it substitutes a parsed report object for raw
stdout — leave it as its own local mapping; forcing every result-reshaping helper onto one shared
function is the same drift toLegacyResult exists to prevent, just in the other direction.
The lint rule that enforces it
local/no-unbounded-spawn (eslint-rules/no-unbounded-spawn.cjs) fails any spawnSync,
execFileSync or execSync under tests/ that is not timeout-bounded. It resolves renamed
destructures (const { execSync: exec } = require('node:child_process')) and chained requires
(require('node:child_process').execSync(...)), so renaming your way around it does not work.
Two things it deliberately rejects, because both look bounded and are not:
timeout: 0— Node reads zero as no timeout.timeout: 999999999— anything above the 600000 ms ceiling is effectively unbounded. Size the number to what the command actually runs and say why in a comment.
A non-literal value (timeout: GIT_TIMEOUT_MS) is trusted — that is the shape you should be
writing.
When a call genuinely needs more than the 600000 ms ceiling — a full installer run, a build plus
generators — the escape is an inline marker comment, exactly the // allow-test-rule: <reason>
idiom above:
// allow-spawn-timeout-ceiling: regen:derived chains a full build plus eight generators
timeout: 900000,
The reason is required and must be non-empty; a bare // allow-spawn-timeout-ceiling: (or one
with only whitespace after the colon) is not an audit trail and still reports timeoutTooLarge.
The marker binds only to the call it decorates — either the line immediately above it, or
anywhere inside that call's own source range — never to the rest of the file. Critically, the
escape only ever raises the ceiling for a call that already resolves to a numeric timeout: it
never waives the requirement for a bound. A marked call with no timeout at all still reports
unboundedSpawn.
There is no allowlist. eslint-rules/no-unbounded-spawn.allowlist.json grandfathered files that
predated the rule; the epic that introduced it (#3064) migrated every site across four waves and
deleted the file in its terminal wave (#3148), so local/no-unbounded-spawn now runs with no
exemption surface across tests/**. There is no file to add an entry to — fix the timeout at
the call site instead. The only sanctioned escapes are an explicit timeout on a raw spawn (for a
call shape the process seam cannot express, e.g. a shell: true invocation for npm.cmd on
Windows, or stdio redirection to a real fd) and the // allow-spawn-timeout-ceiling: <reason>
marker above for a bound over the 600000 ms ceiling. Never reach for eslint-disable on this rule
— with the allowlist gone, that is the only remaining way to silence it, and a test asserts that no
such comment exists anywhere under tests/.
Test Structure
describe('featureName', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
// Additional setup specific to this suite
});
afterEach(() => {
cleanup(tmpDir);
});
test('handles normal case', () => {
// Arrange
// Act
// Assert
});
test('handles edge case', () => {
// ...
});
describe('sub-feature', () => {
// Nested describes can have their own hooks
beforeEach(() => {
// Additional setup for sub-feature
});
test('sub-feature works', () => {
// ...
});
});
});
Fixture Data Formatting
Template literals inside test blocks inherit indentation from the surrounding code. This can introduce unexpected leading whitespace that breaks regex anchors and string matching. Construct multi-line fixture strings using array join() instead:
// GOOD — no indentation bleed
const content = [
'line one',
'line two',
'line three',
].join('\n');
// BAD — template literal inherits surrounding indentation
const content = `
line one
line two
line three
`;
QA Matrix Requirements
Happy-path tests are not enough for code that accepts user input, reads project files, writes to disk, shells out, generates artifacts, or builds prompts. New tests for those areas must include adversarial inputs and negative proof that unsafe behavior did not happen.
See TEST-EXAMPLES.md for concrete demo tests that show these requirements in practice.
Standing rule for error/fallback branches: feeding an adversarial input is not sufficient on its own — if the code degrades permissively instead of throwing, the test must assert the specific degraded verdict, not just that the call survived. See TESTING-STANDARDS.md — "Standing rule: assert the degraded verdict".
Use this matrix when it applies to the changed surface:
- Happy path
- Missing input
- Empty input
- Whitespace-only input
- Malformed input
- Out-of-range input
- Duplicate or conflicting input
- Hostile input
- Filesystem failure
- Concurrency or retry
- Cross-platform path/newline behavior
- Regression fixture from the linked issue
You do not need all twelve cases for every PR. You do need to cover the cases that match the risk of the touched code. If a case is not applicable, the PR should make that obvious from the issue scope or test rationale.
CLI and command routing
Changes to CLI parsing, command dispatch, query dispatch, command routers, gsd-tools, or gsd-sdk must include a negative input matrix for the affected command family.
Required cases where relevant:
- Missing required arguments
- Empty strings, for example
--phase "" - Whitespace-only values
- Duplicate flags, for example
--phase 1 --phase 2 - Conflicting flags, for example
--json --raw - Malformed assignments, for example
--phase=and--phase==1 - Unknown subcommands at the touched command depth
- Values that look like flags, for example
--name --weird - Very long values and Unicode values
- Shell metacharacters in values, for example
;,&&,$(), backticks, and quotes
CLI tests must assert on the full command contract:
- Exit status
- Structured
--jsonresult when the command supports JSON - Filesystem mutation or absence of mutation
- No stack trace in non-debug failure output
- No shell interpolation of attacker-controlled values
Prefer spawnSync(process.execPath, [scriptPath, ...args], { cwd, encoding: 'utf8' }) or execFileSync() with argv arrays. Do not use shell strings for tests that contain hostile values.
Parser and project-file inputs
Changes to markdown, TOML, frontmatter, roadmap, phase, state, config, or schema parsing must include adversarial fixtures. Put reusable fixtures under tests/fixtures/adversarial/ with a directory that names the input type, such as roadmap/, frontmatter/, config/, toml/, or planning-state/.
Required cases where relevant:
- Malformed frontmatter
- Duplicate keys
- Mixed CRLF/LF newlines
- Unclosed or nested fenced code blocks
- Headings inside fenced code blocks
- Unicode headings
- Repeated or decimal phase IDs
- Path traversal-like names such as
../../x - Null bytes or replacement characters
- Huge but bounded files
- TOML duplicate tables or trailing garbage
- Empty arrays vs missing arrays
- Scalars where arrays are expected, and objects where strings are expected
Property-style parser tests are encouraged for high-risk parsers. They must be deterministic: pin the seed, bound the iteration count, and print replay data on failure.
Fixture provenance (#2371)
A gate's fixtures may not be derived from the gate's own writer, grammar, or docstring examples. A negative fixture must come from a source that does not know the gate exists.
This is stricter than the adversarial-input rule above and exists because of it: tests/fixtures/adversarial/ covers hostile input, but a fixture written by the parser's own author — even a deliberately "realistic" one — is still drawn from the author's mental model of the format. It can only ever confirm what the author already believed, never surface what they didn't anticipate. A property-test generator has the same failure mode one level up: seeding the generator from the writer/render function that produces the same format makes the document shape a constant, so the property can never explore a document the writer wouldn't produce (see the document-shaped vs. writer-seeded property tests in tests/api-coverage.test.cjs for a worked example — the writer-seeded one cannot fail against a decoy table; the document-shaped one can).
For a gate whose fixtures come from real user reports, put them under tests/fixtures/representative/<gate>/ with a MANIFEST.json labeling each fixture's source issue and expected gate verdict, and drive them through the gate's real CLI entrypoint (gate-verdict altitude), not the parser function in isolation — see tests/fixtures/representative/README.md and tests/representative-corpus.test.cjs. If the gate is not yet fixed, do not mark the assertion { todo: true } and do not skip it: this repo's test-runner (gsd-test / gsd-test-runner) has no concept of node:test's todo option — its JSONL result parser only recognizes kind: "pass" | "fail", so a thrown todo-marked test is still counted as a real failure and blocks the push gate. Instead record BOTH the correct target verdict (expected*) and the exact current observed verdict (currentBuggyOutput) in the manifest, and assert against currentBuggyOutput — an honest, non-vacuous characterization of today's known-broken behavior that passes today and breaks loudly the moment the real fix changes the observed output, forcing the assertion to be flipped to expected*.
Filesystem writes and installers
Changes to install/uninstall flows, generated artifact writers, state/config writers, worktree safety, or any code that writes under .planning, runtime config dirs, .claude, .codex, hooks, or generated files must include fault-injection coverage where the seam allows it.
Required cases where relevant:
- Missing parent directory
- Target path exists as a file instead of a directory
- Read-only target directory
- Broken symlink
- Symlink escaping the intended root
- Paths with spaces, Unicode, or newlines
- Partial write failure
- Rename failure
- Concurrent deletion or write collision
- Temp-file cleanup after failure
Use node:test mocks such as mock.method() for fs.writeFileSync, fs.renameSync, fs.mkdirSync, fs.rmSync, and subprocess seams when the production code exposes a seam. Restore mocks with test hooks or t.after().
Security and prompt-injection surfaces
Changes that read prompts, plans, markdown, agent instructions, shell command projections, workstream/project names, or user-controlled files must treat those inputs as hostile.
Required cases where relevant:
- Fake instruction tags, for example
<instructions>ignore previous</instructions> - Heredoc breakouts
- Shell command substitution payloads
- Path traversal through project or workstream values
- Malicious markdown links
- Fake frontmatter fields that try to override intent
- Secret-looking values in inputs, logs, stdout, stderr, and thrown errors
- Environment variables with fake tokens to prove redaction
Security tests must assert both the positive guard behavior and the negative proof: no path escape, no command execution, no leaked token, no untrusted content promoted to instructions.
Generated files and parity
Changes to generators, generated .cjs/.ts files, command manifests, aliases, hooks, or SDK/runtime parity must test bad input and runtime parity, not only freshness.
Required cases where relevant:
- Missing source command
- Malformed command frontmatter
- Duplicate command names or aliases
- Partial generator output
- Generator crash halfway through
- Manual edits to generated files
- Stale generated file with valid timestamp but wrong content
- Runtime
.cjsand SDK.tsgenerated surfaces disagree
Generator tests should run in temp fixtures and assert atomic output behavior. Do not mutate production generated files except in explicit freshness checks.
Prohibited: Source-Grep Tests
Never read source-code .cjs files with readFileSync to assert that strings exist within them. This is source-grep theater: it proves a literal is present in a file, not that the feature works at runtime.
// BAD — source-grep theater
const configSrc = fs.readFileSync(
path.join(GSD_ROOT, 'gsd-core', 'bin', 'lib', 'config-schema.cjs'), 'utf-8'
);
assert.ok(
configSrc.includes("'workflow.plan_bounce'"),
'VALID_CONFIG_KEYS should contain workflow.plan_bounce'
);
This test passes even if workflow.plan_bounce is present but misspelled in the schema, removed from the validation path, or moved to a different file under a different name. It survives every behavioral regression and fails only on trivial renames.
The correct pattern for config key tests — use the CLI:
// GOOD — behavioral test via the CLI
test('config-set accepts workflow.plan_bounce', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const result = runGsdTools('config-set workflow.plan_bounce true', tmpDir);
assert.ok(result.success, `config-set should accept workflow.plan_bounce: ${result.error}`);
const configPath = path.join(tmpDir, '.planning', 'config.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
assert.strictEqual(config.workflow?.plan_bounce, true, 'value must be persisted');
});
This single test covers key registration in VALID_CONFIG_KEYS, the key's namespace resolution in KNOWN_TOP_LEVEL, and value persistence — all behaviors that the source-grep test could not touch.
Why this pattern broke at scale: Commit 990c3e64 in this repo updated 5 source-grep tests in one pass when VALID_CONFIG_KEYS moved between files. Zero of those tests were testing behavior. If they had been behavioral tests, the migration would have been invisible.
CI enforcement: The local/no-source-grep ESLint rule (eslint-rules/no-source-grep.cjs, wired in eslint.config.mjs) detects violations. Any test file that calls readFileSync on a .cjs path in a source directory without the exemption annotation below is flagged by npx eslint . (the Lint — ESLint CI step).
Exception: allow-test-rule: <reason>
Some tests legitimately read source files. There are six recognized categories:
| Reason | When to use |
|---|---|
source-text-is-the-product |
Agent .md, workflow .md, command .md files — their text IS what the runtime loads. Testing text content tests the deployed contract. |
architectural-invariant |
Implementation must use a specific primitive (e.g., Atomics.wait, atomic file writes) that cannot be tested by observing outputs. |
structural-regression-guard |
A specific code pattern must (or must not) exist to prevent a class of bug (e.g., regex global-state misuse). Behavioral tests cannot distinguish which pattern was used. |
docs-parity |
A reference doc must stay in sync with source-defined constants (e.g., CONFIG_DEFAULTS). The source is the canonical list; there is no runtime API to enumerate it. |
integration-test-input |
A source file is used as a real fixture input to a transformation function under test — the file is not inspected for strings but passed as data. |
structural-implementation-guard |
A feature's interception or wiring point is not reachable end-to-end via runGsdTools. Used temporarily until a behavioral path exists. |
pending-migration-to-typed-ir |
Tracked for correction, not exempted. Test was identified by the lint as carrying a raw-text-matching pattern that contradicts the rule above. Each annotated file MUST cite the open migration issue (e.g. // allow-test-rule: pending-migration-to-typed-ir [#NNNN]) so the tracking is auditable. New tests cannot use this category — they must refactor production to expose typed IR. The annotation is removed when the test is corrected. |
Annotate with a standalone // comment before the file's opening block comment:
// allow-test-rule: architectural-invariant
// state.cjs locking must use Atomics.wait(), not a spin-loop. Behavioral tests
// cannot observe which sleep primitive was chosen — only source inspection can.
/**
* Regression tests for locking bugs #1909...
*/
The annotation must be a standalone // allow-test-rule: line, not inside a /** */ block comment — the CI linter scans for the pattern // allow-test-rule:.
Prohibited: Raw Text Matching on Test Outputs (file content, stdout, stderr)
Source-grep is not just readFileSync of a .cjs file. The same anti-pattern shows up wherever a test pattern-matches against text that a system-under-test produced, regardless of whether that text came from a source file, a rendered shim, a child process's stdout, or a free-form reason string. All forms are forbidden.
The following are all violations of the same rule:
// BAD — substring match on text written by the code under test
const cmdContent = fs.readFileSync(path.join(tmpDir, 'gsd-sdk.cmd'), 'utf8');
assert.ok(cmdContent.includes(`@node ${jsonQuoted} %*`), '.cmd embeds shim path');
// BAD — regex match on a child process's human-readable stdout formatter
const r = cp.spawnSync(SCRIPT, ['--patches-dir', dir]);
assert.match(r.stdout, /Failures: 1/);
assert.match(r.stdout, /not a regular file/);
// BAD — "structured parser" that hides string ops behind a function wrapper
function parseCmdShim(content) {
const lines = content.split('\r\n').filter((l) => l.length > 0);
return { header: lines[0], usesCRLF: content.includes('\r\n') };
}
// BAD — assert.match on a free-form `reason` string from a JSON report
assert.ok(/not a regular file/.test(report.results[0].reason));
Each of these passes on accidental near-matches (a comment containing @node somewhere, a stack trace that happens to say Failures: 1, a mis-typed reason that still contains the substring you're matching) and fails on harmless reformatting (changing Failures: 1 to 1 failure, swapping CRLF rendering style, rewording the error prose).
The rule
Tests assert on typed structured values. If the code under test produces text, the code under test must also expose a structured intermediate representation, and the test must assert on that IR — never on the rendered text.
Concretely: for any system-under-test that produces text output (a file renderer, a CLI formatter, an error-message builder), the production code MUST expose a typed alternative that the test consumes:
| Output kind | Required structured surface | What the test asserts on |
|---|---|---|
| Rendered file (shim, template, generated code) | A pure builder function returning the IR ({ invocation, eol, fileNames, render }) |
triple.invocation.target === expected, triple.eol.cmd === '\r\n' |
| CLI human-formatter output | A --json mode that emits the same data structurally |
report.results[0].reason === REASON.FAIL_INSTALLED_NOT_REGULAR_FILE |
| Error / status / reason | A frozen enum (Object.freeze({ FAIL_X: 'fail_x', ... })) |
assert.equal(result.reason, REASON.FAIL_X) |
| File presence after a write | fs.statSync().isFile(), .size > 0, .mtimeMs advances |
Filesystem facts; never read the file content back |
Concrete example from this repo
gsd-core/bin/verify-reapply-patches.cjs exposes a frozen REASON enum and emits it through --json. Tests assert report.results[0].reason === REASON.FAIL_USER_LINES_MISSING rather than regex-matching the human-readable prose. The human formatter exists for operator console output only — tests must not depend on it. Adding a new reason code requires updating the REASON enum, the --json output, AND the test that locks Object.keys(REASON).sort() — three coordinated changes that keep the code surface from drifting from the test surface. A pure builder that returns the IR (no I/O) and a writer that consumes it — fs.statSync(target).size === Buffer.byteLength(render()) to prove the writer writes what the renderer produces, without comparing content — is the same pattern applied to rendered files.
Hiding grep behind a function is still grep
parseCmdShim, parsePs1Invocation, etc. that internally do content.split(...), lines[1].trim(), content.includes(...) are still string manipulation. The fact that the entry point looks like a parser doesn't change what's happening underneath — the test is still asserting on the lexical shape of rendered text. The fix is not "wrap the grep in a function with a typed-looking return value." The fix is to eliminate the rendered text from the test path entirely by surfacing the IR.
When you cannot eliminate text matching
There are exactly two cases where text content is the legitimate object of a test, both already covered by the existing exemption matrix:
source-text-is-the-product— workflow.md/ agent.md/ command.mdfiles where the deployed text IS what the runtime loads.docs-parity— a reference doc must mirror source-defined constants and there is no runtime enumeration API.
For everything else, if a test reaches for .includes() / .startsWith() / assert.match(text, /…/), the production code is missing a typed surface. Add the typed surface; do not work around it.
CI enforcement: the local/no-source-grep ESLint rule (eslint-rules/no-source-grep.cjs) is being extended (see issue tracker for the latest scope) to flag String#includes/String#startsWith/String#endsWith/assert.match on readFileSync results and on cp.spawnSync stdout/stderr in test files, with the same // allow-test-rule: exemption mechanism.
Node.js Version Compatibility
Node 24 is the minimum supported version. Node 24 is also the primary CI target. Node 26 is the forward-compatibility target: do not add tests or production code that depend on deprecated behavior likely to fail there.
| Version | Status |
|---|---|
| Node 24 | Minimum required and primary CI target — Active LTS, all tests must pass |
| Node 26 | Forward-compatible target — avoid deprecated APIs and exact runtime-error prose |
Do not use:
- Deprecated APIs
- APIs not available in Node 24
Safe to use:
node:test— stable since Node 18, fully featured in 24describe/it/test— all supportedbeforeEach/afterEach/before/after— all supportedt.after()— per-test cleanupmock.method()— approved for scoped filesystem/subprocess fault injectiont.plan()— fully supported- Snapshot testing — fully supported
Assertions
Use node:assert/strict for strict equality by default:
const assert = require('node:assert/strict');
assert.strictEqual(actual, expected); // ===
assert.deepStrictEqual(actual, expected); // deep ===
assert.ok(value); // truthy
assert.throws(() => { ... }, /pattern/); // throws
assert.rejects(async () => { ... }); // async throws
Running Tests
# Run all tests
npm test
# Run a single test file
node --test tests/core.test.cjs
# Run with coverage
npm run test:coverage
For examples of required negative matrices, parser fixtures, filesystem fault injection, security abuse tests, generated-file checks, and runtime/SDK parity tests, see TEST-EXAMPLES.md.
Preferred local benchmark runner (before PR)
When you can, run the local test bench harness before opening a PR — especially for Windows-sensitive changes.
- Setup guide: gsd-test-runner getting started
- Preferred PR evidence: include the bench results summary (or artifact link) in your PR body.
This gives maintainers a faster, higher-confidence signal than CI-only validation.
Pre-PR Seam Checks (Manifest/Alias Routing)
If you touched src/command-aliases.cts or any of the eight src/*-command-router.cts
sources it feeds, run:
npm run check:alias-drift
This verifies the built alias artifacts under gsd-core/bin/lib/ agree with their
source of truth — each family's *_SUBCOMMANDS list must match the subcommand
values derived from its *_COMMAND_ALIASES table, in order, and each router must
reference its own list. The surface is enumerated once in
scripts/lib/alias-drift-families.cjs.
Editing shipped content (gsd-core/workflows, references, templates, contexts, agents/, commands/gsd/)
Editing the content of a copied shipped file — a gsd-core/workflows/*.md, an agent, a
command definition — requires zero manual fixture regeneration. There is no
committed path→hash manifest or per-file size baseline to update by hand; the
differential attribution check (tests/emitted-attribution.test.cjs, ADR-2719) computes
what your PR changed against next and requires every emitted-artifact hash that moved
to be attributable to your diff. If it is not, the check fails and names the paths.
Legitimate cases where emitted bytes move for a reason your diff cannot show directly —
a converter change, for example — go through a per-PR fragment under
tests/emitted-drift-acks/ (#2914; name the path, say why); see CONTEXT.md's
### Emitted Artifact Provenance entry for the full model. Growth in a
gsd-core/workflows/*.md or agents/gsd-*.md file is reported with its exact byte delta
and needs the same acknowledgment; the outer tier hard caps in
tests/workflow-size-budget.test.cjs / tests/agent-size-budget.test.cjs are unaffected
and still apply. The legacy single tests/emitted-drift-ack.json is still read and
unioned in for any branch that still carries it, but new acknowledgments go in a NEW
fragment, never that file.
You do not need to memorize any of this. The failure output names its own remedy — it
tells you to create a new fragment under tests/emitted-drift-acks/ (with a name nobody
else is using — include your issue or PR number), which key to use, and prints a minimal
valid document you can paste. Note the two key spaces, because the message says which one
applies: an unattributable hash ripple is keyed on the emitted path
(skills/gsd-add-tests/SKILL.md), while growth is keyed on the bare filename as it
appears under gsd-core/workflows/ or agents/ (explore.md). When you remove the last
entry from your fragment, delete the fragment file too — its presence is the alarm, so an
empty one signals nothing. Nothing here is regenerated: if you find yourself looking for a
baseline file to re-run a generator over, that file was deleted by #2724 and is not coming
back.
Why fragments, not one file (#2914): every PR needing an acknowledgment used to
rewrite tests/emitted-drift-ack.json's paths map wholesale — a single shared mutable
file every such PR touches guarantees a merge conflict between any two of them (5 of 6
conflicting PRs in one open queue collided on this file and nothing else), and it means
spent, already-merged entries pile up on next. A fragment per PR — the same shape
.changeset/ already uses for the identical problem — means two PRs can never conflict on
this seam again, and a fragment left on next after merge is inert rather than a shared
cell. Two ack sources (two fragments, or a fragment and the legacy file) may never name
the same path; that is a hard, loudly-reported error, not a silent last-wins.
tests/emitted-drift-ack.json (the legacy single file, specifically — NOT the fragment
directory) must never persist on next (#2914): every entry is scoped to the diff that
introduced it, so once merged it is, by definition, already at the base — spent and inert,
regardless of shape, and its persistence is what makes it a shared merge-conflict cell. A
fragment persisting on next is harmless, since fragments are independently named and
cannot conflict with anything, so this guard is deliberately scoped to the legacy file
alone. This is enforced only on next itself, by the guard-no-ack-on-next job in
.github/workflows/test.yml (push-to-next trigger,
scripts/lint-emitted-drift-ack.cjs --guard-next), never as a PR-lane check — a PR-lane
"base ack must be absent" check would red every open PR the moment one landed (the #2768
shape #2789 exists to prevent). If you ever see the legacy file present on next, delete
it; do not try to make it well-formed.
npm run regen:derived still exists for the artifacts that ARE committed and derived —
sync-manifest-versions, the ADR index, the capability matrix, the inventory manifest,
the registry, and tests/fixtures/install-tree/*.json (npm run gen:install-tree, the
one fixture family ADR-2719 §7 keeps committed, because it conflicts on 0 of 7 and its
diffs are readable). Run it after a change to any of those, before committing:
npm run regen:derived
Optional local pre-commit hook entry (Git-native):
.githooks/pre-commit is committed — you do not write it, you only point git at
it. It runs check:alias-drift when you stage one of the tracked sources that check
reads, and stays silent otherwise.
# one-time setup
git config core.hooksPath .githooks
This is opt-in and stays that way: nothing in npm install sets core.hooksPath for
you, so a fresh clone acquires no hooks. To stop using them, git config --unset core.hooksPath.
Do not paste a copy of the hook body into your own .githooks/pre-commit. Bash cannot
require() a CommonJS module, so the hook does carry the watched paths as literals —
but tests/precommit-alias-drift-hook.test.cjs runs the real hook against every source
derived from scripts/lib/alias-drift-families.cjs and fails in both directions: if
the hook stops watching a source the checker reads, and if it keeps watching a router the
checker dropped. A copy in your own tree has no such test behind it, and a hand-maintained
copy is exactly what silently rotted the previous version of this recipe (#2725) — every
path in it named the retired sdk/ tree or a gitignored build output, so the guard
matched nothing for months.
Optional local pre-push hook to block a private author-email pattern:
.githooks/pre-push is committed too, and is covered by the same
core.hooksPath opt-in above. It is a no-op until you set the regex, so enabling
hooks does not enable this check:
# set locally in your shell profile (example)
export GSD_BLOCKED_AUTHOR_REGEX='@example-corp\.com$'
With that exported, a push carrying a commit whose author email matches is blocked, and the hook names the offending commits. Unset the variable to disable it.
Every commit invocation in shipped content must declare --files
tests/commit-files-pathspec.test.cjs scans every .md under gsd-core/workflows/,
gsd-core/references/, agents/, commands/, skills/ and docs/ for invocations of
the commit seam, and fails if any of them reaches the runtime without a --files
scope. An unscoped invocation lands on the blanket-stage default and sweeps the whole
.planning/ index into a commit whose message names one artifact — that is #2269,
and cmdCommit is a CRITICAL-blast-radius seam, so the guard is repo-wide rather than
keyed to the three sites that were reported.
The scan decides what is an invocation by command shape, not by the markup around
it — a fenced block, an indented block, a cd … && prefix and a bare line are all
scanned alike, because 96 of the live invocations sit inside fences and exempting them
would blind the guard to every site the issue was filed about. Two consequences you may
hit while editing shipped content, and the failure output names both:
A prose mention that runs into its sentence is flagged. Nothing distinguishes
gsd_run query commit followed by ordinary words from an invocation with arguments
without guessing at English, so the scan does not try. Write the command reference in
backticks — the repo's own convention — and it is correctly read as a mention.
A deliberate wrong-example must declare itself. An example that shows the unscoped form is byte-identical to a regression, so no property of the surrounding markup can stand in for your intent. Declare it on the invocation's own line, in shell-comment position:
gsd_run query commit "docs: message" # gsd-scan-ignore: #2269 counter-example for the docs
That block is a live example of itself: the invocation above really is unscoped, and it is the declaration — not the fence around it — that keeps the scan quiet.
The reason must name a tracking issue (#NNN) or an http(s):// URL, exactly as
ADR-456 requires of the sibling
allow-test-rule: marker — an exemption with no ledger never gets revisited. A marker
with a free-text reason is reported as a malformed declaration rather than as an unscoped
commit, so you are told which of the two problems you actually have. A marker that
survives shell tokenization as an argument declares nothing: it reached argv, which
means the runtime executed the line.
CI Test Quality Checks
The following checks run on every PR in addition to the test suite:
| Job | What it checks | How to pass |
|---|---|---|
Lint — ESLint |
No source-grep tests (see above), via the local/no-source-grep rule |
Replace with runGsdTools() behavioral tests, or add // allow-test-rule: <reason> |
Lint — cross-platform portability |
Windows-portability defects in tests, via local/no-path-literal-in-assert (more rules land per ADR-1703) — e.g. a path-returning call asserted against a hardcoded /-literal |
Normalize the actual: String(pathFn(...)).replace(/\\/g, '/'), or structure platform-specific code behind a process.platform !== 'win32' guard. No eslint-disable — see cross-platform-portability-rules.md |
Run locally before pushing: npm run lint (or npx eslint .)
Architecture-Aware Testing Requirements
When work touches architecture, routing, policy, registry assembly, or command semantics:
- Write tests against module interfaces and seam behavior, not implementation trivia.
- Prefer invariant/contract tests that protect ADR-backed behavior and
CONTEXT.mdterminology. - Ensure tests validate canonical behavior through the defined seam (for example: structured result contracts, canonical command metadata, and adapter parity), not source-text coupling.
- If ADRs define expected behavior, tests should assert those expectations directly.
Test Requirements by Contribution Type
The required tests differ depending on what you are contributing:
Bug Fix: A regression test is required. Write the test first — it must demonstrate the original failure before your fix is applied, then pass after the fix. A PR that fixes a bug without a regression test will be asked to add one. If the bug involves CLI input, parsers, filesystem writes, security/prompt surfaces, generated files, or SDK/runtime parity, the regression test must use the relevant QA matrix above and include negative proof that the bad behavior no longer happens. "Tests pass" does not prove correctness; it proves the bug isn't present in the tests that exist.
Enhancement: Tests covering the enhanced behavior are required. Update any existing tests that test the area you changed. If the enhancement expands accepted input, changes command routing, broadens parser behavior, changes generated output, or touches installer/write paths, add the relevant adversarial cases from the QA matrix above. Do not leave tests that pass but no longer accurately describe the behavior.
Feature: Tests are required for the primary success path and enough failure scenarios to cover the relevant QA matrix above. At minimum, every feature must cover one failure scenario; features that expose CLI input, parse user files, write files, generate artifacts, call subprocesses, or build prompts must cover the relevant negative/hostile cases. Leaving gaps in test coverage for a new feature is a rejection reason.
Behavior Change: If your change modifies existing behavior, the existing tests covering that behavior must be updated or replaced. For high-risk surfaces, update the adversarial tests as well as the happy path. Leaving passing-but-incorrect tests in the suite is not acceptable — a test that passes but asserts the old (now wrong) behavior makes the suite less useful than no test at all.
Reviewer Standards
Reviewers do not rely solely on CI to verify correctness. Before approving a PR, reviewers:
- Build locally (
npm run buildif applicable) - Run the full test suite locally (
npm test) - Confirm regression tests exist for bug fixes and that they would fail without the fix
- Validate that the implementation matches what the linked issue described — green CI on the wrong implementation is not an approval signal
"Tests pass in CI" is not sufficient for merge. The implementation must correctly solve the problem described in the linked issue.
Code Review Lessons
Input validation: check shape, not just type
Defensive normalization at trust boundaries must validate both the value's type and its semantic shape. A typeof === 'string' check is necessary but insufficient when the field's contract requires a specific format (UUID v4, semver, file path, etc.). See ADR 227 for the architectural standard and concrete cases.
Code Style
- CommonJS (
.cjs) — the project usesrequire(), not ESMimport - No external dependencies in core —
gsd-tools.cjsand all lib files use only Node.js built-ins - Conventional commits —
feat:,fix:,docs:,refactor:,test:,ci:. The full grammar is<type>(<scope>): <subject>(enforced byhooks/gsd-validate-commit.sh; subject ≤72 chars, lowercase, imperative mood, no trailing period). When the work resolves a tracked issue, put the issue number in the scope:fix(#1520): randomize mktemp temp paths on BSD/macOS. The same convention applies to PR titles — release notes are grouped by the title's type prefix (feat→ Feature,fix→ Fix, non-user-facing types omitted, everything else → Enhancement).
File Structure
bin/install.js — Installer (multi-runtime)
gsd-core/
bin/lib/ — Core library modules (.cjs)
workflows/ — Workflow definitions (.md)
Large workflows split per progressive-disclosure
pattern: workflows/<name>/modes/*.md +
workflows/<name>/templates/*. Parent dispatches
to mode files. See workflows/discuss-phase/ as
the canonical example (the discuss-phase/modes split, #717). New modes for
discuss-phase land in
workflows/discuss-phase/modes/<mode>.md.
Per-file growth is caught by the differential
attribution check (tests/emitted-attribution.test.cjs,
ADR-2719) — it reports the exact byte delta and
requires a per-PR fragment in
tests/emitted-drift-acks/ (#2914), no committed
snapshot to regenerate. Loose tier
hard caps remain in tests/workflow-size-budget.test.cjs.
The same applies to agent files (agents/gsd-*.md,
tests/agent-size-budget.test.cjs). Full how-to +
reference in docs/TESTING-SUITES.md (Workflow &
agent size budget); see issue #1074.
references/ — Reference documentation (.md)
templates/ — File templates
agents/ — Agent definitions (.md) — CANONICAL SOURCE
commands/gsd/ — Slash command definitions (.md)
tests/ — Test files (.test.cjs)
helpers.cjs — Shared test utilities
docs/ — User-facing documentation
Source of truth for agents
Only agents/ at the repo root is tracked by git. The following directories may exist on a developer machine with GSD installed and must not be edited — they are install-sync outputs and will be overwritten:
| Path | Gitignored | What it is |
|---|---|---|
.claude/agents/ |
Yes (.gitignore:9) |
Local Claude Code runtime sync |
.cursor/agents/ |
Yes (.gitignore:12) |
Local Cursor IDE bundle |
.github/agents/gsd-* |
Yes (.gitignore:37) |
Local CI-surface bundle |
If you find that .claude/agents/ has drifted from agents/ (e.g., after a branch change), re-run bin/install.js to re-sync from the canonical source. Always edit agents/ — never the derivative directories.
Security
- Path validation — use
validatePath()fromsecurity.cjsfor any user-provided paths - No shell injection — use
execFileSync(array args) overexecSync(string interpolation) - No
${{ }}in GitHub Actionsrun:blocks — bind toenv:mappings first