chore(#2932): emit a per-invocation section manifest from the init bundle (#2987)

* chore(#2932): emit a per-invocation section manifest from init

Extends the init bundle with a typed per-invocation section manifest so an
invocation loads only the branch guidance it will actually take.

The three flag/state-gated branches in execute-phase.md move into their own
step files; the parent keeps its gsd:section markers wrapping a one-line
on-demand reference, so each section's prose lives in exactly one file and
the parent shrinks 93369 -> 89507 bytes. A new drift-guarded generator
derives the shipped section manifest from those markers, and a new pure
evaluator maps invocation facts to applicable section ids.

The evaluator is a lookup over the frozen WHEN_VOCABULARY, never a parser
(Greenspun's Tenth Rule, ADR-1671:69); a parity test asserts the vocabulary
and the predicate map stay exhaustively in sync.

Closes #2932

* fix(#2932): fail closed on prototype-chain when values

An isolated adversarial review found WHEN_PREDICATES[section.when] was a
bracket lookup on a plain-prototype object, so inherited Object.prototype
members resolved as predicates: "constructor"/"toString"/"valueOf"/
"hasOwnProperty" returned truthy and SILENTLY INCLUDED the section, and
"__proto__" threw an untyped TypeError carrying no .reason. Both violate
the module's documented fail-closed contract, and the manifest is read from
disk at run time so it cannot be assumed trustworthy.

Builds the predicate map on a null prototype and guards the lookup with an
explicit Object.hasOwn check. Adds table-driven coverage for nine
Object.prototype-shaped keys asserting the TYPED reason (asserting only
that it throws would still pass while broken) plus a fast-check property
injecting a hostile value at an arbitrary document position.

* test(#2932): retarget execute-phase step assertions at extracted step files

* fix(#2932): emit typed reasons for generator lib-load and write failures

* fix(#2932): restore launcher preamble in extracted steps and refresh derived fixtures

* chore(#2932): backfill changeset pr number to 2987

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-02 12:34:41 -04:00
committed by GitHub
parent 33985c11a9
commit a987cf2731
46 changed files with 2680 additions and 179 deletions

View File

@@ -0,0 +1,5 @@
---
type: Changed
pr: 2987
---
**`/gsd-execute-phase` now loads only the branch guidance your invocation actually uses.** Running it without `--wave` no longer pulls the wave-filtering instructions into context, and a plain integer phase no longer loads the decimal-phase gap-closure branch. The init bundle reports which sections apply to each invocation and the workflow reads only those, so the orchestrator spends its context on the path it is actually taking. (#2932)

1
.gitignore vendored
View File

@@ -88,6 +88,7 @@ build/
/gsd-core/bin/lib/context-composer.cjs
/gsd-core/bin/lib/context-predicates.cjs
/gsd-core/bin/lib/workflow-fragments.cjs
/gsd-core/bin/lib/section-manifest.cjs
/gsd-core/bin/lib/capability-loader.cjs
/gsd-core/bin/lib/capability-source.cjs
/gsd-core/bin/lib/capability-ledger.cjs

View File

@@ -206,6 +206,10 @@ Shared, pure, no-I/O seam owning priority-ordered composition of content fragmen
Pure, no-I/O seam owning in-file `<!-- gsd:section id="<id>" when="<when>" -->` / `<!-- /gsd:section -->` marker parsing and composition for GSD workflow markdown (ADR-1671 Decision item 1 + migration step 4 + open questions 1 & 2; epic #1671 Phase 3, #2930). `parseWorkflowSections` partitions a document into explicit (marked) and gap (unmarked, `explicit: false`) sections in document order — a marker line is removed in full (text + its own terminator), so an unmarked workflow (88 of 89 today) parses to exactly one implicit gap fragment and round-trips byte-identical. `toFragments` maps sections to Context Composer Module fragments, every one `{kind: 'verbatim'}` — non-lossiness in this phase is a structural guarantee of the strategy set, never a large-budget trick. `composeWorkflow` is the emission entry point: parse → `toFragments` → `composeWithinBudget` → `renderFragments`, run BEFORE the per-runtime converters so a marker attribute is stripped before any path-rewrite regex can reach it. **The grammar is deliberately CLOSED** (Greenspun's Tenth Rule): `when=` takes exactly one atom from the frozen `WHEN_VOCABULARY` — `always`, `flag:--wave`, `state:gap-closure-phase`, `state:has-prior-phases` — with no boolean operators, negation, or nesting; an unknown `when=` value throws rather than being silently dropped, and widening the vocabulary requires an ADR amendment, not an organic edit. Fence and HTML-comment interleaving is scanned in one left-to-right pass with two mutually exclusive states, reusing the discipline from the Context Predicates module's fence/comment scan (the two-pass design that caused #2928's silent-skip-to-EOF defect). `when=` is parsed and validated but not yet acted on — applicability selection is Phase 5; this phase lands the authoring model and proves the seam on one pilot workflow (`execute-phase.md`; retargeted from `plan-phase.md`, which sits 36 B under the ADR-857 `PRE_PHASE6` gate and cannot absorb marker overhead). Source of truth: `gsd-core/bin/lib/workflow-fragments.cjs` (generated from `src/workflow-fragments.cts`). Test anchors: `tests/workflow-fragments.test.cjs`, `tests/workflow-fragments.property.test.cjs`, `tests/workflow-fragments-emission.install.test.cjs`.
### Section Manifest Module
Pure, no-I/O `when=` evaluator over `InvocationFacts`, mapping a document-order list of parsed `gsd:section` sections (Workflow Fragments Module) to an included/excluded partition for one concrete invocation (ADR-1671 Decision items 3 & 4 + migration step 6; epic #1671 Phase 5, #2932). **The evaluator is a LOOKUP, not a parser** — `WHEN_PREDICATES` is a total map from each frozen `WHEN_VOCABULARY` entry (imported unchanged from `workflow-fragments.cjs`, never redeclared) to exactly one predicate over `InvocationFacts = {waveFlag, phaseNumber, hasPriorPhases}`; it MUST NOT tokenize, split on operators, or interpret `when=` structure — the moment it parses, the ad-hoc language Greenspun's Tenth Rule warns against has begun. `selectSections(sections, facts)` returns `{included, excluded}` id arrays that together contain every input id exactly once, in the same relative document order, never mutating the input. An unrecognized `when=` value fails closed via a `TypeError` carrying `.reason = REASON.UNKNOWN_WHEN` — never silently excluded — matching the discipline Phase 3 already established for the same vocabulary at parse time. Every predicate treats an absent fact key as falsy without throwing, since the caller (the init CLI seam) may not always populate every field. A coordinated-change guard runs at module load: every `WHEN_VOCABULARY` entry must have exactly one predicate here, so a 5th vocabulary entry added without a matching predicate fails loudly at load time rather than silently falling through to `REASON.UNKNOWN_WHEN` only at run time. Selection output is generated ahead of time into the committed `gsd-core/workflows/section-manifest.json` (`scripts/gen-section-manifest.cjs`, reusing `parseWorkflowSections` unchanged — a second marker parser here would be the `DEFECT.GENERATIVE-FIX` divergence class) rather than derived from markers at run time, because markers are stripped at emit and the installed parent carries no `gsd:section` metadata. Source of truth: `gsd-core/bin/lib/section-manifest.cjs` (generated from `src/section-manifest.cts`). Test anchors: `tests/section-manifest.test.cjs`, `tests/section-manifest.property.test.cjs`, `tests/gen-section-manifest.test.cjs`.
### Runtime Artifact Layout Module
Module owning the per-runtime mapping from artifact kind to filesystem placement. ADR-3660 defines the typed `kinds` per runtime (`commands`, `agents`, `skills`) with destination subpath, prefix, and stage adapter (with per-runtime converters in `bin/install.js`: `convertClaudeCommandToClaudeSkill`, `…CodexSkill`, `…CopilotSkill`, `…AntigravitySkill`). Owns the per-runtime `nested` skill-bundle decision (#69): a `skillsKind` flag in `src/runtime-artifact-layout.cts` drives whether a runtime receives the nested router layout (6 `gsd-ns-*` routers + concrete skills under `<router>/skills/<name>/`) or the flat `skills/gsd-<stem>/` layout; the evidence/doc-link matrix is recorded in a comment above `resolveRuntimeArtifactLayout`. Phase 1 applies this seam to the Runtime Surface Module (`surface.cjs:applySurface`); as of #813, `applySurface` applies the same per-runtime skill-body path rewrites as `installRuntimeArtifacts` for `skills` kinds — re-surfacing no longer overwrites installed SKILL.md bodies with converter-default `~/.claude` paths. Per ADR-1508 / #1511 the former `getInstallExports`/`loadInstallExports` relay (a `GSD_TEST_MODE`-guarded `require('bin/install.js')` by which `surface.cjs` reached `computePathPrefix`/`applyRuntimeContentRewritesInPlace`) was DELETED from this module; content rewriting now lives in the Runtime Artifact Conversion Module and `surface.cjs:applySurface` calls its `rewriteStagedSkillBodies` directly. The resolved `scope` is still carried on the `Layout` object so `applySurface` derives the same `pathPrefix` (global `$HOME` form vs. absolute) as a fresh install. Phase 2 is planned to migrate install/uninstall in `bin/install.js` so all lifecycle sites iterate one shared layout table instead of re-encoding runtime layout logic. This design is intended to remove the #3659 class of omissions. Migrations remain under the Installer Migration Module (ADR-0008). The `.gsd-source` marker (#1477) is a two-party provisioning contract that lets source resolution succeed on the Claude global skills layout, which ships `gsd-core/{bin,contexts,references,templates,workflows}` but no `commands/gsd` source tree for `findInstallSourceRoot` to walk up to: the writer is `bin/install.js`, which writes `<configDir>/.gsd-source` (content: the absolute path to its own `commands/gsd`, terminated by a newline) when `runtime === 'claude' && isGlobal`, guarded by `fs.existsSync` so a half-published package never writes a dangling marker; the reader is `findInstallSourceRoot(configDir)`, which prefers the marker over its walk-up but falls through to the walk-up if the marker is absent, dangling, or empty/whitespace-only. See ADR-3660.

View File

@@ -381,6 +381,29 @@ grammar, the frozen `when=` vocabulary, and fail-closed authoring rules, and
[ADR-1671](adr/1671-dynamic-context-management-platform.md) (open questions 1 and 2) for why
in-file markers were chosen over separate fragment files or a sidecar manifest.
### Section Manifest (`src/section-manifest.cts`, ADR-1671 Phase 5)
Two seams turn a workflow's `gsd:section` markers into per-invocation applicability data.
`scripts/gen-section-manifest.cjs --write` (wired into `build` after `build:lib`, and into
`lint:generated-sync`) scans `gsd-core/workflows/*.md` and writes the committed
`gsd-core/workflows/section-manifest.json`: a `{id, when, read}` triple per marked section,
where `read` is the path of the step file the section's body was extracted to. It reuses
`parseWorkflowSections` unchanged rather than re-implementing marker parsing, and fails closed
(`--check`) on a marker naming a step file that does not exist or a step file no marker
references.
A separate pure evaluator, `src/section-manifest.cts` (compiled to
`gsd-core/bin/lib/section-manifest.cjs` per ADR-457), maps one invocation's facts —
`{waveFlag, phaseNumber, hasPriorPhases}` — to an included/excluded partition of section ids
via `selectSections`. Per Greenspun's Tenth Rule, this is a total lookup over the frozen
`when=` vocabulary, never a parser: it never tokenizes or interprets `when=` structure, and an
unrecognized value fails closed rather than being silently excluded.
`execute-phase.md`'s `partial-wave` and `gap-closure-artifacts` sections — previously inlined
directly per #2930's pilot — now delegate to dedicated step files under
`gsd-core/workflows/execute-phase/steps/`, the same pattern the pre-existing `regression-gate`
section already used.
### CLI Tools (`gsd-core/bin/`)
Node.js CLI utility (`gsd-tools.cjs`) with domain modules split across `gsd-core/bin/lib/` (see [`docs/INVENTORY.md`](INVENTORY.md#cli-modules) for the authoritative roster):

View File

@@ -440,6 +440,7 @@
"runtime-slash.cjs",
"schema-detect.cjs",
"secrets.cjs",
"section-manifest.cjs",
"security.cjs",
"semver-compare.cjs",
"shell-command-projection.cjs",

View File

@@ -528,6 +528,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`.
| `runtime-slash.cjs` | Runtime-aware slash-command formatter — single source of truth for emitting `/gsd-<cmd>` (skills-based runtimes) and `$gsd-<cmd>` (codex) in user-facing output and persisted artifacts (#3584) |
| `schema-detect.cjs` | Schema-drift detection for ORM patterns (Prisma, Drizzle, Supabase, TypeORM, Payload); exports `detectSchemaFiles`, `detectSchemaOrm`, `checkSchemaDrift`, `SCHEMA_PATTERNS`, `ORM_INFO` |
| `secrets.cjs` | Secret-config masking convention (`****<last-4>`) for integration keys; exports `SECRET_CONFIG_KEYS`, `isSecretKey`, `maskSecret`, `maskIfSecret` |
| `section-manifest.cjs` | Pure `when=` evaluator over `InvocationFacts` (ADR-1671, epic #1671 Phase 5, #2932) — `selectSections` partitions a document-order list of parsed `gsd:section` sections into `included`/`excluded` id arrays for one concrete invocation, via `WHEN_PREDICATES`, a total lookup (never a parser) over the frozen `WHEN_VOCABULARY` imported unchanged from `workflow-fragments.cjs`; an unrecognized `when=` value fails closed (`REASON.UNKNOWN_WHEN`), and a coordinated-change guard at module load throws if a vocabulary entry has no predicate. Compiled from `src/section-manifest.cts` |
| `semver-compare.cjs` | Shared semver comparison policy helpers (`compareSemverCore`, stable-triplet validation, normalized tuple parsing) consumed by update-check hooks, statusline dev-install detection, and changeset extract range logic (#10) |
| `security.cjs` | Path traversal prevention, prompt injection detection, safe JSON/shell helpers |
| `shell-command-projection.cjs` | Runtime-aware shell command projection for managed hook serialization: decides PowerShell call-operator usage by runtime/platform and normalizes Windows script path tokens |

View File

@@ -245,6 +245,8 @@ export default tseslint.config(
'gsd-core/bin/lib/context-composer.cjs',
// ADR-1671 (#2930): tsc-generated runtime artifact — lint the src/workflow-fragments.cts source.
'gsd-core/bin/lib/workflow-fragments.cjs',
// ADR-1671 Phase 5 (#2932): tsc-generated runtime artifact — lint the src/section-manifest.cts source.
'gsd-core/bin/lib/section-manifest.cjs',
],
},

View File

@@ -86,7 +86,9 @@ if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
AGENT_SKILLS=$(gsd_run query agent-skills gsd-executor)
```
Parse JSON for: `executor_model`, `verifier_model`, `commit_docs`, `parallelization`, `branching_strategy`, `branch_name`, `phase_found`, `phase_dir`, `phase_number`, `phase_name`, `phase_slug`, `plans`, `incomplete_plans`, `plan_count`, `incomplete_count`, `state_exists`, `roadmap_exists`, `phase_req_ids`, `response_language`, `requirements_path`.
Parse JSON for: `executor_model`, `verifier_model`, `commit_docs`, `parallelization`, `branching_strategy`, `branch_name`, `phase_found`, `phase_dir`, `phase_number`, `phase_name`, `phase_slug`, `plans`, `incomplete_plans`, `plan_count`, `incomplete_count`, `state_exists`, `roadmap_exists`, `phase_req_ids`, `response_language`, `requirements_path`, `section_manifest`.
`section_manifest` (#2932) gates the three `steps/*.md` reads below: read a step file only when its `id` is in `section_manifest.included` (equivalently, its path is in `section_manifest.read`); skip it — without reading — when its `id` is in `section_manifest.excluded`. When `section_manifest` is `null` (degraded: manifest artifact missing/unreadable), read all three unconditionally — the safe superset.
**Model resolution:** If `executor_model` is `"inherit"`, omit the `model=` parameter from all `Agent()` calls — do NOT pass `model="inherit"` to Agent. Omitting the `model=` parameter causes Claude Code to inherit the current orchestrator model automatically. Only set `model=` when `executor_model` is an explicit model name (e.g., `"claude-sonnet-5"`, `"claude-opus-4-8"`).
@@ -1174,36 +1176,7 @@ If an active secure-phase step hook exists AND SECURITY.md exists: check frontma
</step>
<!-- gsd:section id="partial-wave" when="flag:--wave" -->
<step name="handle_partial_wave_execution">
If `WAVE_FILTER` was used, re-run plan discovery after execution:
```bash
POST_PLAN_INDEX=$(gsd_run query phase-plan-index "${PHASE_NUMBER}")
```
Apply the same "incomplete" filtering rules as earlier:
- ignore plans with `has_summary: true`
- if `--gaps-only`, only consider `gap_closure: true` plans
**If incomplete plans still remain anywhere in the phase:**
- STOP here
- Do NOT run phase verification
- Do NOT mark the phase complete in ROADMAP/STATE
- Present:
```markdown
## Wave {WAVE_FILTER} Complete
Selected wave finished successfully. This phase still has incomplete plans, so phase-level verification and completion were intentionally skipped.
/gsd:execute-phase {phase} ${GSD_WS} # Continue remaining waves
/gsd:execute-phase {phase} --wave {next} ${GSD_WS} # Run the next wave explicitly
```
**If no incomplete plans remain after the selected wave finishes:**
- continue with the normal phase-level verification and completion flow below
- this means the selected wave happened to be the last remaining work in the phase
</step>
If `section_manifest` is `null` or `"partial-wave"` is in its `included` list: read and execute `gsd-core/workflows/execute-phase/steps/partial-wave.md`. Otherwise skip — do not read the file.
<!-- /gsd:section -->
<step name="code_review_gate" required="true">
@@ -1260,106 +1233,11 @@ Resolve and re-run /gsd execute-phase, or override with /gsd execute-phase {phas
</step>
<!-- gsd:section id="gap-closure-artifacts" when="state:gap-closure-phase" -->
<step name="close_parent_artifacts">
**For decimal/polish phases only (X.Y pattern):** Close the feedback loop by resolving parent UAT and debug artifacts.
**Skip if** phase number has no decimal (e.g., `3`, `04`) — only applies to gap-closure phases like `4.1`, `03.1`.
**1. Detect decimal phase and derive parent:**
```bash
# Check if phase_number contains a decimal
if [[ "$PHASE_NUMBER" == *.* ]]; then
PARENT_PHASE="${PHASE_NUMBER%%.*}"
fi
```
**2. Find parent UAT file:**
```bash
PARENT_INFO=$(gsd_run query find-phase "${PARENT_PHASE}" --raw)
# Extract directory from PARENT_INFO JSON, then find UAT file in that directory
```
**If no parent UAT found:** Skip this step (gap-closure may have been triggered by VERIFICATION.md instead).
**3. Update UAT gap statuses:**
Read the parent UAT file's `## Gaps` section. For each gap entry with `status: failed`:
- Update to `status: resolved`
**4. Update UAT frontmatter:**
If all gaps now have `status: resolved`:
- Update frontmatter `status: diagnosed` → `status: resolved`
- Update frontmatter `updated:` timestamp
**5. Resolve referenced debug sessions:**
For each gap that has a `debug_session:` field:
- Read the debug session file
- Update frontmatter `status:` → `resolved`
- Update frontmatter `updated:` timestamp
- Move to resolved directory:
```bash
mkdir -p .planning/debug/resolved
mv .planning/debug/{slug}.md .planning/debug/resolved/
```
**6. Commit updated artifacts:**
```bash
gsd_run query commit "docs(phase-${PARENT_PHASE}): resolve UAT gaps and debug sessions after ${PHASE_NUMBER} gap closure" --files .planning/phases/*${PARENT_PHASE}*/*-UAT.md .planning/debug/resolved/*.md
```
</step>
If `section_manifest` is `null` or `"gap-closure-artifacts"` is in its `included` list: read and execute `gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md`. Otherwise skip — do not read the file.
<!-- /gsd:section -->
<!-- gsd:section id="regression-gate" when="state:has-prior-phases" -->
<step name="regression_gate">
Run prior phases' test suites to catch cross-phase regressions BEFORE verification.
**Skip if:** This is the first phase (no prior phases), or no prior VERIFICATION.md files exist.
**Step 1: Discover prior phases' test files**
```bash
# Find all VERIFICATION.md files from prior phases in current milestone
PRIOR_VERIFICATIONS=$(find .planning/phases/ -name "*-VERIFICATION.md" ! -path "*${PHASE_NUMBER}*" 2>/dev/null)
```
**Step 2: Extract test file lists from prior verifications**
For each VERIFICATION.md found, look for test file references:
- Lines containing `test`, `spec`, or `__tests__` paths
- The "Test Suite" or "Automated Checks" section
- File patterns from `key-files.created` in corresponding SUMMARY.md files that match `*.test.*` or `*.spec.*`
Collect all unique test file paths into `REGRESSION_FILES`.
**Step 3: Run regression tests (if any found)** — Read and execute `gsd-core/workflows/execute-phase/steps/regression-gate.md`. It resolves the project test command, normalizes it to a one-shot form (defeating vitest/jest watch mode via the shared `normalize-test-command` helper), runs it under `workflow.test_gate_timeout`, and aborts on timeout with a watch-mode hint (#1857). On `REGRESSION GATE ABORTED` (exit 124), HALT — do not proceed to verification.
**Step 4: Report results**
If all tests pass:
```
✓ Regression gate: {N} prior-phase test files passed — no regressions detected
```
→ Proceed to verify_phase_goal
If any tests fail:
```
## ⚠ Cross-Phase Regression Detected
Phase {X} execution may have broken functionality from prior phases.
| Test File | Phase | Status | Detail |
|-----------|-------|--------|--------|
| {file} | {origin_phase} | FAILED | {first_failure_line} |
Options:
1. Fix regressions before verification (recommended)
2. Continue to verification anyway (regressions will compound)
3. Abort phase — roll back and re-plan
```
If `TEXT_MODE` is true, present as a plain-text numbered list and ask the user to type their choice number. Otherwise, use AskUserQuestion to present the options.
</step>
If `section_manifest` is `null` or `"regression-gate"` is in its `included` list: read and execute `gsd-core/workflows/execute-phase/steps/regression-gate.md`. Otherwise skip — do not read the file.
<!-- /gsd:section -->
<step name="verify_phase_goal">

View File

@@ -0,0 +1,50 @@
<step name="close_parent_artifacts">
**For decimal/polish phases only (X.Y pattern):** Close the feedback loop by resolving parent UAT and debug artifacts.
**Skip if** phase number has no decimal (e.g., `3`, `04`) — only applies to gap-closure phases like `4.1`, `03.1`.
**1. Detect decimal phase and derive parent:**
```bash
# Check if phase_number contains a decimal
if [[ "$PHASE_NUMBER" == *.* ]]; then
PARENT_PHASE="${PHASE_NUMBER%%.*}"
fi
```
**2. Find parent UAT file:**
```bash
_GSD_SHIM_NAME="gsd-tools.cjs"; _GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"; GSD_TOOLS="${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"; if [ -f "$GSD_TOOLS" ]; then gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.codex/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.codex/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif command -v gsd-tools >/dev/null 2>&1; then GSD_TOOLS="$(command -v gsd-tools)"; gsd_run() { "$GSD_TOOLS" "$@"; }; elif [ -f "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CURSOR_CONFIG_DIR:-$HOME/.cursor}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CURSOR_CONFIG_DIR:-$HOME/.cursor}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CODEX_HOME:-$HOME/.codex}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CODEX_HOME:-$HOME/.codex}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${GEMINI_CONFIG_DIR:-$HOME/.gemini}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${GEMINI_CONFIG_DIR:-$HOME/.gemini}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${COPILOT_CONFIG_DIR:-$HOME/.copilot}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${COPILOT_CONFIG_DIR:-$HOME/.copilot}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${AUGMENT_CONFIG_DIR:-$HOME/.augment}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${AUGMENT_CONFIG_DIR:-$HOME/.augment}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${TRAE_CONFIG_DIR:-$HOME/.trae}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${TRAE_CONFIG_DIR:-$HOME/.trae}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${QWEN_CONFIG_DIR:-$HOME/.qwen}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${QWEN_CONFIG_DIR:-$HOME/.qwen}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CLINE_CONFIG_DIR:-$HOME/.cline}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CLINE_CONFIG_DIR:-$HOME/.cline}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${GROK_AGENTS_HOME:-$HOME/.agents}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${GROK_AGENTS_HOME:-$HOME/.agents}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; else echo "ERROR: gsd-tools.cjs not found at $GSD_TOOLS and gsd-tools is not on PATH. Run: npx -y @opengsd/gsd-core@latest --claude --local" >&2; exit 1; fi; if [ -n "${CLAUDE_ENV_FILE:-}" ] && [ -n "${GSD_TOOLS:-}" ]; then printf "export PATH='%s':\"\$PATH\"\n" "${GSD_TOOLS%/*}" >> "$CLAUDE_ENV_FILE" 2>/dev/null || true; fi
PARENT_INFO=$(gsd_run query find-phase "${PARENT_PHASE}" --raw)
# Extract directory from PARENT_INFO JSON, then find UAT file in that directory
```
**If no parent UAT found:** Skip this step (gap-closure may have been triggered by VERIFICATION.md instead).
**3. Update UAT gap statuses:**
Read the parent UAT file's `## Gaps` section. For each gap entry with `status: failed`:
- Update to `status: resolved`
**4. Update UAT frontmatter:**
If all gaps now have `status: resolved`:
- Update frontmatter `status: diagnosed` → `status: resolved`
- Update frontmatter `updated:` timestamp
**5. Resolve referenced debug sessions:**
For each gap that has a `debug_session:` field:
- Read the debug session file
- Update frontmatter `status:` → `resolved`
- Update frontmatter `updated:` timestamp
- Move to resolved directory:
```bash
mkdir -p .planning/debug/resolved
mv .planning/debug/{slug}.md .planning/debug/resolved/
```
**6. Commit updated artifacts:**
```bash
gsd_run query commit "docs(phase-${PARENT_PHASE}): resolve UAT gaps and debug sessions after ${PHASE_NUMBER} gap closure" --files .planning/phases/*${PARENT_PHASE}*/*-UAT.md .planning/debug/resolved/*.md
```
</step>

View File

@@ -0,0 +1,31 @@
<step name="handle_partial_wave_execution">
If `WAVE_FILTER` was used, re-run plan discovery after execution:
```bash
_GSD_SHIM_NAME="gsd-tools.cjs"; _GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"; GSD_TOOLS="${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"; if [ -f "$GSD_TOOLS" ]; then gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.codex/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.codex/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif command -v gsd-tools >/dev/null 2>&1; then GSD_TOOLS="$(command -v gsd-tools)"; gsd_run() { "$GSD_TOOLS" "$@"; }; elif [ -f "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CURSOR_CONFIG_DIR:-$HOME/.cursor}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CURSOR_CONFIG_DIR:-$HOME/.cursor}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CODEX_HOME:-$HOME/.codex}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CODEX_HOME:-$HOME/.codex}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${GEMINI_CONFIG_DIR:-$HOME/.gemini}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${GEMINI_CONFIG_DIR:-$HOME/.gemini}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${COPILOT_CONFIG_DIR:-$HOME/.copilot}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${COPILOT_CONFIG_DIR:-$HOME/.copilot}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${AUGMENT_CONFIG_DIR:-$HOME/.augment}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${AUGMENT_CONFIG_DIR:-$HOME/.augment}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${TRAE_CONFIG_DIR:-$HOME/.trae}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${TRAE_CONFIG_DIR:-$HOME/.trae}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${QWEN_CONFIG_DIR:-$HOME/.qwen}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${QWEN_CONFIG_DIR:-$HOME/.qwen}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CLINE_CONFIG_DIR:-$HOME/.cline}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CLINE_CONFIG_DIR:-$HOME/.cline}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${GROK_AGENTS_HOME:-$HOME/.agents}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${GROK_AGENTS_HOME:-$HOME/.agents}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; else echo "ERROR: gsd-tools.cjs not found at $GSD_TOOLS and gsd-tools is not on PATH. Run: npx -y @opengsd/gsd-core@latest --claude --local" >&2; exit 1; fi; if [ -n "${CLAUDE_ENV_FILE:-}" ] && [ -n "${GSD_TOOLS:-}" ]; then printf "export PATH='%s':\"\$PATH\"\n" "${GSD_TOOLS%/*}" >> "$CLAUDE_ENV_FILE" 2>/dev/null || true; fi
POST_PLAN_INDEX=$(gsd_run query phase-plan-index "${PHASE_NUMBER}")
```
Apply the same "incomplete" filtering rules as earlier:
- ignore plans with `has_summary: true`
- if `--gaps-only`, only consider `gap_closure: true` plans
**If incomplete plans still remain anywhere in the phase:**
- STOP here
- Do NOT run phase verification
- Do NOT mark the phase complete in ROADMAP/STATE
- Present:
```markdown
## Wave {WAVE_FILTER} Complete
Selected wave finished successfully. This phase still has incomplete plans, so phase-level verification and completion were intentionally skipped.
/gsd:execute-phase {phase} ${GSD_WS} # Continue remaining waves
/gsd:execute-phase {phase} --wave {next} ${GSD_WS} # Run the next wave explicitly
```
**If no incomplete plans remain after the selected wave finishes:**
- continue with the normal phase-level verification and completion flow below
- this means the selected wave happened to be the last remaining work in the phase
</step>

View File

@@ -0,0 +1,42 @@
# Step: regression_gate_run
Run the resolved prior-phase test command one-shot, bounded by a timeout, so a
watch-mode runner (vitest defaults to watch in a TTY; jest `--watch`) cannot
hang this gate forever (#1857). Uses the shared `normalize-test-command` helper
— the same one the post-merge gate uses — so the two gate paths cannot drift.
Expects `REGRESSION_FILES` (from the prior step) in scope for the pytest branch.
```bash
_GSD_SHIM_NAME="gsd-tools.cjs"; _GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"; GSD_TOOLS="${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"; if [ -f "$GSD_TOOLS" ]; then gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.codex/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.codex/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif command -v gsd-tools >/dev/null 2>&1; then GSD_TOOLS="$(command -v gsd-tools)"; gsd_run() { "$GSD_TOOLS" "$@"; }; elif [ -f "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CURSOR_CONFIG_DIR:-$HOME/.cursor}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CURSOR_CONFIG_DIR:-$HOME/.cursor}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CODEX_HOME:-$HOME/.codex}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CODEX_HOME:-$HOME/.codex}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${GEMINI_CONFIG_DIR:-$HOME/.gemini}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${GEMINI_CONFIG_DIR:-$HOME/.gemini}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${COPILOT_CONFIG_DIR:-$HOME/.copilot}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${COPILOT_CONFIG_DIR:-$HOME/.copilot}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${AUGMENT_CONFIG_DIR:-$HOME/.augment}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${AUGMENT_CONFIG_DIR:-$HOME/.augment}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${TRAE_CONFIG_DIR:-$HOME/.trae}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${TRAE_CONFIG_DIR:-$HOME/.trae}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${QWEN_CONFIG_DIR:-$HOME/.qwen}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${QWEN_CONFIG_DIR:-$HOME/.qwen}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CLINE_CONFIG_DIR:-$HOME/.cline}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CLINE_CONFIG_DIR:-$HOME/.cline}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${GROK_AGENTS_HOME:-$HOME/.agents}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${GROK_AGENTS_HOME:-$HOME/.agents}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; else echo "ERROR: gsd-tools.cjs not found at $GSD_TOOLS and gsd-tools is not on PATH. Run: npx -y @opengsd/gsd-core@latest --claude --local" >&2; exit 1; fi; if [ -n "${CLAUDE_ENV_FILE:-}" ] && [ -n "${GSD_TOOLS:-}" ]; then printf "export PATH='%s':\"\$PATH\"\n" "${GSD_TOOLS%/*}" >> "$CLAUDE_ENV_FILE" 2>/dev/null || true; fi
# Resolve test command: project config > Makefile > language sniff
REG_TEST_CMD=$(gsd_run query config-get workflow.test_command --default "" --raw 2>/dev/null || true)
if [ -z "$REG_TEST_CMD" ]; then
if [ -f "Makefile" ] && grep -q "^test:" Makefile; then
REG_TEST_CMD="make test"
elif [ -f "Justfile" ] || [ -f "justfile" ]; then
REG_TEST_CMD="just test"
elif [ -f "package.json" ]; then
REG_TEST_CMD="npm test"
elif [ -f "Cargo.toml" ]; then
REG_TEST_CMD="cargo test"
elif [ -f "go.mod" ]; then
REG_TEST_CMD="go test ./..."
elif [ -f "requirements.txt" ] || [ -f "pyproject.toml" ]; then
REG_TEST_CMD="python -m pytest ${REGRESSION_FILES} -q --tb=short"
else
REG_TEST_CMD="true"
fi
fi
# #1857: normalize to a one-shot form (defeat vitest/jest watch mode) and bound
# with a timeout so a watch-mode runner cannot hang the gate indefinitely.
REG_TEST_CMD=$(gsd_run query normalize-test-command "$REG_TEST_CMD" --cwd . 2>/dev/null || echo "$REG_TEST_CMD")
TEST_GATE_TIMEOUT=$(gsd_run query config-get workflow.test_gate_timeout 2>/dev/null || echo "600")
gsd_run run-with-timeout "$TEST_GATE_TIMEOUT" -- bash -c "$REG_TEST_CMD" 2>&1
REG_TEST_EXIT=$?
if [ "$REG_TEST_EXIT" -eq 124 ]; then
echo "✗ REGRESSION GATE ABORTED — test runner did not exit within ${TEST_GATE_TIMEOUT}s, likely stuck in watch/dev mode (e.g. vitest without 'run'). Run tests one-shot (e.g. 'vitest run'), set workflow.test_command, or raise workflow.test_gate_timeout."
fi
```
**On `REG_TEST_EXIT` 124 (`REGRESSION GATE ABORTED`):** HALT — do not proceed to verification. The runner did not exit within the budget (watch/dev mode is the likely cause). Surface the watch-mode cause and the recovery options; never silently continue.

View File

@@ -1,42 +1,48 @@
# Step: regression_gate_run
<step name="regression_gate">
Run prior phases' test suites to catch cross-phase regressions BEFORE verification.
Run the resolved prior-phase test command one-shot, bounded by a timeout, so a
watch-mode runner (vitest defaults to watch in a TTY; jest `--watch`) cannot
hang this gate forever (#1857). Uses the shared `normalize-test-command` helper
— the same one the post-merge gate uses — so the two gate paths cannot drift.
Expects `REGRESSION_FILES` (from the prior step) in scope for the pytest branch.
**Skip if:** This is the first phase (no prior phases), or no prior VERIFICATION.md files exist.
**Step 1: Discover prior phases' test files**
```bash
_GSD_SHIM_NAME="gsd-tools.cjs"; _GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"; GSD_TOOLS="${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"; if [ -f "$GSD_TOOLS" ]; then gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.codex/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.codex/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif command -v gsd-tools >/dev/null 2>&1; then GSD_TOOLS="$(command -v gsd-tools)"; gsd_run() { "$GSD_TOOLS" "$@"; }; elif [ -f "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CURSOR_CONFIG_DIR:-$HOME/.cursor}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CURSOR_CONFIG_DIR:-$HOME/.cursor}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CODEX_HOME:-$HOME/.codex}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CODEX_HOME:-$HOME/.codex}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${GEMINI_CONFIG_DIR:-$HOME/.gemini}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${GEMINI_CONFIG_DIR:-$HOME/.gemini}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${COPILOT_CONFIG_DIR:-$HOME/.copilot}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${COPILOT_CONFIG_DIR:-$HOME/.copilot}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${AUGMENT_CONFIG_DIR:-$HOME/.augment}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${AUGMENT_CONFIG_DIR:-$HOME/.augment}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${TRAE_CONFIG_DIR:-$HOME/.trae}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${TRAE_CONFIG_DIR:-$HOME/.trae}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${QWEN_CONFIG_DIR:-$HOME/.qwen}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${QWEN_CONFIG_DIR:-$HOME/.qwen}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CLINE_CONFIG_DIR:-$HOME/.cline}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CLINE_CONFIG_DIR:-$HOME/.cline}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${GROK_AGENTS_HOME:-$HOME/.agents}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${GROK_AGENTS_HOME:-$HOME/.agents}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; else echo "ERROR: gsd-tools.cjs not found at $GSD_TOOLS and gsd-tools is not on PATH. Run: npx -y @opengsd/gsd-core@latest --claude --local" >&2; exit 1; fi; if [ -n "${CLAUDE_ENV_FILE:-}" ] && [ -n "${GSD_TOOLS:-}" ]; then printf "export PATH='%s':\"\$PATH\"\n" "${GSD_TOOLS%/*}" >> "$CLAUDE_ENV_FILE" 2>/dev/null || true; fi
# Resolve test command: project config > Makefile > language sniff
REG_TEST_CMD=$(gsd_run query config-get workflow.test_command --default "" --raw 2>/dev/null || true)
if [ -z "$REG_TEST_CMD" ]; then
if [ -f "Makefile" ] && grep -q "^test:" Makefile; then
REG_TEST_CMD="make test"
elif [ -f "Justfile" ] || [ -f "justfile" ]; then
REG_TEST_CMD="just test"
elif [ -f "package.json" ]; then
REG_TEST_CMD="npm test"
elif [ -f "Cargo.toml" ]; then
REG_TEST_CMD="cargo test"
elif [ -f "go.mod" ]; then
REG_TEST_CMD="go test ./..."
elif [ -f "requirements.txt" ] || [ -f "pyproject.toml" ]; then
REG_TEST_CMD="python -m pytest ${REGRESSION_FILES} -q --tb=short"
else
REG_TEST_CMD="true"
fi
fi
# #1857: normalize to a one-shot form (defeat vitest/jest watch mode) and bound
# with a timeout so a watch-mode runner cannot hang the gate indefinitely.
REG_TEST_CMD=$(gsd_run query normalize-test-command "$REG_TEST_CMD" --cwd . 2>/dev/null || echo "$REG_TEST_CMD")
TEST_GATE_TIMEOUT=$(gsd_run query config-get workflow.test_gate_timeout 2>/dev/null || echo "600")
gsd_run run-with-timeout "$TEST_GATE_TIMEOUT" -- bash -c "$REG_TEST_CMD" 2>&1
REG_TEST_EXIT=$?
if [ "$REG_TEST_EXIT" -eq 124 ]; then
echo "✗ REGRESSION GATE ABORTED — test runner did not exit within ${TEST_GATE_TIMEOUT}s, likely stuck in watch/dev mode (e.g. vitest without 'run'). Run tests one-shot (e.g. 'vitest run'), set workflow.test_command, or raise workflow.test_gate_timeout."
fi
# Find all VERIFICATION.md files from prior phases in current milestone
PRIOR_VERIFICATIONS=$(find .planning/phases/ -name "*-VERIFICATION.md" ! -path "*${PHASE_NUMBER}*" 2>/dev/null)
```
**On `REG_TEST_EXIT` 124 (`REGRESSION GATE ABORTED`):** HALT — do not proceed to verification. The runner did not exit within the budget (watch/dev mode is the likely cause). Surface the watch-mode cause and the recovery options; never silently continue.
**Step 2: Extract test file lists from prior verifications**
For each VERIFICATION.md found, look for test file references:
- Lines containing `test`, `spec`, or `__tests__` paths
- The "Test Suite" or "Automated Checks" section
- File patterns from `key-files.created` in corresponding SUMMARY.md files that match `*.test.*` or `*.spec.*`
Collect all unique test file paths into `REGRESSION_FILES`.
**Step 3: Run regression tests (if any found)** — Read and execute `gsd-core/workflows/execute-phase/steps/regression-gate-run.md`. It resolves the project test command, normalizes it to a one-shot form (defeating vitest/jest watch mode via the shared `normalize-test-command` helper), runs it under `workflow.test_gate_timeout`, and aborts on timeout with a watch-mode hint (#1857). On `REGRESSION GATE ABORTED` (exit 124), HALT — do not proceed to verification.
**Step 4: Report results**
If all tests pass:
```
✓ Regression gate: {N} prior-phase test files passed — no regressions detected
```
→ Proceed to verify_phase_goal
If any tests fail:
```
## ⚠ Cross-Phase Regression Detected
Phase {X} execution may have broken functionality from prior phases.
| Test File | Phase | Status | Detail |
|-----------|-------|--------|--------|
| {file} | {origin_phase} | FAILED | {first_failure_line} |
Options:
1. Fix regressions before verification (recommended)
2. Continue to verification anyway (regressions will compound)
3. Abort phase — roll back and re-plan
```
If `TEXT_MODE` is true, present as a plain-text numbered list and ask the user to type their choice number. Otherwise, use AskUserQuestion to present the options.
</step>

View File

@@ -0,0 +1,19 @@
{
"sections": [
{
"id": "partial-wave",
"when": "flag:--wave",
"read": "gsd-core/workflows/execute-phase/steps/partial-wave.md"
},
{
"id": "gap-closure-artifacts",
"when": "state:gap-closure-phase",
"read": "gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md"
},
{
"id": "regression-gate",
"when": "state:has-prior-phases",
"read": "gsd-core/workflows/execute-phase/steps/regression-gate.md"
}
]
}

View File

@@ -86,7 +86,7 @@
"check:identity-drift": "node scripts/lint-package-identity-drift.cjs",
"check:phase-id-drift": "node scripts/lint-phase-id-drift.cjs",
"check:integrity": "node scripts/check-npm-integrity.cjs",
"build": "npm run generate:identity && npm run build:lib && npm run gen:context-index && npm run gen:plugin-skills && npm run gen:loop-host-contract && npm run gen:capability-registry && npm run build:hooks",
"build": "npm run generate:identity && npm run build:lib && npm run gen:section-manifest && npm run gen:context-index && npm run gen:plugin-skills && npm run gen:loop-host-contract && npm run gen:capability-registry && npm run build:hooks",
"build:hooks": "node scripts/build-hooks.js",
"build:lib": "tsc -p tsconfig.build.json",
"generate:identity": "node scripts/generate-package-identity.cjs",
@@ -96,7 +96,8 @@
"gen:capability-registry": "node scripts/gen-capability-registry.cjs --write",
"gen:registry": "node scripts/gen-registry.cjs --write",
"gen:install-tree": "node scripts/gen-install-tree-fixtures.cjs",
"regen:derived": "npm run build && npm run gen:registry && node scripts/gen-adr-index.cjs --write && node scripts/gen-capability-matrix.cjs --write && node scripts/gen-inventory-manifest.cjs --write && node scripts/gen-context-index.cjs --write && node scripts/sync-manifest-versions.cjs && npm run gen:install-tree",
"gen:section-manifest": "node scripts/gen-section-manifest.cjs --write",
"regen:derived": "npm run build && npm run gen:registry && node scripts/gen-adr-index.cjs --write && node scripts/gen-capability-matrix.cjs --write && node scripts/gen-inventory-manifest.cjs --write && node scripts/gen-context-index.cjs --write && npm run gen:section-manifest && node scripts/sync-manifest-versions.cjs && npm run gen:install-tree",
"validate:registry": "node scripts/validate-registry.cjs",
"prepack": "npm run build:lib",
"prepare": "npm run build:lib",
@@ -115,7 +116,7 @@
"lint:test-file-count": "node scripts/lint-test-file-count.cjs",
"lint:pr-checks": "node scripts/lint-pr-check-project-dir.cjs",
"lint:changeset": "node scripts/changeset/lint.cjs",
"lint:generated-sync": "node scripts/gen-capability-registry.cjs --check && node scripts/gen-loop-host-contract.cjs --check && node scripts/gen-capability-matrix.cjs --check && node scripts/sync-manifest-versions.cjs --check && node scripts/gen-inventory-manifest.cjs --check && node scripts/generate-package-identity.cjs --check && node scripts/gen-plugin-skills.cjs --check && node scripts/gen-registry.cjs --check && node scripts/gen-adr-index.cjs --check && node scripts/check-glossary-refs.cjs --check && node scripts/lint-compiled-artifact-sync.cjs --check && node scripts/gen-context-index.cjs --check",
"lint:generated-sync": "node scripts/gen-capability-registry.cjs --check && node scripts/gen-loop-host-contract.cjs --check && node scripts/gen-capability-matrix.cjs --check && node scripts/sync-manifest-versions.cjs --check && node scripts/gen-inventory-manifest.cjs --check && node scripts/generate-package-identity.cjs --check && node scripts/gen-plugin-skills.cjs --check && node scripts/gen-registry.cjs --check && node scripts/gen-adr-index.cjs --check && node scripts/check-glossary-refs.cjs --check && node scripts/lint-compiled-artifact-sync.cjs --check && node scripts/gen-context-index.cjs --check && node scripts/gen-section-manifest.cjs --check",
"lint:docs": "node scripts/lint-docs-required.cjs",
"lint:qa-smells": "node scripts/qa-smell-ratchet.cjs",
"lint:legacy-name": "node scripts/lint-legacy-dir-name.cjs",

View File

@@ -0,0 +1,590 @@
#!/usr/bin/env node
'use strict';
/**
* gen-section-manifest.cjs — generates gsd-core/workflows/section-manifest.json
* from the `<!-- gsd:section -->` markers in gsd-core/workflows/*.md (ADR-1671
* epic #1671, Phase 5 / issue #2932, `.gsd/phase/chore-2932-init-section-manifest/40-design.md`).
*
* Reuses `parseWorkflowSections` from the compiled `workflow-fragments.cjs`
* (src/workflow-fragments.cts, Phase 3 / #2930) UNCHANGED — this module never
* re-implements marker parsing (design "Rejected #6"; a second parser is the
* `DEFECT.GENERATIVE-FIX` divergence class).
*
* The committed artifact is placed INSIDE the `gsd-core/` tree (not `docs/`,
* unlike `docs/CONTEXT-INDEX.json`/`docs/INVENTORY-MANIFEST.json`) because it
* must SHIP: `bin/install.js`'s `copyWithPathReplacement` only copies
* `gsd-core/`, and Phase 5's run-time selection (a later commit) reads this
* artifact from the INSTALLED tree, not the dev repo. `copyWithPathReplacement`
* only runs `composeWorkflow`/converters on `*.md` — a `.json` leaf falls
* through to a plain `fs.copyFileSync`, so the artifact ships byte-identical.
*
* Per design's "Rejected" list, the manifest carries NEITHER section content
* (would duplicate every section's bytes, fighting Phase 4's emitted-byte
* caps) NOR line numbers (re-drifts on any line shift, and per-runtime
* converters rewrite text so ranges would differ per runtime). It carries
* only `{id, when, read}` triples — `read` is a POSIX-normalized path,
* relative to the repo root, of the step file the section body was moved to.
*
* Usage:
* node scripts/gen-section-manifest.cjs # print to stdout
* node scripts/gen-section-manifest.cjs --write # write the manifest
* node scripts/gen-section-manifest.cjs --check # exit 1 if stale/invalid
* node scripts/gen-section-manifest.cjs --check --json # same, + typed report
* node scripts/gen-section-manifest.cjs --write --workflows-dir <p> --manifest-path <p>
* # override the two hardcoded
* # repo-root paths (tests point
* # the real CLI at a temp fixture
* # tree with no fs monkeypatching)
*
* Only `.md` files directly inside `--workflows-dir` are scanned (not files
* already inside a `<workflow>/steps/` subdirectory — those are MOVED-TO
* output, never source-with-markers). A workflow with zero explicit sections
* (88 of 89 today) contributes nothing to the manifest and is never
* orphan-checked — orphan-checking is scoped only to a workflow's OWN
* `steps/` directory, and only for workflows that declare at least one
* `gsd:section` marker.
*
* `--check` fails closed (exit 1, never a stack trace) on:
* - the compiled `workflow-fragments.cjs` dependency being unbuilt/unloadable
* (FAIL_LIB_NOT_BUILT — `npm run build:lib` has not run)
* - the live build itself failing: a marker's derived step file does not
* exist on disk (FAIL_MISSING_STEP_FILE), a step file in a managed
* workflow's `steps/` dir is referenced by no marker AND no reachable
* prose reference (FAIL_ORPHAN_STEP_FILE — see `findOrphanStepFiles`),
* or the source itself fails to parse (FAIL_SOURCE_PARSE_ERROR, wraps
* `parseWorkflowSections`' typed `WorkflowFragmentsError`)
* - the committed manifest: absent (FAIL_MANIFEST_MISSING), empty/unparseable
* JSON (FAIL_MANIFEST_UNPARSEABLE), valid JSON but the wrong shape
* (FAIL_MANIFEST_MALFORMED_SHAPE — `0`, `"s"`, `[]`, `null`, `true` all
* land here), or parseable-and-shaped but not equal to the live build
* (FAIL_STALE)
*
* `--write --json` reports the same typed envelope on failure: the compiled
* dependency being unloadable (FAIL_LIB_NOT_BUILT, same as above) or the
* atomic write itself failing (FAIL_WRITE_ERROR — see `writeManifestAtomically`).
*
* Orphan detection (`findOrphanStepFiles`) is plain substring reachability
* over prose, NOT a second marker parser: starting from the parent workflow's
* raw text, it does a fixed-point search for the literal token
* `steps/<basename>` across the parent text and every already-reached step
* file's own text (so a step file that is itself an on-demand delegation
* TARGET of another step file — e.g. `regression-gate.md`'s own "Read and
* execute `.../steps/regression-gate-run.md`" line — is correctly resolved
* as non-orphan without the generator needing to know that convention).
*
* `--write` writes atomically: content lands at a same-directory temp path
* first, then `fs.renameSync` swaps it into place. If either step throws, the
* temp path is removed (best-effort) and the target manifest is left exactly
* as it was — never truncated or partially written.
*/
const fs = require('node:fs');
const path = require('node:path');
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
const ROOT = path.resolve(__dirname, '..');
const WORKFLOWS_DIR = path.join(ROOT, 'gsd-core', 'workflows');
const MANIFEST_PATH = path.join(WORKFLOWS_DIR, 'section-manifest.json');
// ─── Typed reason enum (CONTRIBUTING.md "Prohibited: Raw Text Matching") ───────
/**
* Stable reason codes for `checkReport`'s `reason` field. Tests assert via
* `assert.equal(report.reason, REASON.X)` rather than regex-matching the
* human-readable prose the non-JSON `--check` mode still writes to
* stdout/stderr.
*
* Adding a new reason requires updating this map AND the test that locks
* `Object.keys(REASON).sort()` as a coordinated change.
*/
const REASON = Object.freeze({
OK_UP_TO_DATE: 'ok_up_to_date',
FAIL_STALE: 'fail_stale',
FAIL_MANIFEST_MISSING: 'fail_manifest_missing',
FAIL_MANIFEST_UNPARSEABLE: 'fail_manifest_unparseable',
FAIL_MANIFEST_MALFORMED_SHAPE: 'fail_manifest_malformed_shape',
FAIL_MISSING_STEP_FILE: 'fail_missing_step_file',
FAIL_ORPHAN_STEP_FILE: 'fail_orphan_step_file',
FAIL_SOURCE_PARSE_ERROR: 'fail_source_parse_error',
FAIL_LIB_NOT_BUILT: 'fail_lib_not_built',
FAIL_WRITE_ERROR: 'fail_write_error',
});
// ─── Loaders ──────────────────────────────────────────────────────────────────
const WORKFLOW_FRAGMENTS_LIB_PATH = path.join(ROOT, 'gsd-core', 'bin', 'lib', 'workflow-fragments.cjs');
/**
* Load the compiled workflow-fragments library. The artifact is a gitignored
* tsc build output of src/workflow-fragments.cts and only exists after
* `npm run build:lib`. Throws a clean `ManifestBuildError` (REASON.FAIL_LIB_NOT_BUILT;
* never a bare MODULE_NOT_FOUND stack) naming the remedy when it is missing —
* `ManifestBuildError` extends `ExitError`, so `runMain` still prints only the
* friendly message, and `checkReport`/the `--write --json` path can still read
* `.reason`/`.subject` off it to emit the typed envelope.
*
* @returns {{ parseWorkflowSections: Function }}
*/
function loadWorkflowFragmentsLib() {
try {
delete require.cache[require.resolve(WORKFLOW_FRAGMENTS_LIB_PATH)];
return require(WORKFLOW_FRAGMENTS_LIB_PATH);
} catch (err) {
const subject = relPosix(ROOT, WORKFLOW_FRAGMENTS_LIB_PATH);
throw new ManifestBuildError(
REASON.FAIL_LIB_NOT_BUILT,
subject,
`Cannot load ${subject}: ${err && err.message}\n` +
'Run:\n npm run build:lib\n',
);
}
}
// ─── POSIX path helpers ─────────────────────────────────────────────────────
/** Unconditional backslash->forward-slash normalization (CONTEXT.md
* path-separator-normalization rule: never gate on `path.sep`). */
function toPosix(p) {
return p.replace(/\\/g, '/');
}
/**
* Repo-root-relative POSIX path for a file under `repoRoot`.
*
* @param {string} repoRoot
* @param {string} absPath
*/
function relPosix(repoRoot, absPath) {
return toPosix(path.relative(repoRoot, absPath));
}
// ─── Orphan detection (plain substring reachability, not a marker parser) ────
/**
* Fixed-point reachability scan over a managed workflow's `steps/` directory:
* a step file is "reached" once the literal token `steps/<its-basename>`
* appears in the parent workflow's raw text OR in the text of any
* already-reached step file (so nested delegation — a step file that itself
* names another step file — resolves without the generator knowing that
* convention explicitly). Returns the SORTED list of `.md` basenames in
* `stepsDir` that are never reached (i.e. orphans). Returns `[]` if
* `stepsDir` does not exist.
*
* @param {string} parentText - the workflow.md's raw source text
* @param {string} stepsDir - absolute path to `<workflow>/steps/`
* @returns {string[]}
*/
function findOrphanStepFiles(parentText, stepsDir) {
if (!fs.existsSync(stepsDir)) return [];
const files = fs
.readdirSync(stepsDir, { withFileTypes: true })
.filter((d) => d.isFile() && d.name.endsWith('.md'))
.map((d) => d.name)
.sort();
const textOf = new Map(files.map((f) => [f, fs.readFileSync(path.join(stepsDir, f), 'utf8')]));
const reached = new Set();
const haystacks = [parentText];
let changed = true;
while (changed) {
changed = false;
for (const f of files) {
if (reached.has(f)) continue;
const token = `steps/${f}`;
if (haystacks.some((h) => h.includes(token))) {
reached.add(f);
haystacks.push(textOf.get(f));
changed = true;
}
}
}
return files.filter((f) => !reached.has(f));
}
// ─── Live manifest build ─────────────────────────────────────────────────────
/** Thrown by `buildFreshManifest`/`loadWorkflowFragmentsLib`/`writeManifestAtomically`
* for every fail-closed condition below; carries a stable `reason` + the
* offending `subject` path so `checkReport`/the `--write --json` path never
* need to string-match a message. Extends `ExitError` (not plain `Error`) so
* `runMain` still prints only the friendly `message` — never a bare stack
* trace — for the `default`/`--write` (non-`--json`) code paths that let it
* propagate uncaught, exactly like every other `ExitError`. */
class ManifestBuildError extends ExitError {
constructor(reason, subject, message) {
super(1, message);
this.name = 'ManifestBuildError';
this.reason = reason;
this.subject = subject;
}
}
/**
* Scan `workflowsDir` for `.md` files carrying `gsd:section` markers and
* build the live (freshly-derived) manifest: `{ sections: [{id, when, read}] }`,
* in document order (files sorted by filename, sections in each file's own
* document order). Throws `ManifestBuildError` on any fail-closed condition
* (missing step file, orphan step file, unparseable source).
*
* @param {string} workflowsDir - defaults to the real repo-root gsd-core/workflows/
* @param {string} repoRoot - root `read` paths are computed relative to
* @returns {{ sections: Array<{id: string, when: string, read: string}> }}
*/
function buildFreshManifest(workflowsDir = WORKFLOWS_DIR, repoRoot = ROOT) {
const { parseWorkflowSections } = loadWorkflowFragmentsLib();
const workflowFiles = fs
.readdirSync(workflowsDir, { withFileTypes: true })
.filter((d) => d.isFile() && d.name.endsWith('.md'))
.map((d) => d.name)
.sort();
const sections = [];
for (const fileName of workflowFiles) {
const filePath = path.join(workflowsDir, fileName);
const relSourcePath = relPosix(repoRoot, filePath);
let content;
try {
content = fs.readFileSync(filePath, 'utf8');
} catch (err) {
throw new ManifestBuildError(REASON.FAIL_SOURCE_PARSE_ERROR, relSourcePath, `Cannot read ${relSourcePath}: ${err && err.message}`);
}
let parsed;
try {
parsed = parseWorkflowSections(content, relSourcePath);
} catch (err) {
throw new ManifestBuildError(REASON.FAIL_SOURCE_PARSE_ERROR, relSourcePath, `${relSourcePath}: ${err && err.message}`);
}
const explicitSections = parsed.filter((s) => s.explicit);
if (explicitSections.length === 0) continue;
const workflowName = fileName.replace(/\.md$/, '');
const stepsDir = path.join(workflowsDir, workflowName, 'steps');
for (const section of explicitSections) {
const stepFileAbs = path.join(stepsDir, `${section.id}.md`);
if (!fs.existsSync(stepFileAbs)) {
const relStepPath = relPosix(repoRoot, stepFileAbs);
throw new ManifestBuildError(
REASON.FAIL_MISSING_STEP_FILE,
relStepPath,
`${relSourcePath}: section "${section.id}" (when="${section.when}") names step file ${relStepPath}, which does not exist`,
);
}
sections.push({
id: section.id,
when: section.when,
read: relPosix(repoRoot, stepFileAbs),
});
}
const orphans = findOrphanStepFiles(content, stepsDir);
if (orphans.length > 0) {
const relOrphanPath = relPosix(repoRoot, path.join(stepsDir, orphans[0]));
throw new ManifestBuildError(
REASON.FAIL_ORPHAN_STEP_FILE,
relOrphanPath,
`${relOrphanPath} is not referenced by any gsd:section marker or reachable "steps/" reference in ${relSourcePath}`,
);
}
}
return { sections };
}
// ─── Serialization ────────────────────────────────────────────────────────────
/**
* @param {{ sections: Array<{id: string, when: string, read: string}> }} manifest
* @returns {string}
*/
function serializeManifest(manifest) {
return JSON.stringify(manifest, null, 2) + '\n';
}
/**
* True when `parsed` has the expected committed-manifest shape: a plain
* object (not an array, not null) carrying a `sections` array of
* `{id, when, read}` string triples. Rejects `0`, `"s"`, `[]`, `null`, `true`.
*
* @param {unknown} parsed
* @returns {boolean}
*/
function isValidManifestShape(parsed) {
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return false;
if (!Array.isArray(parsed.sections)) return false;
return parsed.sections.every(
(s) => s !== null && typeof s === 'object' && typeof s.id === 'string' && typeof s.when === 'string' && typeof s.read === 'string',
);
}
// ─── Atomic write ─────────────────────────────────────────────────────────────
/**
* Write `content` to `targetPath` atomically: write to a same-directory temp
* path, then `fs.renameSync` it into place (same filesystem, so the rename is
* atomic). On ANY failure (the write or the rename), the temp path is removed
* best-effort and a `ManifestBuildError` (REASON.FAIL_WRITE_ERROR, subject
* `targetPath`) is thrown — `targetPath` is left exactly as it was before the
* call, never truncated or partially written. `ManifestBuildError` extends
* `ExitError`, so a caller that lets it propagate uncaught (e.g. `--write`
* without `--json`) still gets only the friendly message, never a stack trace.
*
* @param {string} targetPath
* @param {string} content
*/
function writeManifestAtomically(targetPath, content) {
const tmpPath = path.join(path.dirname(targetPath), `.${path.basename(targetPath)}.tmp-${process.pid}-${Date.now()}`);
try {
fs.writeFileSync(tmpPath, content, 'utf8');
fs.renameSync(tmpPath, targetPath);
} catch (err) {
try {
fs.unlinkSync(tmpPath);
} catch (_cleanupErr) {
// best-effort: tmpPath may never have been created (writeFileSync itself threw)
}
throw new ManifestBuildError(REASON.FAIL_WRITE_ERROR, targetPath, `Cannot write ${targetPath}: ${err && err.message}`);
}
}
// ─── Typed check report ───────────────────────────────────────────────────────
/**
* Empty-report shape shared by every early-exit branch below.
*
* @returns {{ subject: string | null }}
*/
function emptyReportFields() {
return { subject: null };
}
/**
* Compute the full `--check` result as a typed, non-throwing report.
*
* @param {string} [workflowsDir]
* @param {string} [manifestPath]
* @param {string} [repoRoot]
* @returns {{ ok: boolean, reason: string, subject: string | null, message: string }}
*/
function checkReport(workflowsDir = WORKFLOWS_DIR, manifestPath = MANIFEST_PATH, repoRoot = ROOT) {
let live;
try {
live = buildFreshManifest(workflowsDir, repoRoot);
} catch (err) {
if (err instanceof ManifestBuildError) {
return { ok: false, reason: err.reason, subject: err.subject, message: `${err.message}\n` };
}
throw err;
}
if (!fs.existsSync(manifestPath)) {
return {
ok: false,
reason: REASON.FAIL_MANIFEST_MISSING,
...emptyReportFields(),
subject: manifestPath,
message: `${manifestPath} does not exist. Run:\n node scripts/gen-section-manifest.cjs --write\n`,
};
}
let committedText;
try {
committedText = fs.readFileSync(manifestPath, 'utf8');
} catch (err) {
return {
ok: false,
reason: REASON.FAIL_MANIFEST_UNPARSEABLE,
subject: manifestPath,
message: `Cannot read ${manifestPath}: ${err && err.message}\n`,
};
}
let committed;
try {
committed = JSON.parse(committedText);
} catch (err) {
return {
ok: false,
reason: REASON.FAIL_MANIFEST_UNPARSEABLE,
subject: manifestPath,
message: `${manifestPath} is not valid JSON: ${err && err.message}\n` +
'Run:\n node scripts/gen-section-manifest.cjs --write\n',
};
}
if (!isValidManifestShape(committed)) {
return {
ok: false,
reason: REASON.FAIL_MANIFEST_MALFORMED_SHAPE,
subject: manifestPath,
message: `${manifestPath} is valid JSON but does not have the expected {sections:[{id,when,read}]} shape.\n` +
'Run:\n node scripts/gen-section-manifest.cjs --write\n',
};
}
if (JSON.stringify(committed) !== JSON.stringify(live)) {
return {
ok: false,
reason: REASON.FAIL_STALE,
subject: manifestPath,
message: `${manifestPath} is stale. Run:\n node scripts/gen-section-manifest.cjs --write\n`,
};
}
return {
ok: true,
reason: REASON.OK_UP_TO_DATE,
subject: null,
message: `${manifestPath} is up to date (${live.sections.length} section${live.sections.length === 1 ? '' : 's'}).\n`,
};
}
// ─── Argument parsing ─────────────────────────────────────────────────────────
/**
* @param {string|undefined} value
* @returns {boolean}
*/
function isMissingPathValue(value) {
return value === undefined || value === '' || value.startsWith('-');
}
/**
* @param {string[]} argv
* @returns {{ mode: 'check'|'write'|'default'|'unknown', json: boolean, workflowsDir: string, manifestPath: string, repoRoot: string, unknownArg?: string, usageMessage?: string }}
*/
function parseArgs(argv) {
const opts = { mode: 'default', json: false, workflowsDir: WORKFLOWS_DIR, manifestPath: MANIFEST_PATH, repoRoot: ROOT };
let sawCheck = false;
let sawWrite = false;
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--check') {
sawCheck = true;
opts.mode = 'check';
} else if (arg === '--write') {
sawWrite = true;
opts.mode = 'write';
} else if (arg === '--json') {
opts.json = true;
} else if (arg === '--workflows-dir' || arg === '--manifest-path' || arg === '--repo-root') {
const value = argv[i + 1];
if (isMissingPathValue(value)) {
return {
...opts,
mode: 'unknown',
unknownArg: arg,
usageMessage: `${arg} requires a non-empty path argument (got ${value === undefined ? 'nothing' : JSON.stringify(value)})`,
};
}
i++;
if (arg === '--workflows-dir') opts.workflowsDir = path.resolve(value);
else if (arg === '--manifest-path') opts.manifestPath = path.resolve(value);
else opts.repoRoot = path.resolve(value);
} else {
opts.mode = 'unknown';
opts.unknownArg = arg;
}
}
if (sawCheck && sawWrite) {
return { ...opts, mode: 'unknown', usageMessage: '--check and --write are mutually exclusive' };
}
return opts;
}
// ─── Main ─────────────────────────────────────────────────────────────────────
function main() {
const opts = parseArgs(process.argv.slice(2));
if (opts.mode === 'unknown') {
process.stderr.write('Usage: gen-section-manifest.cjs [--write|--check] [--json] [--workflows-dir <path>] [--manifest-path <path>] [--repo-root <path>]\n');
if (opts.usageMessage) process.stderr.write(`${opts.usageMessage}\n`);
throw new ExitError(1);
}
if (opts.mode === 'default') {
process.stdout.write(serializeManifest(buildFreshManifest(opts.workflowsDir, opts.repoRoot)));
return;
}
if (opts.mode === 'check') {
const report = checkReport(opts.workflowsDir, opts.manifestPath, opts.repoRoot);
if (opts.json) {
process.stdout.write(JSON.stringify({ ok: report.ok, reason: report.reason, subject: report.subject }) + '\n');
} else if (report.ok) {
process.stdout.write(report.message);
}
if (!report.ok) {
throw new ExitError(1, opts.json ? undefined : report.message);
}
return;
}
// opts.mode === 'write'. Same typed-envelope pattern as --check --json above:
// a ManifestBuildError (FAIL_LIB_NOT_BUILT from buildFreshManifest,
// FAIL_WRITE_ERROR from writeManifestAtomically) is caught here so --json
// still emits {ok, reason, subject} instead of letting the error propagate
// to runMain unobserved by the JSON caller.
let manifest;
let writeErr;
try {
manifest = buildFreshManifest(opts.workflowsDir, opts.repoRoot);
writeManifestAtomically(opts.manifestPath, serializeManifest(manifest));
} catch (err) {
if (!(err instanceof ManifestBuildError)) throw err;
writeErr = err;
}
if (opts.json) {
process.stdout.write(JSON.stringify(
writeErr
? { ok: false, reason: writeErr.reason, subject: writeErr.subject }
: { ok: true, reason: REASON.OK_UP_TO_DATE, subject: null },
) + '\n');
} else if (!writeErr) {
process.stdout.write(`Wrote ${opts.manifestPath}\n ${manifest.sections.length} section${manifest.sections.length === 1 ? '' : 's'}\n`);
}
if (writeErr) {
throw new ExitError(1, opts.json ? undefined : writeErr.message);
}
}
// ─── Exports (for tests) ──────────────────────────────────────────────────────
module.exports = {
loadWorkflowFragmentsLib,
findOrphanStepFiles,
buildFreshManifest,
serializeManifest,
isValidManifestShape,
writeManifestAtomically,
checkReport,
parseArgs,
ManifestBuildError,
REASON,
WORKFLOW_FRAGMENTS_LIB_PATH,
WORKFLOWS_DIR,
MANIFEST_PATH,
ROOT,
};
// ─── CLI entry point ──────────────────────────────────────────────────────────
if (require.main === module) {
runMain(main);
}

View File

@@ -63,8 +63,12 @@ function routeInitCommand({ init, args, cwd, raw, error }: RouteInitCommandOptio
unknownMessage: (_subcommand: string, available: string[]) => `Unknown init workflow: ${_subcommand}\nAvailable: ${available.join(', ')}`,
handlers: {
'execute-phase': () => {
const namedArgs = parseNamedArgs(args, [], ['validate', 'tdd']);
init.cmdInitExecutePhase(cwd, args[2], raw, { validate: namedArgs['validate'], tdd: namedArgs['tdd'] });
// #2932: 'wave' is boolean/token-presence (parseNamedArgs's booleanFlags
// semantics already match the design's token-presence rule: `--wave` alone,
// `--wave 0`, and duplicate `--wave 1 --wave 2` all resolve to `true`;
// near-miss tokens `--waves`/`--wave-filter` never match the exact `--wave` token).
const namedArgs = parseNamedArgs(args, [], ['validate', 'tdd', 'wave']);
init.cmdInitExecutePhase(cwd, args[2], raw, { validate: namedArgs['validate'], tdd: namedArgs['tdd'], wave: namedArgs['wave'] });
},
'plan-phase': () => {
const namedArgs = parseNamedArgs(args, ['granularity'], ['validate', 'tdd']);

View File

@@ -47,6 +47,8 @@ import verificationMod = require('./verification.cjs');
import uatPredicateMod = require('./uat-predicate.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports -- agent-install-check.cjs is an export= CommonJS module
import agentInstallCheck = require('./agent-install-check.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports -- section-manifest.cjs is compiled from section-manifest.cts's named exports; imported as a namespace to read selectSections/SelectableSection/InvocationFacts off module.exports directly (#2932).
import sectionManifest = require('./section-manifest.cjs');
const { checkAgentsInstalled } = agentInstallCheck;
// eslint-disable-next-line @typescript-eslint/no-require-imports -- git-base-branch.cjs is an export= CommonJS module
import gitBaseBranch = require('./git-base-branch.cjs');
@@ -322,6 +324,134 @@ function getInitGitState(cwd: string): GitState {
};
}
// #2932 (Phase 5, ADR-1671): shipped, generated artifact — see
// scripts/gen-section-manifest.cjs and gsd-core/workflows/section-manifest.json.
// Resolved the same way model-catalog.cts resolves model-catalog.json: relative
// to the compiled module's own directory (gsd-core/bin/lib -> gsd-core/workflows),
// with a GSD_SECTION_MANIFEST env override so tests can point at a temp fixture
// (missing/malformed-JSON degraded-path coverage) without mutating the shipped
// artifact — the shipped file is a shared, concurrently-read resource across
// parallel test runs and must never be moved/corrupted in place.
const _sectionManifestCandidatePath = (): string =>
process.env['GSD_SECTION_MANIFEST']
? path.resolve(process.env['GSD_SECTION_MANIFEST'])
: path.resolve(__dirname, '..', '..', 'workflows', 'section-manifest.json');
/** A manifest entry as shipped on disk: {@link sectionManifest.SelectableSection} plus the `read` step-file path. */
interface ManifestSection extends sectionManifest.SelectableSection {
readonly read: string;
}
/**
* Loads and shape-validates the generated section manifest's `sections` array.
* Returns `null` — never throws — when the artifact is missing, unreadable,
* malformed JSON, or valid JSON of the wrong shape (matrix rows 13/14/57/58:
* a missing derived artifact must not break dispatch).
*/
function loadSectionManifestSections(): ManifestSection[] | null {
try {
const raw = fs.readFileSync(_sectionManifestCandidatePath(), 'utf8');
const parsed: unknown = JSON.parse(raw);
if (parsed === null || typeof parsed !== 'object') return null;
const sections = (parsed as Record<string, unknown>)['sections'];
if (!Array.isArray(sections)) return null;
for (const section of sections) {
if (
!section ||
typeof section !== 'object' ||
typeof (section as Record<string, unknown>)['id'] !== 'string' ||
typeof (section as Record<string, unknown>)['when'] !== 'string' ||
typeof (section as Record<string, unknown>)['read'] !== 'string'
) {
return null;
}
}
return sections as ManifestSection[];
} catch {
return null;
}
}
/**
* `state:has-prior-phases` ground truth (design doc §Behavior table, regression-gate
* body: "Skip if: this is the first phase (no prior phases)"): TRUE when at least
* one OTHER phase directory under `.planning/phases/` contains a `*-VERIFICATION.md`
* file. Bounded, non-throwing — an unreadable phases directory degrades to `false`
* rather than surfacing an error from an init query.
*/
function detectHasPriorPhases(cwd: string, phaseInfo: Record<string, unknown> | null): boolean {
const phasesDir = path.join(planningDir(cwd), 'phases');
const currentDirName = phaseInfo?.['directory']
? path.basename(phaseInfo['directory'] as string)
: null;
try {
if (!fs.existsSync(phasesDir)) return false;
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory() || entry.name === currentDirName) continue;
let files: string[];
try {
files = fs.readdirSync(path.join(phasesDir, entry.name));
} catch {
continue;
}
if (files.some((f) => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md')) {
return true;
}
}
return false;
} catch {
return false;
}
}
/**
* Builds the `section_manifest` init-bundle field (#2932 Deliverable 2): resolves
* {@link sectionManifest.InvocationFacts} from this invocation, loads the generated
* manifest, and partitions it via the pure {@link sectionManifest.selectSections}
* evaluator. Returns `null` on any degraded condition (missing/malformed artifact,
* or an unexpected throw from the evaluator itself) — this field is additive and
* optional, never load-bearing for dispatch (Hyrum's Law: 22 direct init-bundle
* dependents must be unaffected by its absence).
*/
function buildSectionManifestField(
cwd: string,
phaseInfo: Record<string, unknown> | null,
options: Record<string, unknown>,
): Record<string, unknown> | null {
const sections = loadSectionManifestSections();
if (!sections) return null;
const rawPhaseNumber = phaseInfo?.['phase_number'];
const phaseNumber =
typeof rawPhaseNumber === 'string'
? rawPhaseNumber
: typeof rawPhaseNumber === 'number'
? String(rawPhaseNumber)
: null;
const facts: sectionManifest.InvocationFacts = {
waveFlag: options['wave'] === true,
phaseNumber,
hasPriorPhases: detectHasPriorPhases(cwd, phaseInfo),
};
try {
const selection = sectionManifest.selectSections(sections, facts);
const readById = new Map(sections.map((s) => [s.id, s.read]));
return {
workflow: 'execute-phase',
included: selection.included,
excluded: selection.excluded,
read: selection.included
.map((id) => readById.get(id))
.filter((p): p is string => typeof p === 'string'),
};
} catch {
return null;
}
}
function cmdInitExecutePhase(
cwd: string,
phase: string,
@@ -455,6 +585,9 @@ function cmdInitExecutePhase(
}
}
// #2932 (Phase 5): additive, optional field — degrades to null, never throws.
result['section_manifest'] = buildSectionManifestField(cwd, phaseInfo, options);
output(withProjectRoot(cwd, result), raw);
}

182
src/section-manifest.cts Normal file
View File

@@ -0,0 +1,182 @@
/**
* Section Manifest — pure `when=` evaluator over `InvocationFacts`, mapping
* a document-order list of parsed `<!-- gsd:section -->` sections (Phase 3,
* `src/workflow-fragments.cts`) to an included/excluded partition for one
* concrete invocation (ADR-1671 epic #1671, Phase 5 / issue #2932,
* `.gsd/phase/chore-2932-init-section-manifest/40-design.md`).
*
* Pure module: no I/O, no dependency beyond node built-ins and the sibling
* compiled module `workflow-fragments.cjs`, whose {@link
* workflowFragments.WHEN_VOCABULARY} is imported and never redeclared here
* (DEFECT.GENERATIVE-FIX — a second frozen copy of the same 4 strings would
* silently desync from the source of truth the moment either side is edited
* without the other).
*
* ## The evaluator is a LOOKUP, not a parser
*
* Derived from Greenspun's Tenth Rule (ADR-1671:69 cites it by name) and
* binding on this implementation: `when=` is a closed, 4-entry vocabulary.
* {@link WHEN_PREDICATES} is a total map from each frozen vocabulary entry
* to exactly one predicate over {@link InvocationFacts}. It MUST NOT
* tokenize, split on operators, or interpret structure in the `when=`
* string — the moment it parses, the ad-hoc language has begun. An
* unrecognized `when=` value fails closed via {@link selectSections}
* throwing a `TypeError` carrying `.reason = REASON.UNKNOWN_WHEN`; it is
* never silently excluded (Postel's Law: liberal on FORMAT elsewhere in the
* pipeline, strict on this SEMANTIC boundary — matching the discipline
* Phase 3 already established for the same vocabulary at parse time).
*
* ## Totality over facts
*
* Every predicate treats an absent/missing fact key as falsy WITHOUT
* throwing — {@link InvocationFacts} is a plain data object handed in by a
* caller (the init CLI seam) that may not always populate every field, and
* this module must never surprise that caller with an exception for an
* omission rather than a malformed `when=` value.
*
* ## Partition invariant
*
* {@link selectSections} returns `included` and `excluded` id arrays that
* together contain every input section's `id` exactly once, in the SAME
* relative document order they appeared in the input — never mutating the
* input array or its elements.
*
* ADR-457 build-at-publish: compiled by tsc to
* gsd-core/bin/lib/section-manifest.cjs (gitignored).
*/
// eslint-disable-next-line @typescript-eslint/no-require-imports -- workflow-fragments.cjs is a CommonJS module compiled from a sibling .cts source; `import x = require()` reads its module.exports namespace directly.
import workflowFragments = require('./workflow-fragments.cjs');
/**
* The facts a concrete invocation supplies to the evaluator. Every field is
* a plain, already-resolved value — no parsing, no derivation — computed by
* the caller (the init CLI seam) before {@link selectSections} is invoked.
*/
export interface InvocationFacts {
/** Whether the `--wave` flag's literal token was present on the invocation (token-presence, not value-truthiness). */
readonly waveFlag: boolean;
/** The invocation's phase number, or `null` when absent. A decimal (`X.Y`) phase number is a gap-closure phase. */
readonly phaseNumber: string | null;
/** Whether prior phases exist for this invocation. */
readonly hasPriorPhases: boolean;
}
/** A single input to {@link selectSections}: structurally compatible with {@link workflowFragments.WorkflowSection}. */
export interface SelectableSection {
readonly id: string;
readonly when: string;
}
/** The result of partitioning a document-order section list against one set of {@link InvocationFacts}. */
export interface SectionSelection {
/** Ids of sections whose `when=` predicate held, in document order. */
readonly included: string[];
/** Ids of sections whose `when=` predicate did not hold, in document order. */
readonly excluded: string[];
}
/**
* Frozen, stable reason codes for every `fail()` throw site in this module.
* Tests assert via `assert.equal(err.reason, REASON.X)` rather than
* regex-/substring-matching the human-readable message (CONTRIBUTING.md
* "Prohibited: Raw Text Matching on Test Outputs"; shape copied from
* `src/workflow-fragments.cts`'s own `REASON` export) — a message reword
* must never silently pass a test that exists to catch a behavior
* regression.
*
* Adding a new reason requires updating this map AND the test that locks
* `Object.keys(REASON).sort()` as a coordinated change.
*/
export const REASON = Object.freeze({
UNKNOWN_WHEN: 'unknown_when',
});
/** A `TypeError` carrying a stable {@link REASON} code alongside the human-readable message. */
export interface SectionManifestError extends TypeError {
readonly reason: string;
}
/**
* Throws a `TypeError` naming the offending `when` value, carrying `reason`
* (one of {@link REASON}) as a typed property so callers/tests never need
* to pattern-match the message prose.
*/
function fail(reason: string, message: string): never {
const err = new TypeError(`section-manifest: ${message}`) as TypeError & { reason: string };
err.reason = reason;
throw err;
}
/**
* Total map from each frozen {@link workflowFragments.WHEN_VOCABULARY}
* entry to exactly one predicate over {@link InvocationFacts}. This is a
* LOOKUP, never a parser — see the module doc comment's "The evaluator is a
* LOOKUP, not a parser" section. Semantics confirmed against the section
* bodies themselves (design doc "Semantics confirmed against the section
* bodies themselves, not inferred from the id"):
*
* - `gap-closure-artifacts` — "For decimal/polish phases only (X.Y
* pattern) … Skip if phase number has no decimal" -> `state:gap-closure-phase`.
* - `regression-gate` — "Skip if: this is the first phase (no prior
* phases)" -> `state:has-prior-phases`.
* - `partial-wave` — "If `WAVE_FILTER` was used" -> `flag:--wave`.
*/
export const WHEN_PREDICATES: Readonly<Record<string, (facts: InvocationFacts) => boolean>> = Object.freeze(
Object.assign(Object.create(null) as Record<string, (facts: InvocationFacts) => boolean>, {
always: () => true,
'flag:--wave': (facts: InvocationFacts) => facts.waveFlag === true,
'state:gap-closure-phase': (facts: InvocationFacts) =>
typeof facts.phaseNumber === 'string' && facts.phaseNumber.includes('.'),
'state:has-prior-phases': (facts: InvocationFacts) => facts.hasPriorPhases === true,
}),
);
// Coordinated-change guard, checked at module load: every entry of the
// frozen WHEN_VOCABULARY (imported, never redeclared — see module doc
// comment) must have exactly one predicate here, and vice versa. This is
// the load-bearing half of the DEFECT.GENERATIVE-FIX parity contract; the
// test-level half (50-test-matrix.md rows 21-23) additionally asserts it
// from the vocabulary's own export so a 5th vocabulary entry added without
// a predicate fails loudly rather than silently falling through to
// REASON.UNKNOWN_WHEN only at run time.
for (const when of workflowFragments.WHEN_VOCABULARY) {
if (!Object.hasOwn(WHEN_PREDICATES, when)) {
throw new Error(`section-manifest: WHEN_VOCABULARY entry "${when}" has no predicate in WHEN_PREDICATES`);
}
}
/**
* Partition `sections` (document order) into `included`/`excluded` id
* arrays for one set of `facts`, per {@link WHEN_PREDICATES}. Exact
* partition: every input id appears in exactly one of the two output
* arrays, in the same relative order it appeared in `sections`. Never
* mutates `sections` or its elements.
*
* @param sections - document-order sections carrying at least `{id, when}`
* @param facts - the concrete invocation's resolved facts
* @throws {SectionManifestError} with `.reason = REASON.UNKNOWN_WHEN` when a
* section's `when` value has no entry in {@link WHEN_PREDICATES} (fail
* closed — never silently excluded).
*/
export function selectSections(
sections: readonly SelectableSection[],
facts: InvocationFacts,
): SectionSelection {
const included: string[] = [];
const excluded: string[] = [];
for (const section of sections) {
if (!Object.hasOwn(WHEN_PREDICATES, section.when)) {
fail(REASON.UNKNOWN_WHEN, `section "${section.id}" has unrecognized when= value "${section.when}"`);
}
const predicate = WHEN_PREDICATES[section.when];
if (predicate(facts)) {
included.push(section.id);
} else {
excluded.push(section.id);
}
}
return { included, excluded };
}

View File

@@ -99,7 +99,10 @@ const VERIFY_PHASE_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'v
const AUDIT_FIX_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'audit-fix.md');
// #1857: execute-phase's regression-gate test-command resolution was extracted
// to this step file (execute-phase.md is size-frozen — phase-6 capstone).
const REGRESSION_GATE_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'execute-phase', 'steps', 'regression-gate.md');
// #2932: steps/regression-gate.md now only discovers prior-phase test files and
// delegates (via "Read and execute") to steps/regression-gate-run.md, which
// carries the actual test-command resolution (Makefile/config-get priority).
const REGRESSION_GATE_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'execute-phase', 'steps', 'regression-gate-run.md');
function assertMakefileCheckBeforeNpmTest(filePath, label) {
const content = fs.readFileSync(filePath, 'utf-8');

View File

@@ -75,16 +75,31 @@ describe('execute-phase workflow: wave filtering', () => {
test('workflow has partial-wave completion guardrail', () => {
const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
// handle_partial_wave_execution was extracted to
// gsd-core/workflows/execute-phase/steps/partial-wave.md. The parent now only
// references it via a <gsd:section> pointer, so assert the pointer is present here
// and then read the actual step body from the extracted file below.
assert.ok(
content.includes('<step name="handle_partial_wave_execution">'),
content.includes('gsd-core/workflows/execute-phase/steps/partial-wave.md'),
'workflow should reference the extracted partial-wave step file'
);
const PARTIAL_WAVE_STEP_PATH = path.join(
__dirname, '..', 'gsd-core', 'workflows', 'execute-phase', 'steps', 'partial-wave.md'
);
assert.ok(fs.existsSync(PARTIAL_WAVE_STEP_PATH), 'partial-wave step file should exist');
const stepContent = fs.readFileSync(PARTIAL_WAVE_STEP_PATH, 'utf-8');
assert.ok(
stepContent.includes('<step name="handle_partial_wave_execution">'),
'workflow should have a partial wave handling step'
);
assert.ok(
content.includes('Do NOT run phase verification'),
stepContent.includes('Do NOT run phase verification'),
'partial wave step should skip phase verification'
);
assert.ok(
content.includes('Do NOT mark the phase complete'),
stepContent.includes('Do NOT mark the phase complete'),
'partial wave step should skip phase completion'
);
});

View File

@@ -251,8 +251,11 @@
"gsd-core/workflows/execute-phase.md",
"gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md",
"gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md",
"gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md",
"gsd-core/workflows/execute-phase/steps/partial-wave.md",
"gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md",
"gsd-core/workflows/execute-phase/steps/post-merge-gate.md",
"gsd-core/workflows/execute-phase/steps/regression-gate-run.md",
"gsd-core/workflows/execute-phase/steps/regression-gate.md",
"gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md",
"gsd-core/workflows/execute-plan.md",
@@ -303,6 +306,7 @@
"gsd-core/workflows/resume-project.md",
"gsd-core/workflows/review.md",
"gsd-core/workflows/scan.md",
"gsd-core/workflows/section-manifest.json",
"gsd-core/workflows/secure-phase.md",
"gsd-core/workflows/session-report.md",
"gsd-core/workflows/settings-advanced.md",

View File

@@ -322,8 +322,11 @@
"gsd-core/workflows/execute-phase.md",
"gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md",
"gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md",
"gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md",
"gsd-core/workflows/execute-phase/steps/partial-wave.md",
"gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md",
"gsd-core/workflows/execute-phase/steps/post-merge-gate.md",
"gsd-core/workflows/execute-phase/steps/regression-gate-run.md",
"gsd-core/workflows/execute-phase/steps/regression-gate.md",
"gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md",
"gsd-core/workflows/execute-plan.md",
@@ -374,6 +377,7 @@
"gsd-core/workflows/resume-project.md",
"gsd-core/workflows/review.md",
"gsd-core/workflows/scan.md",
"gsd-core/workflows/section-manifest.json",
"gsd-core/workflows/secure-phase.md",
"gsd-core/workflows/session-report.md",
"gsd-core/workflows/settings-advanced.md",

View File

@@ -321,8 +321,11 @@
"gsd-core/workflows/execute-phase.md",
"gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md",
"gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md",
"gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md",
"gsd-core/workflows/execute-phase/steps/partial-wave.md",
"gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md",
"gsd-core/workflows/execute-phase/steps/post-merge-gate.md",
"gsd-core/workflows/execute-phase/steps/regression-gate-run.md",
"gsd-core/workflows/execute-phase/steps/regression-gate.md",
"gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md",
"gsd-core/workflows/execute-plan.md",
@@ -373,6 +376,7 @@
"gsd-core/workflows/resume-project.md",
"gsd-core/workflows/review.md",
"gsd-core/workflows/scan.md",
"gsd-core/workflows/section-manifest.json",
"gsd-core/workflows/secure-phase.md",
"gsd-core/workflows/session-report.md",
"gsd-core/workflows/settings-advanced.md",

View File

@@ -250,8 +250,11 @@
"gsd-core/workflows/execute-phase.md",
"gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md",
"gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md",
"gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md",
"gsd-core/workflows/execute-phase/steps/partial-wave.md",
"gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md",
"gsd-core/workflows/execute-phase/steps/post-merge-gate.md",
"gsd-core/workflows/execute-phase/steps/regression-gate-run.md",
"gsd-core/workflows/execute-phase/steps/regression-gate.md",
"gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md",
"gsd-core/workflows/execute-plan.md",
@@ -302,6 +305,7 @@
"gsd-core/workflows/resume-project.md",
"gsd-core/workflows/review.md",
"gsd-core/workflows/scan.md",
"gsd-core/workflows/section-manifest.json",
"gsd-core/workflows/secure-phase.md",
"gsd-core/workflows/session-report.md",
"gsd-core/workflows/settings-advanced.md",

View File

@@ -254,8 +254,11 @@
"gsd-core/workflows/execute-phase.md",
"gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md",
"gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md",
"gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md",
"gsd-core/workflows/execute-phase/steps/partial-wave.md",
"gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md",
"gsd-core/workflows/execute-phase/steps/post-merge-gate.md",
"gsd-core/workflows/execute-phase/steps/regression-gate-run.md",
"gsd-core/workflows/execute-phase/steps/regression-gate.md",
"gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md",
"gsd-core/workflows/execute-plan.md",
@@ -306,6 +309,7 @@
"gsd-core/workflows/resume-project.md",
"gsd-core/workflows/review.md",
"gsd-core/workflows/scan.md",
"gsd-core/workflows/section-manifest.json",
"gsd-core/workflows/secure-phase.md",
"gsd-core/workflows/session-report.md",
"gsd-core/workflows/settings-advanced.md",

View File

@@ -322,8 +322,11 @@
"gsd-core/workflows/execute-phase.md",
"gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md",
"gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md",
"gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md",
"gsd-core/workflows/execute-phase/steps/partial-wave.md",
"gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md",
"gsd-core/workflows/execute-phase/steps/post-merge-gate.md",
"gsd-core/workflows/execute-phase/steps/regression-gate-run.md",
"gsd-core/workflows/execute-phase/steps/regression-gate.md",
"gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md",
"gsd-core/workflows/execute-plan.md",
@@ -374,6 +377,7 @@
"gsd-core/workflows/resume-project.md",
"gsd-core/workflows/review.md",
"gsd-core/workflows/scan.md",
"gsd-core/workflows/section-manifest.json",
"gsd-core/workflows/secure-phase.md",
"gsd-core/workflows/session-report.md",
"gsd-core/workflows/settings-advanced.md",

View File

@@ -357,8 +357,11 @@
"gsd-core/workflows/execute-phase.md",
"gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md",
"gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md",
"gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md",
"gsd-core/workflows/execute-phase/steps/partial-wave.md",
"gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md",
"gsd-core/workflows/execute-phase/steps/post-merge-gate.md",
"gsd-core/workflows/execute-phase/steps/regression-gate-run.md",
"gsd-core/workflows/execute-phase/steps/regression-gate.md",
"gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md",
"gsd-core/workflows/execute-plan.md",
@@ -409,6 +412,7 @@
"gsd-core/workflows/resume-project.md",
"gsd-core/workflows/review.md",
"gsd-core/workflows/scan.md",
"gsd-core/workflows/section-manifest.json",
"gsd-core/workflows/secure-phase.md",
"gsd-core/workflows/session-report.md",
"gsd-core/workflows/settings-advanced.md",

View File

@@ -252,8 +252,11 @@
"gsd-core/workflows/execute-phase.md",
"gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md",
"gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md",
"gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md",
"gsd-core/workflows/execute-phase/steps/partial-wave.md",
"gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md",
"gsd-core/workflows/execute-phase/steps/post-merge-gate.md",
"gsd-core/workflows/execute-phase/steps/regression-gate-run.md",
"gsd-core/workflows/execute-phase/steps/regression-gate.md",
"gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md",
"gsd-core/workflows/execute-plan.md",
@@ -304,6 +307,7 @@
"gsd-core/workflows/resume-project.md",
"gsd-core/workflows/review.md",
"gsd-core/workflows/scan.md",
"gsd-core/workflows/section-manifest.json",
"gsd-core/workflows/secure-phase.md",
"gsd-core/workflows/session-report.md",
"gsd-core/workflows/settings-advanced.md",

View File

@@ -322,8 +322,11 @@
"gsd-core/workflows/execute-phase.md",
"gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md",
"gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md",
"gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md",
"gsd-core/workflows/execute-phase/steps/partial-wave.md",
"gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md",
"gsd-core/workflows/execute-phase/steps/post-merge-gate.md",
"gsd-core/workflows/execute-phase/steps/regression-gate-run.md",
"gsd-core/workflows/execute-phase/steps/regression-gate.md",
"gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md",
"gsd-core/workflows/execute-plan.md",
@@ -374,6 +377,7 @@
"gsd-core/workflows/resume-project.md",
"gsd-core/workflows/review.md",
"gsd-core/workflows/scan.md",
"gsd-core/workflows/section-manifest.json",
"gsd-core/workflows/secure-phase.md",
"gsd-core/workflows/session-report.md",
"gsd-core/workflows/settings-advanced.md",

View File

@@ -251,8 +251,11 @@
"gsd-core/workflows/execute-phase.md",
"gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md",
"gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md",
"gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md",
"gsd-core/workflows/execute-phase/steps/partial-wave.md",
"gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md",
"gsd-core/workflows/execute-phase/steps/post-merge-gate.md",
"gsd-core/workflows/execute-phase/steps/regression-gate-run.md",
"gsd-core/workflows/execute-phase/steps/regression-gate.md",
"gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md",
"gsd-core/workflows/execute-plan.md",
@@ -303,6 +306,7 @@
"gsd-core/workflows/resume-project.md",
"gsd-core/workflows/review.md",
"gsd-core/workflows/scan.md",
"gsd-core/workflows/section-manifest.json",
"gsd-core/workflows/secure-phase.md",
"gsd-core/workflows/session-report.md",
"gsd-core/workflows/settings-advanced.md",

View File

@@ -322,8 +322,11 @@
"gsd-core/workflows/execute-phase.md",
"gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md",
"gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md",
"gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md",
"gsd-core/workflows/execute-phase/steps/partial-wave.md",
"gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md",
"gsd-core/workflows/execute-phase/steps/post-merge-gate.md",
"gsd-core/workflows/execute-phase/steps/regression-gate-run.md",
"gsd-core/workflows/execute-phase/steps/regression-gate.md",
"gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md",
"gsd-core/workflows/execute-plan.md",
@@ -374,6 +377,7 @@
"gsd-core/workflows/resume-project.md",
"gsd-core/workflows/review.md",
"gsd-core/workflows/scan.md",
"gsd-core/workflows/section-manifest.json",
"gsd-core/workflows/secure-phase.md",
"gsd-core/workflows/session-report.md",
"gsd-core/workflows/settings-advanced.md",

View File

@@ -281,8 +281,11 @@
"gsd-core/workflows/execute-phase.md",
"gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md",
"gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md",
"gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md",
"gsd-core/workflows/execute-phase/steps/partial-wave.md",
"gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md",
"gsd-core/workflows/execute-phase/steps/post-merge-gate.md",
"gsd-core/workflows/execute-phase/steps/regression-gate-run.md",
"gsd-core/workflows/execute-phase/steps/regression-gate.md",
"gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md",
"gsd-core/workflows/execute-plan.md",
@@ -333,6 +336,7 @@
"gsd-core/workflows/resume-project.md",
"gsd-core/workflows/review.md",
"gsd-core/workflows/scan.md",
"gsd-core/workflows/section-manifest.json",
"gsd-core/workflows/secure-phase.md",
"gsd-core/workflows/session-report.md",
"gsd-core/workflows/settings-advanced.md",

View File

@@ -317,8 +317,11 @@
"gsd-core/workflows/execute-phase.md",
"gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md",
"gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md",
"gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md",
"gsd-core/workflows/execute-phase/steps/partial-wave.md",
"gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md",
"gsd-core/workflows/execute-phase/steps/post-merge-gate.md",
"gsd-core/workflows/execute-phase/steps/regression-gate-run.md",
"gsd-core/workflows/execute-phase/steps/regression-gate.md",
"gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md",
"gsd-core/workflows/execute-plan.md",
@@ -369,6 +372,7 @@
"gsd-core/workflows/resume-project.md",
"gsd-core/workflows/review.md",
"gsd-core/workflows/scan.md",
"gsd-core/workflows/section-manifest.json",
"gsd-core/workflows/secure-phase.md",
"gsd-core/workflows/session-report.md",
"gsd-core/workflows/settings-advanced.md",

View File

@@ -322,8 +322,11 @@
"gsd-core/workflows/execute-phase.md",
"gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md",
"gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md",
"gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md",
"gsd-core/workflows/execute-phase/steps/partial-wave.md",
"gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md",
"gsd-core/workflows/execute-phase/steps/post-merge-gate.md",
"gsd-core/workflows/execute-phase/steps/regression-gate-run.md",
"gsd-core/workflows/execute-phase/steps/regression-gate.md",
"gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md",
"gsd-core/workflows/execute-plan.md",
@@ -374,6 +377,7 @@
"gsd-core/workflows/resume-project.md",
"gsd-core/workflows/review.md",
"gsd-core/workflows/scan.md",
"gsd-core/workflows/section-manifest.json",
"gsd-core/workflows/secure-phase.md",
"gsd-core/workflows/session-report.md",
"gsd-core/workflows/settings-advanced.md",

View File

@@ -219,8 +219,11 @@
"gsd-core/workflows/execute-phase.md",
"gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md",
"gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md",
"gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md",
"gsd-core/workflows/execute-phase/steps/partial-wave.md",
"gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md",
"gsd-core/workflows/execute-phase/steps/post-merge-gate.md",
"gsd-core/workflows/execute-phase/steps/regression-gate-run.md",
"gsd-core/workflows/execute-phase/steps/regression-gate.md",
"gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md",
"gsd-core/workflows/execute-plan.md",
@@ -271,6 +274,7 @@
"gsd-core/workflows/resume-project.md",
"gsd-core/workflows/review.md",
"gsd-core/workflows/scan.md",
"gsd-core/workflows/section-manifest.json",
"gsd-core/workflows/secure-phase.md",
"gsd-core/workflows/session-report.md",
"gsd-core/workflows/settings-advanced.md",

View File

@@ -251,8 +251,11 @@
"gsd-core/workflows/execute-phase.md",
"gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md",
"gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md",
"gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md",
"gsd-core/workflows/execute-phase/steps/partial-wave.md",
"gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md",
"gsd-core/workflows/execute-phase/steps/post-merge-gate.md",
"gsd-core/workflows/execute-phase/steps/regression-gate-run.md",
"gsd-core/workflows/execute-phase/steps/regression-gate.md",
"gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md",
"gsd-core/workflows/execute-plan.md",
@@ -303,6 +306,7 @@
"gsd-core/workflows/resume-project.md",
"gsd-core/workflows/review.md",
"gsd-core/workflows/scan.md",
"gsd-core/workflows/section-manifest.json",
"gsd-core/workflows/secure-phase.md",
"gsd-core/workflows/session-report.md",
"gsd-core/workflows/settings-advanced.md",

View File

@@ -251,8 +251,11 @@
"gsd-core/workflows/execute-phase.md",
"gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md",
"gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md",
"gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md",
"gsd-core/workflows/execute-phase/steps/partial-wave.md",
"gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md",
"gsd-core/workflows/execute-phase/steps/post-merge-gate.md",
"gsd-core/workflows/execute-phase/steps/regression-gate-run.md",
"gsd-core/workflows/execute-phase/steps/regression-gate.md",
"gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md",
"gsd-core/workflows/execute-plan.md",
@@ -303,6 +306,7 @@
"gsd-core/workflows/resume-project.md",
"gsd-core/workflows/review.md",
"gsd-core/workflows/scan.md",
"gsd-core/workflows/section-manifest.json",
"gsd-core/workflows/secure-phase.md",
"gsd-core/workflows/session-report.md",
"gsd-core/workflows/settings-advanced.md",

View File

@@ -251,8 +251,11 @@
"gsd-core/workflows/execute-phase.md",
"gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md",
"gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md",
"gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md",
"gsd-core/workflows/execute-phase/steps/partial-wave.md",
"gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md",
"gsd-core/workflows/execute-phase/steps/post-merge-gate.md",
"gsd-core/workflows/execute-phase/steps/regression-gate-run.md",
"gsd-core/workflows/execute-phase/steps/regression-gate.md",
"gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md",
"gsd-core/workflows/execute-plan.md",
@@ -303,6 +306,7 @@
"gsd-core/workflows/resume-project.md",
"gsd-core/workflows/review.md",
"gsd-core/workflows/scan.md",
"gsd-core/workflows/section-manifest.json",
"gsd-core/workflows/secure-phase.md",
"gsd-core/workflows/session-report.md",
"gsd-core/workflows/settings-advanced.md",

View File

@@ -322,8 +322,11 @@
"gsd-core/workflows/execute-phase.md",
"gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md",
"gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md",
"gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md",
"gsd-core/workflows/execute-phase/steps/partial-wave.md",
"gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md",
"gsd-core/workflows/execute-phase/steps/post-merge-gate.md",
"gsd-core/workflows/execute-phase/steps/regression-gate-run.md",
"gsd-core/workflows/execute-phase/steps/regression-gate.md",
"gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md",
"gsd-core/workflows/execute-plan.md",
@@ -374,6 +377,7 @@
"gsd-core/workflows/resume-project.md",
"gsd-core/workflows/review.md",
"gsd-core/workflows/scan.md",
"gsd-core/workflows/section-manifest.json",
"gsd-core/workflows/secure-phase.md",
"gsd-core/workflows/session-report.md",
"gsd-core/workflows/settings-advanced.md",

View File

@@ -0,0 +1,569 @@
'use strict';
/**
* gen-section-manifest.cjs drift-guard tests — 50-test-matrix.md rows 29-41
* (issue #2932, epic #1671 Phase 5,
* `.gsd/phase/chore-2932-init-section-manifest/50-test-matrix.md` section D).
*
* Every test spawns the real CLI (execFileSync) against a temp fixture tree
* shaped like the real repo (`<root>/gsd-core/workflows/<name>.md` +
* `<root>/gsd-core/workflows/<name>/steps/<id>.md`), using the generator's
* `--workflows-dir`/`--manifest-path`/`--repo-root` overrides — no fs
* monkeypatching except rows 40/41, which inject the exact fault
* `writeManifestAtomically` cannot otherwise be made to hit (CONTRIBUTING.md
* / CLAUDE.md cross-platform IO-failure rule: monkeypatch the `fs` method,
* restore via `t.after()`, never `chmod 0o000`).
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const { createTempDir, cleanup } = require('./helpers.cjs');
const {
buildFreshManifest,
writeManifestAtomically,
loadWorkflowFragmentsLib,
checkReport,
ManifestBuildError,
REASON,
WORKFLOW_FRAGMENTS_LIB_PATH,
} = require('../scripts/gen-section-manifest.cjs');
const ROOT = path.resolve(__dirname, '..');
const SCRIPT = path.join(ROOT, 'scripts', 'gen-section-manifest.cjs');
const STACK_FRAME_RE = /\n\s+at\s+\S+\s+\(.*:\d+:\d+\)/;
// ─── Fixture builder ───────────────────────────────────────────────────────
/**
* Build a `<tmpRoot>/gsd-core/workflows/` tree containing one workflow file
* plus (optionally) its `steps/` directory, matching the real repo's
* relative shape exactly so `--repo-root <tmpRoot>` produces the same
* `gsd-core/workflows/...` POSIX `read` paths the real generator emits.
*
* @param {string} tmpRoot
* @param {string} workflowName - e.g. "sample" -> gsd-core/workflows/sample.md
* @param {string} sourceContent
* @param {{[stepFileName: string]: string}} [stepFiles]
* @returns {{ workflowsDir: string, manifestPath: string }}
*/
function buildFixture(tmpRoot, workflowName, sourceContent, stepFiles = {}) {
const workflowsDir = path.join(tmpRoot, 'gsd-core', 'workflows');
fs.mkdirSync(workflowsDir, { recursive: true });
fs.writeFileSync(path.join(workflowsDir, `${workflowName}.md`), sourceContent, 'utf8');
if (Object.keys(stepFiles).length > 0) {
const stepsDir = path.join(workflowsDir, workflowName, 'steps');
fs.mkdirSync(stepsDir, { recursive: true });
for (const [name, content] of Object.entries(stepFiles)) {
fs.writeFileSync(path.join(stepsDir, name), content, 'utf8');
}
}
return { workflowsDir, manifestPath: path.join(workflowsDir, 'section-manifest.json') };
}
/** A single well-formed marker + matching step-file body. */
function markerSource(id, when, refFile) {
return [
'# Sample workflow',
'',
`<!-- gsd:section id="${id}" when="${when}" -->`,
`Read and execute \`gsd-core/workflows/sample/steps/${refFile}\`.`,
'<!-- /gsd:section -->',
'',
].join('\n');
}
/**
* @param {string[]} args
* @param {string} cwd
* @returns {{code: number, stdout: string, stderr: string}}
*/
function runGenSectionManifest(args, cwd = ROOT) {
try {
const stdout = execFileSync(process.execPath, [SCRIPT, ...args], {
cwd,
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 30000,
});
return { code: 0, stdout, stderr: '' };
} catch (err) {
return {
code: err.status ?? 1,
stdout: err.stdout ? err.stdout.toString() : '',
stderr: err.stderr ? err.stderr.toString() : '',
};
}
}
function parseJsonReport(stdout) {
return JSON.parse(stdout.trim());
}
// ─── D. Generator drift-guard (rows 29-41) ─────────────────────────────────
describe('gen-section-manifest.cjs --check / --write (matrix D)', () => {
test('checkExitsZeroWhenManifestMatchesSource (row 29)', (t) => {
const tmpRoot = createTempDir('gen-section-manifest-');
t.after(() => cleanup(tmpRoot));
const { workflowsDir, manifestPath } = buildFixture(
tmpRoot,
'sample',
markerSource('handle-x', 'always', 'handle-x.md'),
{ 'handle-x.md': '<step name="handle_x">\nbody\n</step>\n' },
);
const fresh = buildFreshManifest(workflowsDir, tmpRoot);
fs.writeFileSync(manifestPath, JSON.stringify(fresh, null, 2) + '\n', 'utf8');
const r = runGenSectionManifest([
'--check', '--workflows-dir', workflowsDir, '--manifest-path', manifestPath, '--repo-root', tmpRoot,
]);
assert.equal(r.code, 0, `stderr: ${r.stderr}`);
});
test('checkExitsOneAndNamesFileWhenManifestIsStale (row 30)', (t) => {
const tmpRoot = createTempDir('gen-section-manifest-');
t.after(() => cleanup(tmpRoot));
const { workflowsDir, manifestPath } = buildFixture(
tmpRoot,
'sample',
markerSource('handle-x', 'always', 'handle-x.md'),
{ 'handle-x.md': '<step name="handle_x">\nbody\n</step>\n' },
);
// Stale: valid shape, but "when" no longer matches the source's marker.
fs.writeFileSync(
manifestPath,
JSON.stringify({ sections: [{ id: 'handle-x', when: 'flag:--wave', read: 'gsd-core/workflows/sample/steps/handle-x.md' }] }, null, 2) + '\n',
'utf8',
);
const r = runGenSectionManifest([
'--check', '--json', '--workflows-dir', workflowsDir, '--manifest-path', manifestPath, '--repo-root', tmpRoot,
]);
assert.equal(r.code, 1);
assert.doesNotMatch(r.stderr, STACK_FRAME_RE, 'no bare stack trace for a stale manifest');
const report = parseJsonReport(r.stdout);
assert.equal(report.ok, false);
assert.equal(report.reason, REASON.FAIL_STALE);
assert.equal(report.subject, manifestPath, 'report must name the stale manifest file');
});
test('writeThenCheckRoundTripsClean (row 31)', (t) => {
const tmpRoot = createTempDir('gen-section-manifest-');
t.after(() => cleanup(tmpRoot));
const { workflowsDir, manifestPath } = buildFixture(
tmpRoot,
'sample',
markerSource('handle-x', 'always', 'handle-x.md'),
{ 'handle-x.md': '<step name="handle_x">\nbody\n</step>\n' },
);
const w = runGenSectionManifest([
'--write', '--workflows-dir', workflowsDir, '--manifest-path', manifestPath, '--repo-root', tmpRoot,
]);
assert.equal(w.code, 0, `stderr: ${w.stderr}`);
assert.ok(fs.existsSync(manifestPath));
const c = runGenSectionManifest([
'--check', '--workflows-dir', workflowsDir, '--manifest-path', manifestPath, '--repo-root', tmpRoot,
]);
assert.equal(c.code, 0, '--check must be clean immediately after --write');
});
test('checkFailsCleanlyWhenManifestAbsent (row 32)', (t) => {
const tmpRoot = createTempDir('gen-section-manifest-');
t.after(() => cleanup(tmpRoot));
const { workflowsDir, manifestPath } = buildFixture(
tmpRoot,
'sample',
markerSource('handle-x', 'always', 'handle-x.md'),
{ 'handle-x.md': '<step name="handle_x">\nbody\n</step>\n' },
);
assert.equal(fs.existsSync(manifestPath), false, 'sanity: manifest must not exist yet');
const r = runGenSectionManifest([
'--check', '--json', '--workflows-dir', workflowsDir, '--manifest-path', manifestPath, '--repo-root', tmpRoot,
]);
assert.equal(r.code, 1);
assert.doesNotMatch(r.stderr, STACK_FRAME_RE, 'an absent manifest must not crash, just fail closed');
const report = parseJsonReport(r.stdout);
assert.equal(report.ok, false);
assert.equal(report.reason, REASON.FAIL_MANIFEST_MISSING);
assert.equal(report.subject, manifestPath);
});
test('checkFailsCleanlyOnEmptyManifestFile (row 33)', (t) => {
const tmpRoot = createTempDir('gen-section-manifest-');
t.after(() => cleanup(tmpRoot));
const { workflowsDir, manifestPath } = buildFixture(
tmpRoot,
'sample',
markerSource('handle-x', 'always', 'handle-x.md'),
{ 'handle-x.md': '<step name="handle_x">\nbody\n</step>\n' },
);
fs.writeFileSync(manifestPath, '', 'utf8');
const r = runGenSectionManifest([
'--check', '--json', '--workflows-dir', workflowsDir, '--manifest-path', manifestPath, '--repo-root', tmpRoot,
]);
assert.equal(r.code, 1);
assert.doesNotMatch(r.stderr, STACK_FRAME_RE, 'no stack trace for an empty manifest file');
const report = parseJsonReport(r.stdout);
assert.equal(report.ok, false);
assert.equal(report.reason, REASON.FAIL_MANIFEST_UNPARSEABLE);
});
test('rejectsValidJsonThatIsNotTheExpectedShape (row 34)', (t) => {
const tmpRoot = createTempDir('gen-section-manifest-');
t.after(() => cleanup(tmpRoot));
const { workflowsDir, manifestPath } = buildFixture(
tmpRoot,
'sample',
markerSource('handle-x', 'always', 'handle-x.md'),
{ 'handle-x.md': '<step name="handle_x">\nbody\n</step>\n' },
);
for (const hostileJson of ['0', '"s"', '[]', 'null', 'true']) {
fs.writeFileSync(manifestPath, hostileJson, 'utf8');
const r = runGenSectionManifest([
'--check', '--json', '--workflows-dir', workflowsDir, '--manifest-path', manifestPath, '--repo-root', tmpRoot,
]);
assert.equal(r.code, 1, `hostile JSON ${hostileJson} must fail --check`);
assert.doesNotMatch(r.stderr, STACK_FRAME_RE, `hostile JSON ${hostileJson} must not crash`);
const report = parseJsonReport(r.stdout);
assert.equal(report.ok, false, `hostile JSON ${hostileJson}`);
assert.equal(report.reason, REASON.FAIL_MANIFEST_MALFORMED_SHAPE, `hostile JSON ${hostileJson}`);
}
});
test('checkFailsWhenMarkerReferencesMissingStepFile (row 35)', (t) => {
const tmpRoot = createTempDir('gen-section-manifest-');
t.after(() => cleanup(tmpRoot));
// Marker present, but the step file it names is never created.
const { workflowsDir, manifestPath } = buildFixture(
tmpRoot,
'sample',
markerSource('handle-x', 'always', 'handle-x.md'),
{},
);
const r = runGenSectionManifest([
'--check', '--json', '--workflows-dir', workflowsDir, '--manifest-path', manifestPath, '--repo-root', tmpRoot,
]);
assert.equal(r.code, 1);
assert.doesNotMatch(r.stderr, STACK_FRAME_RE, 'a missing step file must not crash the generator');
const report = parseJsonReport(r.stdout);
assert.equal(report.ok, false);
assert.equal(report.reason, REASON.FAIL_MISSING_STEP_FILE);
assert.equal(
report.subject,
'gsd-core/workflows/sample/steps/handle-x.md',
'report must name the missing step file path',
);
});
test('checkFailsOnOrphanStepFile (row 36)', (t) => {
const tmpRoot = createTempDir('gen-section-manifest-');
t.after(() => cleanup(tmpRoot));
// handle-x.md is the legitimate marker target; stray.md is referenced by
// nothing (not the marker, not any prose in the parent or in handle-x.md).
const { workflowsDir, manifestPath } = buildFixture(
tmpRoot,
'sample',
markerSource('handle-x', 'always', 'handle-x.md'),
{
'handle-x.md': '<step name="handle_x">\nbody\n</step>\n',
'stray.md': '<step name="unreachable">\nnever referenced anywhere\n</step>\n',
},
);
const r = runGenSectionManifest([
'--check', '--json', '--workflows-dir', workflowsDir, '--manifest-path', manifestPath, '--repo-root', tmpRoot,
]);
assert.equal(r.code, 1);
assert.doesNotMatch(r.stderr, STACK_FRAME_RE, 'an orphan step file must not crash the generator');
const report = parseJsonReport(r.stdout);
assert.equal(report.ok, false);
assert.equal(report.reason, REASON.FAIL_ORPHAN_STEP_FILE);
assert.equal(
report.subject,
'gsd-core/workflows/sample/steps/stray.md',
'report must name the orphan step file path',
);
});
test('nestedStepReferenceIsNotFlaggedAsOrphan (supplemental: proves the reachability fixed-point, not just the negative case)', (t) => {
const tmpRoot = createTempDir('gen-section-manifest-');
t.after(() => cleanup(tmpRoot));
// handle-x.md is the marker target; handle-x.md's OWN prose delegates to
// handle-x-run.md, which is referenced by NO marker directly — mirrors
// the real repo's regression-gate.md -> regression-gate-run.md shape.
const { workflowsDir, manifestPath } = buildFixture(
tmpRoot,
'sample',
markerSource('handle-x', 'always', 'handle-x.md'),
{
'handle-x.md': 'Read and execute `gsd-core/workflows/sample/steps/handle-x-run.md`.\n',
'handle-x-run.md': 'the nested run body\n',
},
);
const fresh = buildFreshManifest(workflowsDir, tmpRoot);
assert.equal(fresh.sections.length, 1);
fs.writeFileSync(manifestPath, JSON.stringify(fresh, null, 2) + '\n', 'utf8');
const r = runGenSectionManifest([
'--check', '--workflows-dir', workflowsDir, '--manifest-path', manifestPath, '--repo-root', tmpRoot,
]);
assert.equal(r.code, 0, `handle-x-run.md must resolve as reachable, not orphan; stderr: ${r.stderr}`);
});
test('producesIdenticalManifestForCrlfAndLfSources (row 37)', (t) => {
const lfRoot = createTempDir('gen-section-manifest-lf-');
const crlfRoot = createTempDir('gen-section-manifest-crlf-');
t.after(() => {
cleanup(lfRoot);
cleanup(crlfRoot);
});
const lfSource = markerSource('handle-x', 'state:has-prior-phases', 'handle-x.md');
const lfStep = '<step name="handle_x">\nbody\n</step>\n';
const { workflowsDir: lfDir } = buildFixture(lfRoot, 'sample', lfSource, { 'handle-x.md': lfStep });
const { workflowsDir: crlfDir } = buildFixture(
crlfRoot,
'sample',
lfSource.replace(/\n/g, '\r\n'),
{ 'handle-x.md': lfStep.replace(/\n/g, '\r\n') },
);
const lfManifest = buildFreshManifest(lfDir, lfRoot);
const crlfManifest = buildFreshManifest(crlfDir, crlfRoot);
assert.deepEqual(crlfManifest, lfManifest, 'CRLF and LF sources must produce an identical manifest');
});
test('ignoresSectionShapedLineInsideFencedBlock (row 38)', (t) => {
const tmpRoot = createTempDir('gen-section-manifest-');
t.after(() => cleanup(tmpRoot));
const source = [
'# Sample workflow',
'',
'<!-- gsd:section id="handle-x" when="always" -->',
'Read and execute `gsd-core/workflows/sample/steps/handle-x.md`.',
'<!-- /gsd:section -->',
'',
'Documentation of the marker grammar (must NOT be treated as real):',
'```',
'<!-- gsd:section id="fake" when="always" -->',
'never a real section',
'<!-- /gsd:section -->',
'```',
'',
].join('\n');
const { workflowsDir, manifestPath } = buildFixture(tmpRoot, 'sample', source, {
'handle-x.md': '<step name="handle_x">\nbody\n</step>\n',
});
const fresh = buildFreshManifest(workflowsDir, tmpRoot);
assert.equal(fresh.sections.length, 1, 'the fenced marker-shaped lines must not produce a section');
assert.equal(fresh.sections[0].id, 'handle-x');
fs.writeFileSync(manifestPath, JSON.stringify(fresh, null, 2) + '\n', 'utf8');
const r = runGenSectionManifest([
'--check', '--workflows-dir', workflowsDir, '--manifest-path', manifestPath, '--repo-root', tmpRoot,
]);
assert.equal(r.code, 0, `stderr: ${r.stderr}`);
});
test('leavesLoopHostMarkerUntouched (row 39)', (t) => {
const tmpRoot = createTempDir('gen-section-manifest-');
t.after(() => cleanup(tmpRoot));
const source = [
'<!-- gsd:loop-host name="example" -->',
'',
'# Sample workflow',
'',
'<!-- gsd:section id="handle-x" when="always" -->',
'Read and execute `gsd-core/workflows/sample/steps/handle-x.md`.',
'<!-- /gsd:section -->',
'',
].join('\n');
const { workflowsDir, manifestPath } = buildFixture(tmpRoot, 'sample', source, {
'handle-x.md': '<step name="handle_x">\nbody\n</step>\n',
});
const fresh = buildFreshManifest(workflowsDir, tmpRoot);
assert.equal(fresh.sections.length, 1, 'gsd:loop-host must never be treated as a gsd:section marker');
assert.equal(fresh.sections[0].id, 'handle-x');
fs.writeFileSync(manifestPath, JSON.stringify(fresh, null, 2) + '\n', 'utf8');
const r = runGenSectionManifest([
'--check', '--workflows-dir', workflowsDir, '--manifest-path', manifestPath, '--repo-root', tmpRoot,
]);
assert.equal(r.code, 0, `stderr: ${r.stderr}`);
});
test('writesManifestAtomically (row 40)', (t) => {
const tmpRoot = createTempDir('gen-section-manifest-');
t.after(() => cleanup(tmpRoot));
const manifestPath = path.join(tmpRoot, 'section-manifest.json');
const originalContent = '{"sections":[{"id":"pre-existing","when":"always","read":"x.md"}]}\n';
fs.writeFileSync(manifestPath, originalContent, 'utf8');
// Cross-platform IO-failure injection (CLAUDE.md rule): monkeypatch the
// fs method, restore via t.after() (never chmod 0o000 — root bypasses
// mode bits, silently zero-coverage in root Docker/CI).
const origRenameSync = fs.renameSync;
fs.renameSync = function patchedRenameSync() {
throw new Error('injected rename failure (never a real fs fault)');
};
t.after(() => {
fs.renameSync = origRenameSync;
});
assert.throws(() => writeManifestAtomically(manifestPath, '{"sections":[]}\n'), (err) => {
// Finding 2 (#2932 review): the throw site must attach the typed
// FAIL_WRITE_ERROR reason, not just a plain ExitError, so a downstream
// `--write --json` consumer can emit the same {ok,reason,subject}
// envelope every other failure path in this file produces.
assert.ok(err instanceof ManifestBuildError, 'must throw a ManifestBuildError, not a plain ExitError');
assert.equal(err.reason, REASON.FAIL_WRITE_ERROR);
assert.equal(err.subject, manifestPath);
return true;
});
assert.equal(
fs.readFileSync(manifestPath, 'utf8'),
originalContent,
'a rename failure must leave the pre-existing manifest completely untouched, never truncated',
);
const leftoverTmp = fs.readdirSync(tmpRoot).filter((f) => f.includes('.tmp-'));
assert.deepEqual(leftoverTmp, [], 'the temp file must be cleaned up even when the rename step fails');
});
test('surfacesWriteFailureAndCleansTemp (row 41)', (t) => {
const tmpRoot = createTempDir('gen-section-manifest-');
t.after(() => cleanup(tmpRoot));
const manifestPath = path.join(tmpRoot, 'section-manifest.json');
assert.equal(fs.existsSync(manifestPath), false, 'sanity: no pre-existing manifest');
const origWriteFileSync = fs.writeFileSync;
fs.writeFileSync = function patchedWriteFileSync(target, ...rest) {
if (target === manifestPath || (typeof target === 'string' && target.includes('.tmp-'))) {
throw new Error('injected write failure (never a real fs fault)');
}
return origWriteFileSync.call(fs, target, ...rest);
};
t.after(() => {
fs.writeFileSync = origWriteFileSync;
});
assert.throws(() => writeManifestAtomically(manifestPath, '{"sections":[]}\n'), (err) => {
assert.ok(err instanceof ManifestBuildError, 'must throw a ManifestBuildError, not a plain ExitError');
assert.equal(err.reason, REASON.FAIL_WRITE_ERROR);
assert.equal(err.subject, manifestPath);
return true;
});
assert.equal(fs.existsSync(manifestPath), false, 'the target manifest must never be created on a write failure');
const leftover = fs.readdirSync(tmpRoot).filter((f) => f.includes('.tmp-'));
assert.deepEqual(leftover, [], 'no temp file must leak when the initial write itself fails');
});
test('loadWorkflowFragmentsLib throws ManifestBuildError with FAIL_LIB_NOT_BUILT when the compiled lib cannot be read (Finding 2, #2932 review)', (t) => {
// Cross-platform IO-failure injection (CLAUDE.md rule): monkeypatch the
// `fs` method Node's own module loader uses to read the file content
// (never chmod 0o000 — root bypasses mode bits, silently zero-coverage in
// root Docker/CI, and would also mutate a file shared by concurrent test
// workers). The real compiled artifact is never touched or renamed.
const origReadFileSync = fs.readFileSync;
fs.readFileSync = function patchedReadFileSync(target, ...rest) {
if (target === WORKFLOW_FRAGMENTS_LIB_PATH) {
throw new Error('injected read failure (never a real fs fault)');
}
return origReadFileSync.call(fs, target, ...rest);
};
t.after(() => {
fs.readFileSync = origReadFileSync;
});
assert.throws(() => loadWorkflowFragmentsLib(), (err) => {
assert.ok(err instanceof ManifestBuildError, 'must throw a ManifestBuildError, not a plain ExitError');
assert.equal(err.reason, REASON.FAIL_LIB_NOT_BUILT);
assert.equal(err.subject, 'gsd-core/bin/lib/workflow-fragments.cjs');
assert.match(err.message, /npm run build:lib/);
return true;
});
});
test('checkReport surfaces FAIL_LIB_NOT_BUILT as the same typed envelope other --check failures produce (Finding 2, #2932 review)', (t) => {
const tmpRoot = createTempDir('gen-section-manifest-');
t.after(() => cleanup(tmpRoot));
const { workflowsDir, manifestPath } = buildFixture(
tmpRoot,
'sample',
markerSource('handle-x', 'always', 'handle-x.md'),
{ 'handle-x.md': '<step name="handle_x">\nbody\n</step>\n' },
);
const origReadFileSync = fs.readFileSync;
fs.readFileSync = function patchedReadFileSync(target, ...rest) {
if (target === WORKFLOW_FRAGMENTS_LIB_PATH) {
throw new Error('injected read failure (never a real fs fault)');
}
return origReadFileSync.call(fs, target, ...rest);
};
t.after(() => {
fs.readFileSync = origReadFileSync;
});
const report = checkReport(workflowsDir, manifestPath, tmpRoot);
assert.equal(report.ok, false);
assert.equal(report.reason, REASON.FAIL_LIB_NOT_BUILT);
assert.equal(report.subject, 'gsd-core/bin/lib/workflow-fragments.cjs');
});
});
// ─── REASON enum shape lock (mirrors gen-context-index.cjs precedent) ──────
describe('gen-section-manifest.cjs REASON enum', () => {
test('REASON key set is exactly the documented set', () => {
assert.deepEqual(Object.keys(REASON).sort(), [
'FAIL_LIB_NOT_BUILT',
'FAIL_MANIFEST_MALFORMED_SHAPE',
'FAIL_MANIFEST_MISSING',
'FAIL_MANIFEST_UNPARSEABLE',
'FAIL_MISSING_STEP_FILE',
'FAIL_ORPHAN_STEP_FILE',
'FAIL_SOURCE_PARSE_ERROR',
'FAIL_STALE',
'FAIL_WRITE_ERROR',
'OK_UP_TO_DATE',
]);
});
test('REASON is frozen', () => {
assert.ok(Object.isFrozen(REASON));
});
});

View File

@@ -6,9 +6,11 @@ const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { runGsdTools, cleanup, absPlanningPath } = require('./helpers.cjs');
const { spawnSync } = require('node:child_process');
const { runGsdTools, cleanup, absPlanningPath, TOOLS_PATH } = require('./helpers.cjs');
const { createFixture, seedPhase } = require('./fixtures/index.cjs');
const { createTempProject, createTempDir } = require('./helpers.cjs');
const { executionContextRefs } = require('../scripts/command-contract-helpers.cjs');
describe('init commands', () => {
let tmpDir;
@@ -2972,3 +2974,342 @@ test('bug-3491: new-project.md gates `git init` on in_nested_subdir, not just ha
});
});
}
// ─────────────────────────────────────────────────────────────────────────────
// Folded from tests/init-section-manifest.test.cjs — #2932 (epic #1671 Phase 5)
// ─────────────────────────────────────────────────────────────────────────────
//
// init CLI negative matrix for `section_manifest`. Covers
// `.gsd/phase/chore-2932-init-section-manifest/50-test-matrix.md` section E
// (rows 42-59) plus row 62. Drives the REAL CLI through the dispatch seam
// (`spawnSync(process.execPath, [...])` with argv ARRAYS — never shell strings) so
// hostile inputs (rows 55/56) prove no shell interpolation and no path escape.
//
// Each test asserts: exit status, structured JSON result, absence of project-tree
// fs mutation, and no stack trace in non-debug stderr — never substring-matching
// rendered prose (local/no-source-grep).
describe('init section manifest', () => {
const GSD_ROOT = path.join(__dirname, '..', 'gsd-core');
const COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd');
// ── Drivers ─────────────────────────────────────────────────────────────
/**
* Invokes the real CLI dispatch seam with an argv ARRAY (never a shell string),
* so shell metacharacters in an argument (rows 55/56) can never be interpreted
* by a shell — spawnSync with an array bypasses the shell entirely. Always runs
* with GSD_JSON_ERRORS=1 so an error path yields a typed `{ ok, reason, message }`
* envelope instead of prose, per CONTRIBUTING.md "Prohibited: Raw Text Matching".
*/
function runSectionManifestCli(args, cwd, env = {}) {
const result = spawnSync(process.execPath, [TOOLS_PATH, 'query', ...args], {
cwd,
encoding: 'utf8',
env: { ...process.env, GSD_JSON_ERRORS: '1', ...env },
timeout: 30000,
});
let stdout = result.stdout || '';
// output() spills payloads over 50KB to a tmpfile and prints "@file:<path>"
// (src/io.cts) — dereference it exactly as the workflow itself does.
if (stdout.startsWith('@file:')) {
stdout = fs.readFileSync(stdout.slice('@file:'.length).trim(), 'utf8');
}
return { status: result.status, stdout, stderr: result.stderr || '' };
}
function runExecutePhase(phaseArgs, cwd, env = {}) {
return runSectionManifestCli(['init.execute-phase', ...phaseArgs], cwd, env);
}
function parseOkJson(result, label) {
assert.equal(result.status, 0, `${label}: expected exit 0, got ${result.status} (stderr: ${result.stderr})`);
assertNoStackTrace(result.stderr, label);
return JSON.parse(result.stdout);
}
function parseErrorJson(result, label) {
assert.notEqual(result.status, 0, `${label}: expected non-zero exit`);
assertNoStackTrace(result.stderr, label);
return JSON.parse(result.stderr);
}
/** Node stack-trace frames look like "\n at fn (file:line:col)" — a structural
* signal, not a content match on any file's prose. */
function assertNoStackTrace(text, label) {
assert.ok(!/\n\s+at\s+\S+/.test(text || ''), `${label}: unexpected stack trace: ${text}`);
}
/** Recursive, sorted snapshot of a directory's structure — name/size/mtime triples,
* used to assert a read-only `query` command mutates nothing under the project tree. */
function snapshotSectionManifestTree(dir) {
const out = [];
function walk(d, rel) {
let entries;
try {
entries = fs.readdirSync(d, { withFileTypes: true });
} catch {
return;
}
entries.sort((a, b) => a.name.localeCompare(b.name));
for (const entry of entries) {
const relPath = rel ? `${rel}/${entry.name}` : entry.name;
const full = path.join(d, entry.name);
if (entry.isDirectory()) {
out.push(`D:${relPath}`);
walk(full, relPath);
} else {
const st = fs.statSync(full);
out.push(`F:${relPath}:${st.size}`);
}
}
}
walk(dir, '');
return out;
}
function seedSinglePhaseProject(t, prefix) {
const dir = createTempProject(prefix);
t.after(() => cleanup(dir));
seedPhase(dir, '01-widgets', {});
return dir;
}
// ── E42-43: baseline manifest emission, with/without --wave ─────────────
describe('init execute-phase: section_manifest emission (#2932)', () => {
test('emitsSectionManifestWithoutWaveFlag', (t) => {
const dir = seedSinglePhaseProject(t, 'gsd-e42-');
const before = snapshotSectionManifestTree(dir);
const body = parseOkJson(runExecutePhase(['1'], dir), 'no-flag');
assert.deepStrictEqual(snapshotSectionManifestTree(dir), before, 'query command must not mutate the project tree');
assert.ok(body.section_manifest, 'section_manifest must be present');
assert.equal(body.section_manifest.workflow, 'execute-phase');
assert.ok(!body.section_manifest.included.includes('partial-wave'), 'partial-wave must be excluded without --wave');
assert.ok(body.section_manifest.excluded.includes('partial-wave'));
assert.deepStrictEqual(body.section_manifest.read, []);
});
test('emitsSectionManifestIncludingPartialWaveWithWaveFlag (#2932 headline acceptance criterion)', (t) => {
const dir = seedSinglePhaseProject(t, 'gsd-e43-');
const body = parseOkJson(runExecutePhase(['1', '--wave', '1'], dir), 'with-wave');
assert.ok(body.section_manifest);
assert.deepStrictEqual(body.section_manifest.included, ['partial-wave']);
assert.ok(!body.section_manifest.excluded.includes('partial-wave'));
assert.deepStrictEqual(body.section_manifest.read, [
'gsd-core/workflows/execute-phase/steps/partial-wave.md',
]);
});
});
// ── E44-46: phase argument boundary ────────────────────────────────────
describe('init execute-phase: phase argument boundary (#2932)', () => {
test('failsWhenPhaseArgumentMissing', (t) => {
const dir = seedSinglePhaseProject(t, 'gsd-e44-');
const err = parseErrorJson(runExecutePhase([], dir), 'missing-phase');
assert.equal(err.ok, false);
assert.equal(typeof err.reason, 'string');
assert.equal(typeof err.message, 'string');
});
test('failsOnEmptyPhaseArgument', (t) => {
const dir = seedSinglePhaseProject(t, 'gsd-e45-');
const err = parseErrorJson(runExecutePhase([''], dir), 'empty-phase');
assert.equal(err.ok, false);
});
test('failsOnWhitespaceOnlyPhaseArgument', (t) => {
// Test-matrix row 46 names "non-zero" as expected. Empirically, cmdInitExecutePhase's
// guard is `if (!phase)`, which is false for a non-empty whitespace string — this
// pre-existing idiom is SHARED by every phase-taking init subcommand (plan-phase,
// todos, phase-op, ...), not introduced by #2932, and changing it here would be a
// blast-radius violation of the CRITICAL routeInitCommand rating (37 affected files).
// " " instead falls through to guardedFindPhase, which returns no match — the same
// graceful "not found" degrade the path-traversal/shell-metachar rows (55/56) require,
// not a crash. This test locks the REAL, current, exit-0 "not found" behavior rather
// than the matrix's a-priori assumption; see the dispatch report for this reconciliation.
const dir = seedSinglePhaseProject(t, 'gsd-e46-');
const body = parseOkJson(runExecutePhase([' '], dir), 'whitespace-phase');
assert.equal(body.phase_found, false);
assert.ok(body.section_manifest, 'manifest is still emitted — it does not depend on phase_found');
});
});
// ── E47-52: --wave token-presence semantics ──────────────────────────────
describe('init execute-phase: --wave token-presence semantics (#2932)', () => {
test('treatsValuelessWaveFlagAsPresent', (t) => {
const dir = seedSinglePhaseProject(t, 'gsd-e47-');
const body = parseOkJson(runExecutePhase(['1', '--wave'], dir), 'valueless-wave');
assert.deepStrictEqual(body.section_manifest.included, ['partial-wave']);
});
test('treatsWaveZeroAsPresent', (t) => {
const dir = seedSinglePhaseProject(t, 'gsd-e48-');
const body = parseOkJson(runExecutePhase(['1', '--wave', '0'], dir), 'wave-zero');
assert.deepStrictEqual(body.section_manifest.included, ['partial-wave']);
});
test('treatsDuplicateWaveFlagsIdempotently', (t) => {
const dir = seedSinglePhaseProject(t, 'gsd-e49-');
const body = parseOkJson(runExecutePhase(['1', '--wave', '1', '--wave', '2'], dir), 'dup-wave');
assert.deepStrictEqual(body.section_manifest.included, ['partial-wave']);
});
test('handlesMalformedWaveAssignments', (t) => {
// Documented handling (decision made during this dispatch): parseNamedArgs's
// booleanFlags check is an EXACT token match against the literal "--wave" —
// "--wave=" and "--wave==1" are different literal tokens, so neither activates
// the flag. No crash either way; this is the same exact-match discipline that
// keeps "--waves"/"--wave-filter" from false-activating (row 52).
const dir = seedSinglePhaseProject(t, 'gsd-e50-');
for (const token of ['--wave=', '--wave==1']) {
const body = parseOkJson(runExecutePhase(['1', token], dir), `malformed-wave:${token}`);
assert.ok(!body.section_manifest.included.includes('partial-wave'), `"${token}" must not activate --wave`);
}
});
test('doesNotConsumeFollowingFlagAsWaveValue', (t) => {
const dir = seedSinglePhaseProject(t, 'gsd-e51-');
const body = parseOkJson(runExecutePhase(['1', '--wave', '--weird'], dir), 'wave-then-weird');
// Boolean-flag semantics: --wave never reads a following token as its value,
// so an adjacent flag-shaped token is simply ignored, not eaten or mis-parsed.
assert.deepStrictEqual(body.section_manifest.included, ['partial-wave']);
});
test('nearMissFlagNamesDoNotActivateWave', (t) => {
const dir = seedSinglePhaseProject(t, 'gsd-e52-');
for (const flag of ['--waves', '--wave-filter']) {
const body = parseOkJson(runExecutePhase(['1', flag], dir), `near-miss:${flag}`);
assert.ok(!body.section_manifest.included.includes('partial-wave'), `"${flag}" must not activate --wave`);
}
});
});
// ── E53: unknown subcommand ───────────────────────────────────────────────
describe('init dispatch: unknown subcommand (#2932 row 53)', () => {
test('reportsUnknownInitSubcommand', (t) => {
const dir = seedSinglePhaseProject(t, 'gsd-e53-');
const err = parseErrorJson(runSectionManifestCli(['init.frobnicate'], dir), 'unknown-subcommand');
assert.equal(err.ok, false);
assert.equal(err.reason, 'sdk_unknown_command');
});
});
// ── E54-56: hostile inputs ────────────────────────────────────────────────
describe('init execute-phase: hostile inputs (#2932)', () => {
test('handlesVeryLongAndUnicodeValues', (t) => {
const dir = seedSinglePhaseProject(t, 'gsd-e54-');
const longUnicodeValue = 'x'.repeat(20000) + '你好\u{1f600}';
const result = runExecutePhase(['1', '--wave', longUnicodeValue], dir);
assert.equal(result.status, 0, `expected exit 0, got ${result.status} (stderr: ${result.stderr})`);
assertNoStackTrace(result.stderr, 'long-unicode-value');
const body = JSON.parse(result.stdout);
assert.deepStrictEqual(body.section_manifest.included, ['partial-wave']);
const longPhaseResult = runExecutePhase(['1' + 'z'.repeat(20000)], dir);
assert.equal(longPhaseResult.status, 0);
assertNoStackTrace(longPhaseResult.stderr, 'long-phase-value');
});
test('doesNotInterpolateShellMetacharactersInPhase', (t) => {
const dir = seedSinglePhaseProject(t, 'gsd-e55-');
const sentinel = path.join(dir, 'pwned-sentinel');
const hostilePhase = `1; touch ${sentinel}; $(touch ${sentinel}) \`touch ${sentinel}\` && touch ${sentinel} || touch ${sentinel}`;
const result = runExecutePhase([hostilePhase], dir);
assert.equal(result.status, 0, `expected exit 0 (no shell execution), got ${result.status}`);
assertNoStackTrace(result.stderr, 'shell-metachars');
assert.ok(!fs.existsSync(sentinel), 'shell metacharacters in the phase argument must never be interpreted — argv array bypasses the shell entirely');
});
test('rejectsPathTraversalStylePhaseValue', (t) => {
const dir = seedSinglePhaseProject(t, 'gsd-e56-');
const before = snapshotSectionManifestTree(dir);
const body = parseOkJson(runExecutePhase(['../../../../etc/passwd'], dir), 'path-traversal');
assert.deepStrictEqual(snapshotSectionManifestTree(dir), before, 'no fs mutation from a traversal-shaped phase value');
assert.equal(body.phase_found, false, 'a traversal-shaped value must never resolve to a real phase');
assert.ok(
!String(body.phase_dir || '').includes('etc/passwd'),
'phase_dir must never resolve outside the project tree',
);
});
});
// ── E57-58: degraded path — manifest artifact missing/malformed ─────────
describe('init execute-phase: section_manifest degrades to null (#2932)', () => {
test('degradesToNullManifestWhenArtifactMissing', (t) => {
const dir = seedSinglePhaseProject(t, 'gsd-e57-');
const missingPath = path.join(dir, 'does-not-exist-section-manifest.json');
const result = runExecutePhase(['1'], dir, { GSD_SECTION_MANIFEST: missingPath });
assert.equal(result.status, 0, `expected exit 0 despite missing manifest artifact, got ${result.status} (stderr: ${result.stderr})`);
assertNoStackTrace(result.stderr, 'manifest-missing');
const body = JSON.parse(result.stdout);
assert.equal(body.section_manifest, null);
});
test('degradesToNullManifestWhenArtifactMalformed', (t) => {
const dir = seedSinglePhaseProject(t, 'gsd-e58-');
const badPath = path.join(dir, 'bad-section-manifest.json');
fs.writeFileSync(badPath, '{ this is not valid json');
const result = runExecutePhase(['1'], dir, { GSD_SECTION_MANIFEST: badPath });
assert.equal(result.status, 0, `expected exit 0 despite malformed manifest artifact, got ${result.status} (stderr: ${result.stderr})`);
assertNoStackTrace(result.stderr, 'manifest-malformed');
const body = JSON.parse(result.stdout);
assert.equal(body.section_manifest, null);
});
test('degradesToNullManifestWhenArtifactWrongShape', (t) => {
// Extends row 58: valid JSON, wrong shape (design doc + init.cts loadSectionManifestSections
// both name this as a degraded case distinct from "not JSON at all").
const dir = seedSinglePhaseProject(t, 'gsd-e58b-');
const wrongShapePath = path.join(dir, 'wrong-shape-section-manifest.json');
fs.writeFileSync(wrongShapePath, JSON.stringify([1, 2, 3]));
const result = runExecutePhase(['1'], dir, { GSD_SECTION_MANIFEST: wrongShapePath });
assert.equal(result.status, 0);
const body = JSON.parse(result.stdout);
assert.equal(body.section_manifest, null);
});
});
// ── E59: independence — other init subcommands unaffected ───────────────
describe('init dispatch: other subcommands unaffected (#2932 row 59, CRITICAL radius guard)', () => {
test('leavesOtherInitSubcommandsUnchanged', (t) => {
const dir = seedSinglePhaseProject(t, 'gsd-e59-');
const planPhase = parseOkJson(runSectionManifestCli(['init.plan-phase', '1'], dir), 'plan-phase');
assert.equal(planPhase.phase_found, true);
assert.ok(!('section_manifest' in planPhase), 'section_manifest must be execute-phase-only, never leak into plan-phase');
const resume = parseOkJson(runSectionManifestCli(['init.resume'], dir), 'resume');
assert.ok(!('section_manifest' in resume), 'section_manifest must never leak into resume');
});
});
// ── Row 62: stub <execution_context> @-refs still resolve (ADR-0002) ────
describe('commands/gsd/execute-phase.md: <execution_context> @-refs resolve (#2932 row 62)', () => {
test('stubExecutionContextRefStillResolves', () => {
const stubPath = path.join(COMMANDS_DIR, 'execute-phase.md');
const content = fs.readFileSync(stubPath, 'utf-8');
const refs = executionContextRefs(content);
assert.ok(refs.length > 0, 'execute-phase.md stub must declare at least one execution_context @-ref');
const workflowRef = refs.find((r) => r.normalized === 'workflows/execute-phase.md');
assert.ok(workflowRef, 'execute-phase.md stub must @-reference workflows/execute-phase.md');
for (const ref of refs) {
assert.ok(
fs.existsSync(path.join(GSD_ROOT, ref.normalized)),
`execution_context @-ref "${ref.normalized}" must resolve to a file that exists on disk`,
);
}
});
});
});

View File

@@ -102,7 +102,12 @@ describe('ADR-857 phase 6 verification and review capability migration', () => {
test('execute-phase code-review gate resolves execute:post hooks instead of inlining code_review config', () => {
const content = workflow('execute-phase.md');
const section = sectionBetween(content, '<step name="code_review_gate"', '<step name="close_parent_artifacts">');
// close_parent_artifacts was extracted to
// gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md; the parent now
// marks that boundary with a <!-- gsd:section id="gap-closure-artifacts" --> comment
// immediately after code_review_gate's closing </step>, so it remains the correct
// end-of-step delimiter for isolating this step's body.
const section = sectionBetween(content, '<step name="code_review_gate"', '<!-- gsd:section id="gap-closure-artifacts"');
assert.ok(section.includes('loop render-hooks execute:post'));
assert.ok(section.includes('gsd-${ref.skill}'));

View File

@@ -0,0 +1,201 @@
'use strict';
/**
* Property-based tests for src/section-manifest.cts (compiled to
* gsd-core/bin/lib/section-manifest.cjs) — issue #2932 (epic #1671 Phase 5).
* Covers 50-test-matrix.md rows 25-28.
*
* Document-shaped generators (CONTRIBUTING.md "Fixture provenance #2371",
* mirroring tests/workflow-fragments.property.test.cjs): section lists are
* generated as arbitrary document-order id/when sequences — the SHAPE a
* real `parseWorkflowSections` output would have — never by round-tripping
* through `selectSections`/`WHEN_PREDICATES` itself. `when` values are drawn
* from the module's own frozen `WHEN_VOCABULARY` re-export (imported from
* `workflow-fragments.cjs`, the true source of truth) rather than a
* hardcoded local copy, so the generator can never silently desync from
* production (DEFECT.GENERATIVE-FIX).
*
* Deterministic per CONTRIBUTING.md: seed and numRuns are pinned by
* tests/helpers/fast-check-setup.cjs (seed 42, numRuns 200).
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fc = require('./helpers/fast-check-setup.cjs');
const { selectSections, REASON } = require('../gsd-core/bin/lib/section-manifest.cjs');
const { WHEN_VOCABULARY } = require('../gsd-core/bin/lib/workflow-fragments.cjs');
const WHEN_VALUES = [...WHEN_VOCABULARY];
// Object.prototype-shaped `when` values — added during review — prototype-
// chain fail-open found by isolated adversarial pass. A bracket lookup on a
// plain frozen object resolves these as inherited members instead of failing
// closed; the totality property below must cover them too.
const HOSTILE_WHEN_VALUES = [
'constructor',
'toString',
'valueOf',
'hasOwnProperty',
'__proto__',
'prototype',
'isPrototypeOf',
'propertyIsEnumerable',
'toLocaleString',
];
// ─── Document-shaped generators ────────────────────────────────────────────
const whenArb = fc.constantFrom(...WHEN_VALUES);
// Ids are made unique WITHIN one generated document by suffixing the array
// index at assembly time (below) rather than relying on the raw string
// generator for uniqueness — this keeps the shape arbitrary while still
// letting assertions key on "this exact id".
const idBaseArb = fc.stringMatching(/^[a-z][a-z0-9-]{0,8}$/);
/** A document-order list of `{id, when}` sections, id uniqueness enforced by index-suffixing. */
const sectionsArb = fc
.array(fc.tuple(idBaseArb, whenArb), { minLength: 0, maxLength: 15 })
.map((pairs) => pairs.map(([base, when], idx) => ({ id: `${base}-${idx}`, when })));
const phaseNumberArb = fc.oneof(
fc.constant(null),
fc.constant(''),
fc.stringMatching(/^[0-9]{1,2}$/),
fc.stringMatching(/^[0-9]{1,2}\.[0-9]{1,2}$/),
);
const factsArb = fc.record({
waveFlag: fc.boolean(),
phaseNumber: phaseNumberArb,
hasPriorPhases: fc.boolean(),
});
// ─── Row 25: exact partition ────────────────────────────────────────────────
describe('property: selection is always an exact partition', () => {
test('selectionIsAlwaysAnExactPartitionOfInputSections', () => {
fc.assert(
fc.property(sectionsArb, factsArb, (sections, facts) => {
const { included, excluded } = selectSections(sections, facts);
const allIds = sections.map((s) => s.id);
// Union recovers every id exactly once, intersection is empty.
const unionSorted = [...included, ...excluded].sort();
assert.deepEqual(unionSorted, [...allIds].sort());
const intersection = included.filter((id) => excluded.includes(id));
assert.deepEqual(intersection, []);
}),
);
});
});
// ─── Row 26: `always` sections included under every fact combination ──────
describe('property: always sections are included under every fact combination', () => {
test('alwaysSectionsAreIncludedUnderEveryFactCombination', () => {
fc.assert(
fc.property(sectionsArb, factsArb, (sections, facts) => {
const { included } = selectSections(sections, facts);
const alwaysIds = sections.filter((s) => s.when === 'always').map((s) => s.id);
for (const id of alwaysIds) {
assert.equal(included.includes(id), true, `expected always-section "${id}" to be included`);
}
}),
);
});
});
// ─── Row 27: totality — never throws for vocab-valid when × arbitrary facts ─
describe('property: never throws for vocabulary-valid when and arbitrary facts', () => {
test('neverThrowsForVocabularyValidWhenAndArbitraryFacts', () => {
fc.assert(
fc.property(sectionsArb, factsArb, (sections, facts) => {
assert.doesNotThrow(() => selectSections(sections, facts));
}),
);
});
test('neverThrowsWhenFactsAreMissingKeysEntirely', () => {
// Totality also over PARTIAL facts objects (row 19's property-level
// twin): dropping zero or more of the three fact keys must never throw.
const factKeys = ['waveFlag', 'phaseNumber', 'hasPriorPhases'];
fc.assert(
fc.property(sectionsArb, factsArb, fc.subarray(factKeys), (sections, facts, keysToKeep) => {
const partialFacts = {};
for (const key of keysToKeep) partialFacts[key] = facts[key];
assert.doesNotThrow(() => selectSections(sections, partialFacts));
}),
);
});
});
// ─── Added during review: totality fails closed for prototype-shaped keys ──
// A hostile, Object.prototype-shaped `when` value injected anywhere in an
// otherwise vocab-valid document must always throw REASON.UNKNOWN_WHEN and
// must never appear in `included` — the prototype-chain fail-open the
// isolated adversarial pass found (constructor/toString/etc. resolving as
// truthy inherited members, or `__proto__` throwing an untyped error).
describe('property: prototype-shaped when values always fail closed', () => {
const hostileWhenArb = fc.constantFrom(...HOSTILE_WHEN_VALUES);
const hostileIdArb = fc.stringMatching(/^[a-z][a-z0-9-]{0,8}$/);
test('injectingAHostileWhenAnywhereAlwaysThrowsUnknownWhen', () => {
fc.assert(
fc.property(
sectionsArb,
hostileIdArb,
hostileWhenArb,
fc.nat(),
(sections, hostileIdBase, hostileWhen, rawIndex) => {
const hostileSection = { id: `hostile-${hostileIdBase}`, when: hostileWhen };
const insertAt = sections.length === 0 ? 0 : rawIndex % (sections.length + 1);
const withHostile = [...sections.slice(0, insertAt), hostileSection, ...sections.slice(insertAt)];
let caught;
try {
selectSections(withHostile, {});
} catch (err) {
caught = err;
}
assert.ok(caught instanceof TypeError, 'expected selectSections to throw for a hostile when value');
assert.equal(caught.reason, REASON.UNKNOWN_WHEN);
},
),
);
});
});
// ─── Row 28: order preservation ─────────────────────────────────────────────
describe('property: included ids preserve document order', () => {
test('includedIdsPreserveDocumentOrder', () => {
fc.assert(
fc.property(sectionsArb, factsArb, (sections, facts) => {
const { included, excluded } = selectSections(sections, facts);
const allIds = sections.map((s) => s.id);
// A subsequence check: the positions of `included` ids within
// `allIds`, taken in the order they appear in `included`, must be
// strictly increasing (never reordered relative to the input).
let cursor = -1;
for (const id of included) {
const pos = allIds.indexOf(id, cursor + 1);
assert.ok(pos > cursor, `id "${id}" out of document order in included[]`);
cursor = pos;
}
// Same subsequence guarantee for excluded[].
cursor = -1;
for (const id of excluded) {
const pos = allIds.indexOf(id, cursor + 1);
assert.ok(pos > cursor, `id "${id}" out of document order in excluded[]`);
cursor = pos;
}
}),
);
});
});

View File

@@ -0,0 +1,281 @@
'use strict';
/**
* Example-based unit tests for src/section-manifest.cts (compiled to
* gsd-core/bin/lib/section-manifest.cjs) — issue #2932 (epic #1671 Phase 5).
*
* Covers 50-test-matrix.md rows 1-24: section A (the pure `when=` evaluator)
* and section B (the `DEFECT.GENERATIVE-FIX` / Greenspun vocabulary parity
* guard against Phase 3's exported `WHEN_VOCABULARY`).
*
* No source-grep (CONTRIBUTING.md): every assertion is on typed values
* (`included`/`excluded` id arrays, the thrown error's `.reason`) — never on
* rendered text via `.includes()`/`.match()` of source/message prose.
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const { selectSections, WHEN_PREDICATES, REASON } = require('../gsd-core/bin/lib/section-manifest.cjs');
const { WHEN_VOCABULARY } = require('../gsd-core/bin/lib/workflow-fragments.cjs');
// The three branch sections named throughout the design doc's behavior
// table, plus one `always` section — the exact shape `selectSections`
// consumes (structurally compatible with a parsed `WorkflowSection` array,
// but hand-built here since this suite tests the evaluator in isolation).
const BRANCH_SECTIONS = Object.freeze([
{ id: 'preamble', when: 'always' },
{ id: 'partial-wave', when: 'flag:--wave' },
{ id: 'gap-closure-artifacts', when: 'state:gap-closure-phase' },
{ id: 'regression-gate', when: 'state:has-prior-phases' },
]);
function facts(overrides) {
return { waveFlag: false, phaseNumber: null, hasPriorPhases: false, ...overrides };
}
// ─── Rows 1-8: happy path + combinations over W/D/P ─────────────────────────
describe('W/D/P combination matrix (design doc behavior table rows 1-8)', () => {
test('selectsOnlyAlwaysSectionsWhenNoFactsHold', () => {
const result = selectSections(BRANCH_SECTIONS, facts({}));
assert.deepEqual(result.included, ['preamble']);
assert.deepEqual(result.excluded, ['partial-wave', 'gap-closure-artifacts', 'regression-gate']);
});
test('includesPartialWaveWhenWaveFlagPresent', () => {
const result = selectSections(BRANCH_SECTIONS, facts({ waveFlag: true }));
assert.deepEqual(result.included, ['preamble', 'partial-wave']);
assert.deepEqual(result.excluded, ['gap-closure-artifacts', 'regression-gate']);
});
test('includesGapClosureWhenPhaseNumberHasDecimal', () => {
const result = selectSections(BRANCH_SECTIONS, facts({ phaseNumber: '3.1' }));
assert.deepEqual(result.included, ['preamble', 'gap-closure-artifacts']);
assert.deepEqual(result.excluded, ['partial-wave', 'regression-gate']);
});
test('includesRegressionGateWhenPriorPhasesExist', () => {
const result = selectSections(BRANCH_SECTIONS, facts({ hasPriorPhases: true }));
assert.deepEqual(result.included, ['preamble', 'regression-gate']);
assert.deepEqual(result.excluded, ['partial-wave', 'gap-closure-artifacts']);
});
test('includesBothWaveAndGapClosureWhenBothHold', () => {
const result = selectSections(BRANCH_SECTIONS, facts({ waveFlag: true, phaseNumber: '3.1' }));
assert.deepEqual(result.included, ['preamble', 'partial-wave', 'gap-closure-artifacts']);
assert.deepEqual(result.excluded, ['regression-gate']);
});
test('includesBothWaveAndRegressionWhenBothHold', () => {
const result = selectSections(BRANCH_SECTIONS, facts({ waveFlag: true, hasPriorPhases: true }));
assert.deepEqual(result.included, ['preamble', 'partial-wave', 'regression-gate']);
assert.deepEqual(result.excluded, ['gap-closure-artifacts']);
});
test('includesBothGapClosureAndRegressionWhenBothHold', () => {
const result = selectSections(BRANCH_SECTIONS, facts({ phaseNumber: '3.1', hasPriorPhases: true }));
assert.deepEqual(result.included, ['preamble', 'gap-closure-artifacts', 'regression-gate']);
assert.deepEqual(result.excluded, ['partial-wave']);
});
test('includesEveryBranchSectionWhenAllFactsHold', () => {
const result = selectSections(BRANCH_SECTIONS, facts({ waveFlag: true, phaseNumber: '3.1', hasPriorPhases: true }));
assert.deepEqual(result.included, ['preamble', 'partial-wave', 'gap-closure-artifacts', 'regression-gate']);
assert.deepEqual(result.excluded, []);
});
});
// ─── Rows 9-14: phase-number decimal boundary + hostile literal rule ───────
describe('gap-closure-phase predicate boundary and hostile inputs', () => {
test('treatsTrailingZeroDecimalPhaseAsGapClosure', () => {
assert.equal(WHEN_PREDICATES['state:gap-closure-phase'](facts({ phaseNumber: '3.0' })), true);
});
test('treatsZeroPaddedDecimalPhaseAsGapClosure', () => {
assert.equal(WHEN_PREDICATES['state:gap-closure-phase'](facts({ phaseNumber: '03.1' })), true);
});
test('treatsIntegerPhaseAsNotGapClosure', () => {
assert.equal(WHEN_PREDICATES['state:gap-closure-phase'](facts({ phaseNumber: '3' })), false);
assert.equal(WHEN_PREDICATES['state:gap-closure-phase'](facts({ phaseNumber: '04' })), false);
});
test('treatsNullPhaseNumberAsNotGapClosure', () => {
assert.equal(WHEN_PREDICATES['state:gap-closure-phase'](facts({ phaseNumber: null })), false);
});
test('treatsEmptyPhaseNumberAsNotGapClosure', () => {
assert.equal(WHEN_PREDICATES['state:gap-closure-phase'](facts({ phaseNumber: '' })), false);
});
test('treatsBareDotPhaseNumberLiterallyPerDocumentedRule', () => {
// The predicate is deliberately literal (`.includes('.')`) per the
// design doc's negative-space note — it does not invent a stricter
// regex the section body's documented rule does not claim.
assert.equal(WHEN_PREDICATES['state:gap-closure-phase'](facts({ phaseNumber: '.' })), true);
});
});
// ─── Rows 15-17: boundary section-list sizes ────────────────────────────────
describe('boundary section-list sizes (limit-1 / limit / limit+1)', () => {
test('returnsEmptySelectionForWorkflowWithNoSections', () => {
const result = selectSections([], facts({}));
assert.deepEqual(result, { included: [], excluded: [] });
});
test('partitionsSingleSectionWorkflow', () => {
const includedResult = selectSections([{ id: 'only', when: 'always' }], facts({}));
assert.deepEqual(includedResult, { included: ['only'], excluded: [] });
const excludedResult = selectSections([{ id: 'only', when: 'flag:--wave' }], facts({}));
assert.deepEqual(excludedResult, { included: [], excluded: ['only'] });
});
test('preservesDocumentOrderAcrossManySections', () => {
// Duplicates-by-when: several sections sharing the SAME when= value must
// each retain their own id and their own document-order position.
const sections = [
{ id: 's0', when: 'always' },
{ id: 's1', when: 'flag:--wave' },
{ id: 's2', when: 'always' },
{ id: 's3', when: 'flag:--wave' },
{ id: 's4', when: 'state:gap-closure-phase' },
{ id: 's5', when: 'always' },
{ id: 's6', when: 'state:has-prior-phases' },
];
const result = selectSections(sections, facts({ waveFlag: true }));
assert.deepEqual(result.included, ['s0', 's1', 's2', 's3', 's5']);
assert.deepEqual(result.excluded, ['s4', 's6']);
});
});
// ─── Row 18: fail-closed on unknown when= ───────────────────────────────────
describe('fail-closed on an unrecognized when= value', () => {
test('throwsOnWhenValueOutsideFrozenVocabulary', () => {
assert.throws(
() => selectSections([{ id: 'x', when: 'flag:--nonexistent' }], facts({})),
(err) => err instanceof TypeError && err.reason === REASON.UNKNOWN_WHEN,
);
});
});
// ─── Row 19: totality over facts ────────────────────────────────────────────
describe('totality: an absent fact key is treated as falsy, never throws', () => {
test('treatsAbsentFactAsFalseWithoutThrowing', () => {
assert.doesNotThrow(() => selectSections(BRANCH_SECTIONS, {}));
const result = selectSections(BRANCH_SECTIONS, {});
assert.deepEqual(result.included, ['preamble']);
assert.deepEqual(result.excluded, ['partial-wave', 'gap-closure-artifacts', 'regression-gate']);
});
});
// ─── Row 20: determinism + non-mutation ─────────────────────────────────────
describe('determinism and input non-mutation', () => {
test('isDeterministicAndDoesNotMutateInput', () => {
const sections = [
{ id: 'a', when: 'always' },
{ id: 'b', when: 'flag:--wave' },
];
const snapshotBefore = sections.map((s) => ({ ...s }));
const f = facts({ waveFlag: true });
const first = selectSections(sections, f);
const second = selectSections(sections, f);
assert.deepEqual(first, second);
assert.deepEqual(sections, snapshotBefore);
assert.equal(Array.isArray(sections), true);
assert.equal(sections.length, 2);
});
});
// ─── Rows 21-23: DEFECT.GENERATIVE-FIX vocabulary parity guard ─────────────
describe('WHEN_PREDICATES and WHEN_VOCABULARY parity (DEFECT.GENERATIVE-FIX)', () => {
test('everyFrozenVocabularyEntryHasAPredicate', () => {
for (const when of WHEN_VOCABULARY) {
assert.equal(typeof WHEN_PREDICATES[when], 'function', `expected a predicate for when="${when}"`);
}
});
test('everyPredicateKeyIsInTheFrozenVocabulary', () => {
for (const when of Object.keys(WHEN_PREDICATES)) {
assert.equal(WHEN_VOCABULARY.includes(when), true, `predicate key "${when}" is not in WHEN_VOCABULARY`);
}
});
test('failsWhenVocabularyGainsAnEntryWithoutAPredicate', () => {
// Simulates a 5th vocabulary entry being added without a corresponding
// predicate: the SAME parity check as row 21, run against a vocabulary
// array with an extra entry, must fail (i.e. NOT every entry has a
// predicate) until a predicate is added on the evaluator side too.
const widenedVocabulary = [...WHEN_VOCABULARY, 'state:not-yet-real'];
const missing = widenedVocabulary.filter((when) => typeof WHEN_PREDICATES[when] !== 'function');
assert.deepEqual(missing, ['state:not-yet-real']);
});
});
// ─── Row 24: REASON enum shape is locked ────────────────────────────────────
describe('REASON enum is frozen and its shape is locked', () => {
test('locksReasonEnumKeySet', () => {
assert.equal(Object.isFrozen(REASON), true);
assert.deepEqual(Object.keys(REASON).sort(), ['UNKNOWN_WHEN']);
});
});
// ─── Rows 25-33: Object.prototype-shaped when= values fail closed ──────────
// Added during review — prototype-chain fail-open found by isolated
// adversarial pass. A bracket lookup on a plain frozen object resolves
// inherited Object.prototype members (`constructor`, `toString`, etc.) as if
// they were predicates, silently including the section or throwing an
// untyped error instead of failing closed with REASON.UNKNOWN_WHEN.
describe('Object.prototype-shaped when= values fail closed (REASON.UNKNOWN_WHEN)', () => {
const HOSTILE_WHEN_VALUES = Object.freeze([
'constructor',
'toString',
'valueOf',
'hasOwnProperty',
'__proto__',
'prototype',
'isPrototypeOf',
'propertyIsEnumerable',
'toLocaleString',
]);
for (const when of HOSTILE_WHEN_VALUES) {
test(`throwsUnknownWhenFor_${when}`, () => {
assert.throws(
() => selectSections([{ id: 'x', when }], facts({})),
(err) => err instanceof TypeError && err.reason === REASON.UNKNOWN_WHEN,
);
});
test(`neverIncludesSectionFor_${when}`, () => {
let caught;
try {
selectSections([{ id: 'x', when }], facts({}));
} catch (err) {
caught = err;
}
assert.ok(caught, `expected selectSections to throw for when="${when}"`);
assert.equal(caught.reason, REASON.UNKNOWN_WHEN);
});
}
test('noneOfTheHostileValuesAppearInIncludedAcrossAMixedSectionList', () => {
for (const when of HOSTILE_WHEN_VALUES) {
assert.throws(
() => selectSections([{ id: 'safe', when: 'always' }, { id: 'hostile', when }], facts({})),
(err) => err instanceof TypeError && err.reason === REASON.UNKNOWN_WHEN,
);
}
});
});

View File

@@ -25,7 +25,12 @@ const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.join(__dirname, '..');
// #2932: the regression-gate step was split — steps/regression-gate.md now only
// discovers prior-phase test files and delegates (via "Read and execute") to
// steps/regression-gate-run.md, which carries the actual command resolution,
// normalize-test-command call, timeout bound, and watch-mode abort mechanics.
const REGRESSION_GATE = path.join(ROOT, 'gsd-core', 'workflows', 'execute-phase', 'steps', 'regression-gate.md');
const REGRESSION_GATE_RUN = path.join(ROOT, 'gsd-core', 'workflows', 'execute-phase', 'steps', 'regression-gate-run.md');
const POST_MERGE_GATE = path.join(ROOT, 'gsd-core', 'workflows', 'execute-phase', 'steps', 'post-merge-gate.md');
const AUDIT_FIX = path.join(ROOT, 'gsd-core', 'workflows', 'audit-fix.md');
const VERIFY_PHASE = path.join(ROOT, 'gsd-core', 'workflows', 'verify-phase.md');
@@ -34,8 +39,9 @@ const EXECUTE_PHASE = path.join(ROOT, 'gsd-core', 'workflows', 'execute-phase.md
function read(p) { return fs.readFileSync(p, 'utf-8'); }
// The three gates that were unbounded/silently-continued: full normalize + configured timeout.
// Regression gate's mechanics now live in the delegated regression-gate-run.md (#2932).
const FULL_GATES = [
['regression gate', REGRESSION_GATE],
['regression gate', REGRESSION_GATE_RUN],
['post-merge gate', POST_MERGE_GATE],
['audit-fix gate', AUDIT_FIX],
];
@@ -86,7 +92,7 @@ describe('#1857: test gates normalize to one-shot and bound with a timeout', ()
test('the gates share ONE normalizer — the helper is a single source of truth', () => {
// The behaviour lives in src/normalize-test-command.cts; every gate invokes it
// by the same verb name, so a change to watch-defeat logic touches one place.
for (const file of [REGRESSION_GATE, POST_MERGE_GATE, AUDIT_FIX, VERIFY_PHASE]) {
for (const file of [REGRESSION_GATE_RUN, POST_MERGE_GATE, AUDIT_FIX, VERIFY_PHASE]) {
assert.match(read(file), /gsd_run query normalize-test-command/);
}
});
@@ -109,7 +115,7 @@ describe('#2350: every gate resolves build/test commands with --raw', () => {
// build a shell command it then runs. Add new gates here as they appear.
const GATE_FILES = [
['post-merge gate', POST_MERGE_GATE],
['regression gate', REGRESSION_GATE],
['regression gate', REGRESSION_GATE_RUN],
['verify-phase gate', VERIFY_PHASE],
['audit-fix gate', AUDIT_FIX],
];

View File

@@ -452,6 +452,37 @@ test('nonWorkflowMarkdownWithMarkerShapedLineIsNotComposed', () => {
// ─── Row 36: a malformed marker fails install loudly, with no partial emit ─
// ─── Row 63 (50-test-matrix.md, issue #2932 Phase 5): extracting
// execute-phase.md's 3 sections must not perturb any OTHER workflow's
// emission — independence guard for the CRITICAL blast radius Phase 5's own
// design doc calls out. Pure-function check (no spawn needed): composeWorkflow
// is a documented no-op for every unmarked file, so any file other than the
// one Phase 5 migrates must still compose to itself, byte-identical. ────────
test('leavesUnmarkedWorkflowEmissionByteIdentical', () => {
const workflowsDir = path.join(REPO_ROOT, 'gsd-core', 'workflows');
const workflowFiles = fs
.readdirSync(workflowsDir, { withFileTypes: true })
.filter((d) => d.isFile() && d.name.endsWith('.md'))
.map((d) => d.name);
assert.ok(workflowFiles.length > 1, 'sanity: there must be more than the pilot workflow on disk');
let checkedCount = 0;
for (const fileName of workflowFiles) {
if (fileName === 'execute-phase.md') continue; // the one file #2932 Phase 5 migrates
const filePath = path.join(workflowsDir, fileName);
const source = fs.readFileSync(filePath, 'utf8');
const composed = composeWorkflow(source, { sourcePath: filePath });
assert.equal(
composed,
source,
`${fileName}: emission drifted — Phase 5's execute-phase.md extraction must not touch any other workflow`,
);
checkedCount += 1;
}
assert.equal(checkedCount, workflowFiles.length - 1, 'every workflow file except execute-phase.md must have been checked');
});
test('malformedMarkersFailInstallWithoutPartialEmit', () => {
const malformed = '<!-- gsd:section id="broken" when="always" -->\nnever closed\n';
const overlayRepo = buildOverlayRepo({ 'gsd-core/workflows/execute-phase.md': malformed });