feat(#1169): complete ADR-857 phase 6 — migrate features to Capabilities, revive dead gates, harden conformance gate (#1183)
* test(#1168): make phase-6 gate un-gameable — reject empty stubs + require loop shrink The migration assertion previously checked only role==feature, so a registration-only stub (empty hooks, logic left inline) would turn the gate green while phase 6 stayed incomplete — the exact false-completion pattern this gate exists to prevent. Strengthen it: each ADR-named feature must OWN its behavior (>=1 hook, or a command family); and plan-phase.md/execute-phase.md must shrink strictly below their frozen pre-phase-6 sizes (94519/93166 LF bytes), which also defeats double-run gaming (declare a hook but keep the inline block -> file does not shrink -> red). Gate now 5 pass / 4 fail (orphaned execute:wave:post, empty/unregistered features, config-key leaks, no shrink). Green is now reachable only by REAL migration. Refs #1168, #1169. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(#1169): migrate gap-analysis to a Capability (plan:post gate) First real ADR-857 phase-6 migration (pattern-defining tracer). gap-analysis moves from an inline post_planning_gaps branch in plan-phase.md to a real plan:post gate Capability: - capabilities/gap-analysis/capability.json: role:feature, plan:post gate (when=workflow.post_planning_gaps, blocking:false advisory), OWNS workflow.post_planning_gaps (federated out of central schema). - plan-phase.md: inline config-get + gsd_run gap-analysis block replaced with a plan:post render-hooks call site dispatching the gate; file shrinks 94519->93279. - src/check-command-router.cts: cmdGapAnalysisPlanPost runs the real gap analysis via gap-checker. - post_planning_gaps removed from central manifest; resolves via federated config (default true preserved). - tests/post-planning-gaps-2493: re-pointed to assert capability ownership. Verified: gate 5 pass / 4 fail (gap-analysis cleared from migration, plan:post-orphan, config-leak, and plan-phase shrink checks); loadConfig still returns post_planning_gaps=true; check command runs real analysis; 392/392 in the config/registry/federation/router net. Refs #1169. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(#1169): migrate profile-pipeline to a command-family Capability ADR-857 Decision 7: profile-pipeline becomes a command-family Capability (like audit/intel/graphify). capabilities/profile-pipeline/capability.json declares an 8-command family (scan-sessions, extract-messages, profile-sample, write-profile, profile-questionnaire, generate-dev-preferences, generate-claude-profile, generate-claude-md) backed by a new gsd-core/bin/lib/profile-pipeline-command-router.cjs; the inline case arms are removed from gsd-tools.cjs. Owns profile-pipeline.enabled (federated). Verified: registry shows role:feature with commands.length=8; scan-sessions/profile-sample run live via the family; gate cleared profile-pipeline from the empty-stub failure (only tdd/schema-gate/drift remain); 296/296 registry+inventory+gsd-tools tests; lint 0 errors. Refs #1169. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#1167): wire execute:wave:post + implement ui.safety-gate check Revives the second dead gate from #1167: ui.gates@execute:wave:post was declared but never dispatched AND its check.query (ui.safety-gate) was unimplemented. Adds the per-wave execute:wave:post render-hooks call site in execute-phase.md (fires after each wave's merge/cleanup, before the next forks) and implements cmdUiSafetyGate (frontend + UI-SPEC aware, mirrors cmdUiPlanGate) in check-command-router. +17 regression tests. Verified: phase-6 orphaned-points conformance test now PASSES (gate 6 pass / 3 fail); ui-safety-gate routable in dot+hyphen forms; check-ui-safety-gate 17/17, check-ui-plan-gate 18/18; lint 0 errors. Refs #1167, #1168. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(#1169): migrate drift (schema + codebase) to execute:wave:post gates Removes the inline schema_drift_gate + codebase_drift_gate steps (77 lines) from execute-phase.md; drift becomes a Capability with two execute:wave:post gates (verify.schema-drift blocking, verify.codebase-drift advisory) dispatched via the per-wave render-hooks call site. check-command-router routes verify.schema-drift / verify.codebase-drift to the real detectors. Federates workflow.drift_threshold / drift_action / schema_drift_gate out of central. Also fixes the execute:wave:post dispatch prose to run NON-blocking (advisory) gates too — the prior version only ran blocking gates, which would have silently dropped the codebase-drift advisory after its inline step was removed. Behavior preserved. Verified: gate 7 pass / 2 fail (drift cleared from stub + config-leak; execute-phase.md 92297 < 93166 frozen -> shrink passes); both drift checks run real detection; loadConfig defaults preserved (threshold=3, action=warn, gate=true); drift-detection 56/56 + schema-drift 34/34; lint 0 errors. Refs #1169. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(#1169): migrate tdd to a Capability (plan:pre contribution + execute:post gate) tdd becomes a real Capability: a plan:pre contribution injects the <tdd_mode_active> planner guidance (rendered from PLAN_PRE_HOOKS_JSON like security's contribution), and an execute:post gate (tdd.review-checkpoint, advisory) runs the real end-of-phase RED/GREEN review via a new check-command handler. Inline tdd_mode reads + the inline planner block + the tdd_review_checkpoint step are removed; workflow.tdd_mode is federated out of central. The MVP+TDD per-task RED-commit gate is preserved — TDD_MODE is now derived from the execute:post hooks (capId==tdd active), not an inline config-get. BEHAVIOR CHANGE (documented, not silent): the --tdd CLI flag now persists workflow.tdd_mode=true via config-set instead of being per-invocation. Rationale: tdd is now a config-toggled Capability, and env vars do not persist across the workflow's separate bash blocks (config does), so an ephemeral override isn't cleanly achievable; --tdd therefore enables the tdd capability, consistent with how all capabilities are toggled. Verified: gate 7 pass / 2 fail (tdd cleared from stub + config-leak; plan-phase + execute-phase both < frozen sizes); contribution injection + execute:post gate dispatch wired; MVP+TDD gate preserved; tdd.review-checkpoint runs real review; full unit suite 556/0; lint 0 errors. Refs #1169. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(#1169): migrate schema-gate to a plan:pre contribution Capability The plan-time schema-push detection (former plan-phase.md §5.7) becomes a schema-gate Capability: a plan:pre contribution (into:planner, when:workflow.schema_push_detection) whose fragment carries the full ORM-detection + [BLOCKING] schema-push-task injection logic, rendered into the planner via the existing plan:pre render-hooks dispatch. The inline §5.7 block is removed (plan-phase.md 94519->90445). workflow.schema_push_detection is a new capability-owned (federated) key, default true. (The execute-side schema-drift gate was migrated separately into the drift capability.) Verified: registry inlines the fragment (len 2704) so it is actually delivered at plan:pre; gate 8 pass / 1 fail — all 5 ADR-named features now real Capabilities, only the config-leak test remains (intel/security, next unit). Refs #1169. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(#1169): close the 3 capability config-key leaks — phase-6 gate now GREEN Removes the last inline config-get reads of capability-owned keys from plan-phase.md. security_asvs_level/security_block_on now flow through the security plan:pre contribution via a new loop-resolver configValues mechanism (resolves declared config keys with the same 4-level precedence as activation and attaches them to the rendered hook); the §5.55 banner reads them from PLAN_PRE_HOOKS_JSON. intel.enabled becomes a real intel plan:pre step (ref.command: intel api-surface) dispatched via render-hooks; the inline intel branch is gone. gen-capability-registry now validates ref.command as a third dispatch shape. Verified: phase-6 capstone conformance gate is FULLY GREEN (9/0); 3 leaks gone (grep=0); security configValues resolve to {2,medium}/default {1,high}; intel step present only when enabled; loop-render-hooks 62/0, capability-registry 287/0, capability-state/federated-config 113/0; lint 0 errors. Closes the migration half of #1169. Refs #1139, #1167, #1168. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#1169): address adversarial review — restore schema-drift block, generic planner injection, uniform gate contract Adversarial review caught 2 real regressions the green gate missed: (1) schema-drift no longer blocked — the execute:wave:post dispatch read GATE_RESULT.block but verify.schema-drift emitted drift_detected/blocking, and onError:skip wrongly bypassed positive blocks; (2) only tdd's plan:pre contribution was injected into the planner, dropping schema-gate's schema-push detection and security's threat-model guidance. Fixes: (A) every gate check returns a uniform boolean 'block' under --raw (the dispatch form), with advisory gates (tdd/gap) carrying their report in 'message'; (B) gate-dispatch contract corrected at all sites — onError governs command errors only, a blocking gate's positive block always halts; (C) generic planner injection of all plan:pre contributions where into=='planner' (tdd + schema-gate + security incl configValues); (D) two new conformance assertions: planner contributions injected generically + every gate check.query returns boolean block under --raw. Verified: gate 11/11; all 6 gate checks return boolean block under --raw; full suite 595/0; lint 0 errors. Refs #1167, #1168, #1169. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#1169): restore MVP+TDD end-of-phase blocking escalation (2nd adversarial pass) The migrated tdd execute:post gate is statically blocking:false, but the contract (references/execute-mvp-tdd.md + CONTEXT.md) requires the end-of-phase TDD review to ESCALATE from advisory to blocking when MVP_MODE && TDD_MODE && a TDD plan misses a RED/GREEN commit. The migration prose had downgraded this to a 'strong advisory recommendation' — silent loss of the blocking escalation. Restore it: the tdd-gate dispatch now refuses to mark the phase complete (Phase blocked message) under MVP+TDD when GATE_RESULT.block is true; advisory otherwise. Also strengthen tests/execute-mvp-tdd-gate.test.cjs: hasBlockingEscalation previously matched any line with 'blocking'+'mvp+tdd' (so 'advisory (blocking: false) ... under MVP+TDD' was a false green); now it requires the real refusal semantics ('refuse to mark the phase complete' / 'phase blocked'). Caught by 2nd adversarial review pass. Verified: execute-phase.md 92702 < 93166 frozen; mvp-tdd-gate + phase-6 gate 19/0; full suite green; lint 0 errors. Refs #1169. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#1169): restore MVP+TDD proceed-block, codebase auto-remap, schema skip-flag (3rd adversarial pass) 3rd adversarial pass found 4 more silent regressions: (1) the tdd MVP+TDD 'refuse to mark complete' was nullified by a downstream 'ALWAYS proceed regardless of gate results' line — proceed is now conditional (stops on an active MVP+TDD block); (2) the test now asserts the proceed is NOT an unconditional override; (3) codebase-drift auto-remap (spawn gsd-codebase-mapper when drift_action=auto-remap) was dropped — the execute:wave:post advisory dispatch now consumes spawn_mapper/directive; (4) GSD_SKIP_SCHEMA_CHECK bypass was lost from the gate path — cmdVerifySchemaDrift now honors the env var (block:false when set). Verified: no unconditional proceed; GSD_SKIP_SCHEMA_CHECK=true -> block:false; gate 11/11 + mvp-tdd 9/9; full suite 569/0; lint 0; execute-phase.md 93109 < 93166. Refs #1169. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#1169): init.cts reads federated config keys from nested path (4th adversarial pass) Config federation moved tdd_mode/research/nyquist_validation from flat config.<key> to nested config.workflow.<key>, but src/init.cts still read them flat — so init.plan-phase/init.execute-phase emitted tdd_mode:false / research_enabled:undefined / nyquist:undefined regardless of config (a public command-contract regression; the migrated loops use render-hooks so enforcement was unaffected). Read via config.workflow (type-safe Record cast). Now init reflects the same resolved values + federated defaults (research/nyquist default true) as the render-hooks path. Verified: build clean; init.plan-phase emits tdd_mode:true/research:false/nyquist:false for set config, defaults true for empty; full suite 591/0; lint 0. Refs #1169. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(#1169): add changeset for ADR-857 phase-6 completion (PR #1183) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#1169): complete phase-6 migration fallout — restore TEXT_MODE, fix registry .claude leak, re-point stale workflow-contract tests The capability migration left real regressions and stale consumer tests that the per-module unit suite missed but the full cross-platform suite caught (27 failing tests): Real source regressions (fixed): - execute-phase.md lost its AskUserQuestion TEXT_MODE plain-text fallback when the inline schema_drift_gate step was removed — non-Claude runtimes would stall. Restored, and the execute:post gate-dispatch prose de-duplicated to cite the execute:wave:post contract (loop body shrinks below the frozen pre-phase-6 ceiling while keeping every onError/blocking nuance). - capabilities/tdd inline fragment hardcoded `@~/.claude/gsd-core/references/tdd.md`, baked verbatim into the committed capability-registry.cjs and leaked the install path on 11 non-Claude runtimes (registry .cjs is copied, not path-converted). Made the fragment path-free; regenerated the registry. The phase-6 conformance gate now guards this (no ~/.claude install path in any capability source or the generated registry). - plan-phase.md: removed a §5.7 stub re-added in error and routed Branch 2 to step 6 (schema-gate is a plan:pre capability, §5.7 is gone). Stale workflow-contract tests re-pointed to the capability dispatch they now must assert (behavior verified preserved in source first, assertions kept equal-or-stronger): bug-621 + bug-2851 (gap-analysis via gsd_run render-hooks plan:post + registry binding), feat-2527 (tdd_mode federated out of central), phase6-planning + plan-phase-ui-redirect (§5.6 bounded by ## 6.), plan-phase-drift-guard (intel when:intel.enabled skip branch). profile-pipeline-command-router.cjs un-ignored from eslint (hand-written, no TS source) + stale disable comments removed. Size baseline regenerated. Verified: full suite 15140 tests / 0 fail; lint 0 errors; conformance gate green legitimately. Refs #1139, #1167, #1168, #1169. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(#1169): add ADR-857 E2E content-test coverage for the 12 loop points + capability deliverables Grounds the capability engine in behavioral E2E tests (drive the real render-hooks/check CLI + the real registry, assert typed result content — no source-grep), structured around what ADR-857 says to deliver. 207 tests; each genuineness-checked (flip the expectation, confirm it fails). Per-loop-point dispatch (7 files): empty-point negative-space across the 6 no-hook points; verify:post 3-step resolution+ordering+onError; plan:pre contribution/configValues + ui.plan-gate + intel; plan:post gap-analysis; execute:wave:post drift+ui gates via the check route (schema-drift block/skip, codebase-drift threshold BVA, auto-remap); execute:post tdd.review-checkpoint RED/GREEN; ship:pre security gate resolution + frontmatter-get predicate pieces. ADR-deliverable coverage (4 files): predicate boundary held (edge/prohibition probes stay core, not off-by-default Feature Capabilities — phase-6 exception); core loop runs with zero capabilities (all 12 points empty, init bundles resolve); contribution merge (multiple ordered <contribution from=> blocks); federated-config key removal on uninstall. federated-config allowlisted for its 3-file split (unit + integration + lifecycle). Refs #1139, #1167, #1168, #1169. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#1169): remove dead drifted converter dups + address adversarial review Lint cleanup (root-caused, not waved off): src/runtime-artifact-conversion.cts carried 11 agent-converter functions (+5 orphaned consts/helpers) that were never exported, never called, and had silently DRIFTED from the live hand-authored copies in bin/install.js (one even referenced an undefined `claudeToCopilotTools`). Deleted the dead duplicates; install.js's live copies are untouched (it never imported these). Lint now 0 errors / 0 warnings. Adversarial-review (Codex) findings fixed: - HIGH: execute-phase.md TDD_MODE used `jq ... || echo false`, silently disabling the MVP+TDD blocking gate on jq-less runtimes. Reverted to the `node -e` form (node is guaranteed; matches the file's other node-e usages) so a missing optional tool can no longer fail-open a blocking safety path. - MEDIUM: federated-config-key-removal orphan-key test was vacuous (it skipped the orphan assertion). Now asserts the removed capability's key is genuinely not surfaced/validated after uninstall. - LOW: phase-6 conformance leak regex broadened to catch absolute-home and Windows-backslash `.claude/(gsd-core|commands|agents|hooks)` paths, not only `~`/`$HOME` forward-slash forms. - LOW: bug-2851 plan:post dispatch assertion now requires `--raw` (matched its stated contract). - nit: plan-pre intel-step test duplicate assertion replaced with a distinct structured-output check. Size baseline regenerated (execute-phase.md 93089 < 93166 frozen). Refs #1167, #1169. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(#1169): make runtime-homes-descriptor-drive titles environment-independent The descriptor-equivalence test embedded the absolute golden config path (`os.homedir()`-derived) directly in each `test(...)` title, so titles differed between macOS (`/Users/x/.claude`) and Docker (`/home/gsdtest/.claude`). Every test PASSES on both platforms (15885/0 leaf tests each), but gsd-test-summary compares results by title and reported 29+29 false "only in Mac / only in Docker" discrepancies for tests that actually pass everywhere. Move the golden path out of the title and into the assertion message (still shown on failure); titles are now byte-identical across platforms so the cross-platform comparator matches them. No assertion logic or golden values changed. Refs #1169. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#1169): derive TDD_MODE via gsd_run --active-cap, not node -e (fix prompt-injection CI gate) The prior fix reverted execute-phase.md:181 from jq to `node -e` to close a Codex HIGH (jq||echo-false silently disabling the MVP+TDD blocking gate on jq-less runtimes) — but the CI prompt-injection scanner BLOCKS new `node -e` in workflow markdown (inline code-exec = injection vector), turning the security gate red. Both forms were wrong: node -e fails the scanner; jq fail-opens a blocking safety gate; `config-get workflow.tdd_mode` is forbidden by the conformance leak gate (tdd_mode is capability-owned). Correct fix (what Codex recommended): a gsd_run-native boolean. Add an `--active-cap <capId>` flag to `loop render-hooks <point>` that resolves hooks the normal way and prints exactly `true`/`false` for whether a capId is active — scanner-safe (canonical launcher, no inline code), node-reliable (no optional jq to fail-open), and leak-free (render-hooks resolution, not config-get). execute-phase.md:181 now `TDD_MODE=$(gsd_run loop render-hooks execute:post --active-cap tdd)`. +5 behavioral tests for the flag. Verified: prompt-injection-scan --diff origin/next → 0 findings; conformance gate 13/13 (execute-phase.md 92934 < 93166); execute-mvp-tdd + tdd-mode + loop-render-hooks 87/0; lint 0/0. Refs #1167, #1169. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
5
.changeset/1169-phase6-capstone-completion.md
Normal file
5
.changeset/1169-phase6-capstone-completion.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Changed
|
||||
pr: 1183
|
||||
---
|
||||
**ADR-857 phase 6 complete: optional features are now Capabilities, not inline loop branches.** `tdd`, `schema-gate`, `drift`, `gap-analysis`, and `profile-pipeline` are migrated out of the five-step host loop into declarative Capabilities (loop hooks + a command family); their config keys are federated to capability ownership; and the `plan-phase`/`execute-phase` workflow bodies shrink accordingly. Two previously-declared-but-dead capability gates now actually fire — the security ship-time gate (`ship:pre`) and the UI safety gate (`execute:wave:post`) — and the phase-6 conformance gate is hardened to be un-gameable (rejects empty stubs, requires loop-body shrink, verifies hook dispatch and gate-result contracts). Behavior is preserved, verified across five adversarial review passes. (#1139, #1167, #1168, #1169)
|
||||
48
capabilities/drift/capability.json
Normal file
48
capabilities/drift/capability.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"id": "drift",
|
||||
"role": "feature",
|
||||
"title": "Drift detection gates",
|
||||
"description": "Post-execution drift detection gates that run after each wave completes. Provides two gates at execute:wave:post: a blocking schema drift gate (detects schema files changed without a database push) and a non-blocking codebase drift gate (detects structural additions not reflected in STRUCTURE.md).",
|
||||
"tier": "full",
|
||||
"requires": [],
|
||||
"runtimeCompat": { "supported": ["*"], "unsupported": [] },
|
||||
"skills": [],
|
||||
"agents": [],
|
||||
"hooks": [],
|
||||
"config": {
|
||||
"workflow.drift_threshold": {
|
||||
"type": "number",
|
||||
"default": 3,
|
||||
"description": "Minimum number of new structural elements (directories, barrel exports, migrations, routes) before the codebase drift gate triggers a warn or auto-remap action."
|
||||
},
|
||||
"workflow.drift_action": {
|
||||
"type": "enum",
|
||||
"values": ["warn", "auto-remap"],
|
||||
"default": "warn",
|
||||
"description": "Action taken by the codebase drift gate when the threshold is exceeded: warn (advisory message) or auto-remap (spawn gsd-codebase-mapper agent to refresh STRUCTURE.md)."
|
||||
},
|
||||
"workflow.schema_drift_gate": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Enable the drift gates at execute:wave:post. When enabled, the schema drift gate blocks verification if schema-relevant files changed during execution but no database push command was executed; the codebase drift gate (non-blocking) warns when structural additions exceed the drift_threshold."
|
||||
}
|
||||
},
|
||||
"steps": [],
|
||||
"contributions": [],
|
||||
"gates": [
|
||||
{
|
||||
"point": "execute:wave:post",
|
||||
"check": { "query": "verify.schema-drift" },
|
||||
"when": "workflow.schema_drift_gate",
|
||||
"blocking": true,
|
||||
"onError": "skip"
|
||||
},
|
||||
{
|
||||
"point": "execute:wave:post",
|
||||
"check": { "query": "verify.codebase-drift" },
|
||||
"when": "workflow.schema_drift_gate",
|
||||
"blocking": false,
|
||||
"onError": "skip"
|
||||
}
|
||||
]
|
||||
}
|
||||
32
capabilities/gap-analysis/capability.json
Normal file
32
capabilities/gap-analysis/capability.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"id": "gap-analysis",
|
||||
"role": "feature",
|
||||
"title": "Post-planning gap analysis",
|
||||
"description": "Proactive, non-blocking post-planning coverage report. After all PLAN.md files are generated, cross-references every REQ-ID and D-ID from REQUIREMENTS.md and CONTEXT.md against plan bodies. Emits a Source | Item | Status table. Does not block phase advancement.",
|
||||
"tier": "standard",
|
||||
"requires": [],
|
||||
"runtimeCompat": { "supported": ["*"], "unsupported": [] },
|
||||
"skills": [],
|
||||
"agents": [],
|
||||
"hooks": [],
|
||||
"config": {
|
||||
"workflow.post_planning_gaps": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Run the post-planning gap analysis report after plans are generated."
|
||||
}
|
||||
},
|
||||
"steps": [],
|
||||
"contributions": [],
|
||||
"gates": [
|
||||
{
|
||||
"point": "plan:post",
|
||||
"check": {
|
||||
"query": "gap-analysis.plan-post"
|
||||
},
|
||||
"when": "workflow.post_planning_gaps",
|
||||
"blocking": false,
|
||||
"onError": "skip"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -23,7 +23,16 @@
|
||||
}
|
||||
],
|
||||
"hooks": [],
|
||||
"steps": [],
|
||||
"steps": [
|
||||
{
|
||||
"point": "plan:pre",
|
||||
"ref": { "command": "intel api-surface" },
|
||||
"produces": [".planning/intel/API-SURFACE.md"],
|
||||
"consumes": [],
|
||||
"when": "intel.enabled",
|
||||
"onError": "skip"
|
||||
}
|
||||
],
|
||||
"contributions": [],
|
||||
"gates": []
|
||||
}
|
||||
|
||||
64
capabilities/profile-pipeline/capability.json
Normal file
64
capabilities/profile-pipeline/capability.json
Normal file
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"id": "profile-pipeline",
|
||||
"role": "feature",
|
||||
"title": "Developer profiling pipeline",
|
||||
"description": "Developer behavioral profiling from Claude Code session history; scans session JSONL files, extracts and samples user messages, and generates profile artifacts (USER-PROFILE.md, dev-preferences.md, CLAUDE.md sections). Exposes eight `gsd-tools` commands: scan-sessions, extract-messages, profile-sample (pipeline phase) and write-profile, profile-questionnaire, generate-dev-preferences, generate-claude-profile, generate-claude-md (output phase). Backs the /gsd-profile-user skill and gsd-user-profiler agent.",
|
||||
"tier": "full",
|
||||
"requires": [],
|
||||
"runtimeCompat": { "supported": ["*"], "unsupported": [] },
|
||||
"skills": ["profile-user"],
|
||||
"agents": ["gsd-user-profiler"],
|
||||
"config": {
|
||||
"profile-pipeline.enabled": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Enable the developer profiling pipeline commands (scan-sessions, extract-messages, profile-sample, write-profile, etc.)."
|
||||
}
|
||||
},
|
||||
"commands": [
|
||||
{
|
||||
"family": "scan-sessions",
|
||||
"module": "profile-pipeline-command-router.cjs",
|
||||
"router": "routeScanSessions"
|
||||
},
|
||||
{
|
||||
"family": "extract-messages",
|
||||
"module": "profile-pipeline-command-router.cjs",
|
||||
"router": "routeExtractMessages"
|
||||
},
|
||||
{
|
||||
"family": "profile-sample",
|
||||
"module": "profile-pipeline-command-router.cjs",
|
||||
"router": "routeProfileSample"
|
||||
},
|
||||
{
|
||||
"family": "write-profile",
|
||||
"module": "profile-pipeline-command-router.cjs",
|
||||
"router": "routeWriteProfile"
|
||||
},
|
||||
{
|
||||
"family": "profile-questionnaire",
|
||||
"module": "profile-pipeline-command-router.cjs",
|
||||
"router": "routeProfileQuestionnaire"
|
||||
},
|
||||
{
|
||||
"family": "generate-dev-preferences",
|
||||
"module": "profile-pipeline-command-router.cjs",
|
||||
"router": "routeGenerateDevPreferences"
|
||||
},
|
||||
{
|
||||
"family": "generate-claude-profile",
|
||||
"module": "profile-pipeline-command-router.cjs",
|
||||
"router": "routeGenerateClaudeProfile"
|
||||
},
|
||||
{
|
||||
"family": "generate-claude-md",
|
||||
"module": "profile-pipeline-command-router.cjs",
|
||||
"router": "routeGenerateClaudeMd"
|
||||
}
|
||||
],
|
||||
"hooks": [],
|
||||
"steps": [],
|
||||
"contributions": [],
|
||||
"gates": []
|
||||
}
|
||||
32
capabilities/schema-gate/capability.json
Normal file
32
capabilities/schema-gate/capability.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"id": "schema-gate",
|
||||
"role": "feature",
|
||||
"title": "Schema push detection gate",
|
||||
"description": "Detects ORM schema-relevant files in the phase scope during planning and injects a mandatory [BLOCKING] schema push task into the plan. Prevents false-positive verification where build/types pass because TypeScript types come from config, not the live database.",
|
||||
"tier": "full",
|
||||
"requires": [],
|
||||
"runtimeCompat": { "supported": ["*"], "unsupported": [] },
|
||||
"skills": [],
|
||||
"agents": [],
|
||||
"hooks": [],
|
||||
"config": {
|
||||
"workflow.schema_push_detection": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Enable ORM schema push detection during planning. When schema-relevant files are detected in the phase scope, a [BLOCKING] push task is injected into the plan."
|
||||
}
|
||||
},
|
||||
"steps": [],
|
||||
"contributions": [
|
||||
{
|
||||
"point": "plan:pre",
|
||||
"into": "planner",
|
||||
"fragment": { "path": "fragments/plan-pre.md" },
|
||||
"produces": [],
|
||||
"consumes": ["CONTEXT.md"],
|
||||
"when": "workflow.schema_push_detection",
|
||||
"onError": "skip"
|
||||
}
|
||||
],
|
||||
"gates": []
|
||||
}
|
||||
61
capabilities/schema-gate/fragments/plan-pre.md
Normal file
61
capabilities/schema-gate/fragments/plan-pre.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# Schema Push Detection Gate
|
||||
|
||||
> Detects schema-relevant files in the phase scope and injects a mandatory `[BLOCKING]` schema push task into the plan. Prevents false-positive verification where build/types pass because TypeScript types come from config, not the live database.
|
||||
|
||||
Check if any files in the phase scope match schema patterns:
|
||||
|
||||
```bash
|
||||
PHASE_SECTION=$(gsd_run query roadmap.get-phase "${PHASE}" --pick section 2>/dev/null)
|
||||
```
|
||||
|
||||
Scan `PHASE_SECTION`, `CONTEXT.md` (if loaded), and `RESEARCH.md` (if exists) for file paths matching these ORM patterns:
|
||||
|
||||
| ORM | File Patterns |
|
||||
|-----|--------------|
|
||||
| Payload CMS | `src/collections/**/*.ts`, `src/globals/**/*.ts` |
|
||||
| Prisma | `prisma/schema.prisma`, `prisma/schema/*.prisma` |
|
||||
| Drizzle | `drizzle/schema.ts`, `src/db/schema.ts`, `drizzle/*.ts` |
|
||||
| Supabase | `supabase/migrations/*.sql` |
|
||||
| TypeORM | `src/entities/**/*.ts`, `src/migrations/**/*.ts` |
|
||||
|
||||
Also check if any existing PLAN.md files for this phase already reference these file patterns in `files_modified`.
|
||||
|
||||
**If schema-relevant files detected:**
|
||||
|
||||
Set `SCHEMA_PUSH_REQUIRED=true` and `SCHEMA_ORM={detected_orm}`.
|
||||
|
||||
Determine the push command for the detected ORM:
|
||||
|
||||
| ORM | Push Command | Non-TTY Workaround |
|
||||
|-----|-------------|-------------------|
|
||||
| Payload CMS | `npx payload migrate` | `CI=true PAYLOAD_MIGRATING=true npx payload migrate` |
|
||||
| Prisma | `npx prisma db push` | `npx prisma db push --accept-data-loss` (if destructive) |
|
||||
| Drizzle | `npx drizzle-kit push` | `npx drizzle-kit push` |
|
||||
| Supabase | `supabase db push` | Set `SUPABASE_ACCESS_TOKEN` env var |
|
||||
| TypeORM | `npx typeorm migration:run` | `npx typeorm migration:run -d src/data-source.ts` |
|
||||
|
||||
Inject the following into the planner prompt (step 8) as an additional constraint:
|
||||
|
||||
```markdown
|
||||
<schema_push_requirement>
|
||||
**[BLOCKING] Schema Push Required**
|
||||
|
||||
This phase modifies schema-relevant files ({detected_files}). The planner MUST include
|
||||
a `[BLOCKING]` task that runs the database schema push command AFTER all schema file
|
||||
modifications are complete but BEFORE verification.
|
||||
|
||||
- ORM detected: {SCHEMA_ORM}
|
||||
- Push command: {push_command}
|
||||
- Non-TTY workaround: {env_hint}
|
||||
- If push requires interactive prompts that cannot be suppressed, flag the task for
|
||||
manual intervention with `autonomous: false`
|
||||
|
||||
This task is mandatory — the phase CANNOT pass verification without it. Build and
|
||||
type checks will pass without the push (types come from config, not the live database),
|
||||
creating a false-positive verification state.
|
||||
</schema_push_requirement>
|
||||
```
|
||||
|
||||
Display: `Schema files detected ({SCHEMA_ORM}) — [BLOCKING] push task will be injected into plans`
|
||||
|
||||
**If no schema-relevant files detected:** Skip silently.
|
||||
@@ -44,6 +44,10 @@
|
||||
"fragment": {
|
||||
"inline": "Each PLAN.md must include a <threat_model> block when security enforcement is active. Use the configured ASVS level and blocking threshold from workflow.security_asvs_level and workflow.security_block_on."
|
||||
},
|
||||
"configValues": {
|
||||
"security_asvs_level": "workflow.security_asvs_level",
|
||||
"security_block_on": "workflow.security_block_on"
|
||||
},
|
||||
"produces": [],
|
||||
"consumes": ["CONTEXT.md"],
|
||||
"when": "workflow.security_enforcement"
|
||||
|
||||
44
capabilities/tdd/capability.json
Normal file
44
capabilities/tdd/capability.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"id": "tdd",
|
||||
"role": "feature",
|
||||
"title": "Test-driven development",
|
||||
"description": "Injects TDD heuristics into the planner and enforces RED/GREEN gate compliance on type:tdd plans after execution. Owns workflow.tdd_mode; the --tdd CLI flag is the ephemeral override.",
|
||||
"tier": "full",
|
||||
"requires": [],
|
||||
"runtimeCompat": { "supported": ["*"], "unsupported": [] },
|
||||
"skills": [],
|
||||
"agents": [],
|
||||
"hooks": [],
|
||||
"config": {
|
||||
"workflow.tdd_mode": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Enable TDD mode: planner annotates eligible tasks type:tdd and executor enforces RED/GREEN/REFACTOR gate sequence."
|
||||
}
|
||||
},
|
||||
"steps": [],
|
||||
"contributions": [
|
||||
{
|
||||
"point": "plan:pre",
|
||||
"into": "planner",
|
||||
"fragment": {
|
||||
"inline": "<tdd_mode_active>\n**TDD Mode is ENABLED.** Apply TDD heuristics to all eligible tasks:\n- Business logic with defined I/O → type: tdd\n- API endpoints with request/response contracts → type: tdd\n- Data transformations, validation, algorithms → type: tdd\n- UI, config, glue code, CRUD → standard plan (type: execute)\nEach TDD plan gets one feature with RED/GREEN/REFACTOR gate sequence.\n</tdd_mode_active>"
|
||||
},
|
||||
"produces": [],
|
||||
"consumes": [],
|
||||
"when": "workflow.tdd_mode",
|
||||
"onError": "skip"
|
||||
}
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"point": "execute:post",
|
||||
"check": {
|
||||
"query": "tdd.review-checkpoint"
|
||||
},
|
||||
"when": "workflow.tdd_mode",
|
||||
"blocking": false,
|
||||
"onError": "skip"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -335,6 +335,7 @@
|
||||
"planning-workspace.cjs",
|
||||
"probe-core.cjs",
|
||||
"profile-output.cjs",
|
||||
"profile-pipeline-command-router.cjs",
|
||||
"profile-pipeline.cjs",
|
||||
"project-root.cjs",
|
||||
"prompt-budget.cjs",
|
||||
|
||||
@@ -372,7 +372,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t
|
||||
|
||||
---
|
||||
|
||||
## CLI Modules (112 shipped)
|
||||
## CLI Modules (113 shipped)
|
||||
|
||||
Full listing: `gsd-core/bin/lib/*.cjs`.
|
||||
|
||||
@@ -446,6 +446,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`.
|
||||
| `planning-workspace.cjs` | Planning path/workstream seam (`planningDir`, `planningPaths`, active-workstream routing, `.planning/.lock` orchestration) |
|
||||
| `project-root.cjs` | Resolves a project root from a starting directory using four heuristics (own `.planning/` guard, `sub_repos` config, `multiRepo` flag, `.git` heuristic) |
|
||||
| `profile-output.cjs` | Profile rendering, USER-PROFILE.md and dev-preferences.md generation |
|
||||
| `profile-pipeline-command-router.cjs` | ADR-959 capability command router for the profile-pipeline command family — dispatches scan-sessions, extract-messages, profile-sample (pipeline phase) and write-profile, profile-questionnaire, generate-dev-preferences, generate-claude-profile, generate-claude-md (output phase); phase 6 cutover |
|
||||
| `profile-pipeline.cjs` | User behavioral profiling data pipeline, session file scanning |
|
||||
| `prompt-budget.cjs` | Pure token-budget accounting for review prompts — estimates tokens, applies deterministic trim priority (head-shrink PROJECT.md, proportional plan truncation, drop context/research/requirements, hard-fail guard), returns structured metadata for `review.max_prompt_tokens` (#3081) |
|
||||
| `research-provider.cjs` | Research provider waterfall, confidence tiers, and planResearch (cache-hits + fetch plan) |
|
||||
|
||||
@@ -201,8 +201,6 @@ const milestone = require('./lib/milestone.cjs');
|
||||
const commands = require('./lib/commands.cjs');
|
||||
const init = require('./lib/init.cjs');
|
||||
const frontmatter = require('./lib/frontmatter.cjs');
|
||||
const profilePipeline = require('./lib/profile-pipeline.cjs');
|
||||
const profileOutput = require('./lib/profile-output.cjs');
|
||||
const workstream = require('./lib/workstream.cjs');
|
||||
const docs = require('./lib/docs.cjs');
|
||||
const learnings = require('./lib/learnings.cjs');
|
||||
@@ -1250,8 +1248,24 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
|
||||
}
|
||||
loopConfigDir = value;
|
||||
}
|
||||
// --active-cap <capId>: parse and validate before delegating
|
||||
let loopActiveCap = undefined;
|
||||
const activeCapEqArg = args.find(arg => arg.startsWith('--active-cap='));
|
||||
const activeCapIdx = args.indexOf('--active-cap');
|
||||
if (activeCapEqArg) {
|
||||
const value = activeCapEqArg.slice('--active-cap='.length).trim();
|
||||
if (!value) error('Missing value for --active-cap (e.g. --active-cap tdd)', core.ERROR_REASON ? core.ERROR_REASON.USAGE : undefined);
|
||||
loopActiveCap = value;
|
||||
} else if (activeCapIdx !== -1) {
|
||||
const value = args[activeCapIdx + 1];
|
||||
if (!value || value.startsWith('--')) {
|
||||
error('Missing value for --active-cap (e.g. --active-cap tdd)', core.ERROR_REASON ? core.ERROR_REASON.USAGE : undefined);
|
||||
}
|
||||
loopActiveCap = value;
|
||||
}
|
||||
loopResolver.cmdLoopRenderHooks(cwd, args[2], raw, {
|
||||
configDir: loopConfigDir ? path.resolve(loopConfigDir) : undefined,
|
||||
activeCap: loopActiveCap,
|
||||
});
|
||||
} else {
|
||||
error(
|
||||
@@ -1321,94 +1335,6 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
|
||||
break;
|
||||
}
|
||||
|
||||
// ─── Profiling Pipeline ────────────────────────────────────────────────
|
||||
|
||||
case 'scan-sessions': {
|
||||
const pathIdx = args.indexOf('--path');
|
||||
const sessionsPath = pathIdx !== -1 ? args[pathIdx + 1] : null;
|
||||
const verboseFlag = args.includes('--verbose');
|
||||
const jsonFlag = args.includes('--json');
|
||||
await profilePipeline.cmdScanSessions(sessionsPath, { verbose: verboseFlag, json: jsonFlag }, raw);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'extract-messages': {
|
||||
const sessionIdx = args.indexOf('--session');
|
||||
const sessionId = sessionIdx !== -1 ? args[sessionIdx + 1] : null;
|
||||
const limitIdx = args.indexOf('--limit');
|
||||
const limit = limitIdx !== -1 ? parseInt(args[limitIdx + 1], 10) : null;
|
||||
const pathIdx = args.indexOf('--path');
|
||||
const sessionsPath = pathIdx !== -1 ? args[pathIdx + 1] : null;
|
||||
const projectArg = args[1];
|
||||
if (!projectArg || projectArg.startsWith('--')) {
|
||||
error('Usage: gsd-tools extract-messages <project> [--session <id>] [--limit N] [--path <dir>]\nRun scan-sessions first to see available projects.', ERROR_REASON.USAGE);
|
||||
}
|
||||
await profilePipeline.cmdExtractMessages(projectArg, { sessionId, limit }, raw, sessionsPath);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'profile-sample': {
|
||||
const pathIdx = args.indexOf('--path');
|
||||
const sessionsPath = pathIdx !== -1 ? args[pathIdx + 1] : null;
|
||||
const limitIdx = args.indexOf('--limit');
|
||||
const limit = limitIdx !== -1 ? parseInt(args[limitIdx + 1], 10) : 150;
|
||||
const maxPerIdx = args.indexOf('--max-per-project');
|
||||
const maxPerProject = maxPerIdx !== -1 ? parseInt(args[maxPerIdx + 1], 10) : null;
|
||||
const maxCharsIdx = args.indexOf('--max-chars');
|
||||
const maxChars = maxCharsIdx !== -1 ? parseInt(args[maxCharsIdx + 1], 10) : 500;
|
||||
await profilePipeline.cmdProfileSample(sessionsPath, { limit, maxPerProject, maxChars }, raw);
|
||||
break;
|
||||
}
|
||||
|
||||
// ─── Profile Output ──────────────────────────────────────────────────
|
||||
|
||||
case 'write-profile': {
|
||||
const inputIdx = args.indexOf('--input');
|
||||
const inputPath = inputIdx !== -1 ? args[inputIdx + 1] : null;
|
||||
if (!inputPath) error('--input <analysis-json-path> is required', ERROR_REASON.USAGE);
|
||||
const outputIdx = args.indexOf('--output');
|
||||
const outputPath = outputIdx !== -1 ? args[outputIdx + 1] : null;
|
||||
profileOutput.cmdWriteProfile(cwd, { input: inputPath, output: outputPath }, raw);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'profile-questionnaire': {
|
||||
const answersIdx = args.indexOf('--answers');
|
||||
const answers = answersIdx !== -1 ? args[answersIdx + 1] : null;
|
||||
profileOutput.cmdProfileQuestionnaire({ answers }, raw);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'generate-dev-preferences': {
|
||||
const analysisIdx = args.indexOf('--analysis');
|
||||
const analysisPath = analysisIdx !== -1 ? args[analysisIdx + 1] : null;
|
||||
const outputIdx = args.indexOf('--output');
|
||||
const outputPath = outputIdx !== -1 ? args[outputIdx + 1] : null;
|
||||
const stackIdx = args.indexOf('--stack');
|
||||
const stack = stackIdx !== -1 ? args[stackIdx + 1] : null;
|
||||
profileOutput.cmdGenerateDevPreferences(cwd, { analysis: analysisPath, output: outputPath, stack }, raw);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'generate-claude-profile': {
|
||||
const analysisIdx = args.indexOf('--analysis');
|
||||
const analysisPath = analysisIdx !== -1 ? args[analysisIdx + 1] : null;
|
||||
const outputIdx = args.indexOf('--output');
|
||||
const outputPath = outputIdx !== -1 ? args[outputIdx + 1] : null;
|
||||
const globalFlag = args.includes('--global');
|
||||
profileOutput.cmdGenerateClaudeProfile(cwd, { analysis: analysisPath, output: outputPath, global: globalFlag }, raw);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'generate-claude-md': {
|
||||
const outputIdx = args.indexOf('--output');
|
||||
const outputPath = outputIdx !== -1 ? args[outputIdx + 1] : null;
|
||||
const autoFlag = args.includes('--auto');
|
||||
const forceFlag = args.includes('--force');
|
||||
profileOutput.cmdGenerateClaudeMd(cwd, { output: outputPath, auto: autoFlag, force: forceFlag }, raw);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'workstream': {
|
||||
const subcommand = args[1];
|
||||
if (subcommand === 'create') {
|
||||
|
||||
@@ -595,6 +595,103 @@ const capabilities = {
|
||||
"extendedHookEvents": []
|
||||
}
|
||||
},
|
||||
"drift": {
|
||||
"id": "drift",
|
||||
"role": "feature",
|
||||
"title": "Drift detection gates",
|
||||
"description": "Post-execution drift detection gates that run after each wave completes. Provides two gates at execute:wave:post: a blocking schema drift gate (detects schema files changed without a database push) and a non-blocking codebase drift gate (detects structural additions not reflected in STRUCTURE.md).",
|
||||
"tier": "full",
|
||||
"requires": [],
|
||||
"runtimeCompat": {
|
||||
"supported": [
|
||||
"*"
|
||||
],
|
||||
"unsupported": []
|
||||
},
|
||||
"skills": [],
|
||||
"agents": [],
|
||||
"hooks": [],
|
||||
"config": {
|
||||
"workflow.drift_threshold": {
|
||||
"type": "number",
|
||||
"default": 3,
|
||||
"description": "Minimum number of new structural elements (directories, barrel exports, migrations, routes) before the codebase drift gate triggers a warn or auto-remap action."
|
||||
},
|
||||
"workflow.drift_action": {
|
||||
"type": "enum",
|
||||
"values": [
|
||||
"warn",
|
||||
"auto-remap"
|
||||
],
|
||||
"default": "warn",
|
||||
"description": "Action taken by the codebase drift gate when the threshold is exceeded: warn (advisory message) or auto-remap (spawn gsd-codebase-mapper agent to refresh STRUCTURE.md)."
|
||||
},
|
||||
"workflow.schema_drift_gate": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Enable the drift gates at execute:wave:post. When enabled, the schema drift gate blocks verification if schema-relevant files changed during execution but no database push command was executed; the codebase drift gate (non-blocking) warns when structural additions exceed the drift_threshold."
|
||||
}
|
||||
},
|
||||
"steps": [],
|
||||
"contributions": [],
|
||||
"gates": [
|
||||
{
|
||||
"point": "execute:wave:post",
|
||||
"check": {
|
||||
"query": "verify.schema-drift"
|
||||
},
|
||||
"when": "workflow.schema_drift_gate",
|
||||
"blocking": true,
|
||||
"onError": "skip"
|
||||
},
|
||||
{
|
||||
"point": "execute:wave:post",
|
||||
"check": {
|
||||
"query": "verify.codebase-drift"
|
||||
},
|
||||
"when": "workflow.schema_drift_gate",
|
||||
"blocking": false,
|
||||
"onError": "skip"
|
||||
}
|
||||
]
|
||||
},
|
||||
"gap-analysis": {
|
||||
"id": "gap-analysis",
|
||||
"role": "feature",
|
||||
"title": "Post-planning gap analysis",
|
||||
"description": "Proactive, non-blocking post-planning coverage report. After all PLAN.md files are generated, cross-references every REQ-ID and D-ID from REQUIREMENTS.md and CONTEXT.md against plan bodies. Emits a Source | Item | Status table. Does not block phase advancement.",
|
||||
"tier": "standard",
|
||||
"requires": [],
|
||||
"runtimeCompat": {
|
||||
"supported": [
|
||||
"*"
|
||||
],
|
||||
"unsupported": []
|
||||
},
|
||||
"skills": [],
|
||||
"agents": [],
|
||||
"hooks": [],
|
||||
"config": {
|
||||
"workflow.post_planning_gaps": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Run the post-planning gap analysis report after plans are generated."
|
||||
}
|
||||
},
|
||||
"steps": [],
|
||||
"contributions": [],
|
||||
"gates": [
|
||||
{
|
||||
"point": "plan:post",
|
||||
"check": {
|
||||
"query": "gap-analysis.plan-post"
|
||||
},
|
||||
"when": "workflow.post_planning_gaps",
|
||||
"blocking": false,
|
||||
"onError": "skip"
|
||||
}
|
||||
]
|
||||
},
|
||||
"gemini": {
|
||||
"id": "gemini",
|
||||
"role": "runtime",
|
||||
@@ -763,7 +860,20 @@ const capabilities = {
|
||||
}
|
||||
],
|
||||
"hooks": [],
|
||||
"steps": [],
|
||||
"steps": [
|
||||
{
|
||||
"point": "plan:pre",
|
||||
"ref": {
|
||||
"command": "intel api-surface"
|
||||
},
|
||||
"produces": [
|
||||
".planning/intel/API-SURFACE.md"
|
||||
],
|
||||
"consumes": [],
|
||||
"when": "intel.enabled",
|
||||
"onError": "skip"
|
||||
}
|
||||
],
|
||||
"contributions": [],
|
||||
"gates": []
|
||||
},
|
||||
@@ -1052,6 +1162,79 @@ const capabilities = {
|
||||
"contributions": [],
|
||||
"gates": []
|
||||
},
|
||||
"profile-pipeline": {
|
||||
"id": "profile-pipeline",
|
||||
"role": "feature",
|
||||
"title": "Developer profiling pipeline",
|
||||
"description": "Developer behavioral profiling from Claude Code session history; scans session JSONL files, extracts and samples user messages, and generates profile artifacts (USER-PROFILE.md, dev-preferences.md, CLAUDE.md sections). Exposes eight `gsd-tools` commands: scan-sessions, extract-messages, profile-sample (pipeline phase) and write-profile, profile-questionnaire, generate-dev-preferences, generate-claude-profile, generate-claude-md (output phase). Backs the /gsd-profile-user skill and gsd-user-profiler agent.",
|
||||
"tier": "full",
|
||||
"requires": [],
|
||||
"runtimeCompat": {
|
||||
"supported": [
|
||||
"*"
|
||||
],
|
||||
"unsupported": []
|
||||
},
|
||||
"skills": [
|
||||
"profile-user"
|
||||
],
|
||||
"agents": [
|
||||
"gsd-user-profiler"
|
||||
],
|
||||
"config": {
|
||||
"profile-pipeline.enabled": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Enable the developer profiling pipeline commands (scan-sessions, extract-messages, profile-sample, write-profile, etc.)."
|
||||
}
|
||||
},
|
||||
"commands": [
|
||||
{
|
||||
"family": "scan-sessions",
|
||||
"module": "profile-pipeline-command-router.cjs",
|
||||
"router": "routeScanSessions"
|
||||
},
|
||||
{
|
||||
"family": "extract-messages",
|
||||
"module": "profile-pipeline-command-router.cjs",
|
||||
"router": "routeExtractMessages"
|
||||
},
|
||||
{
|
||||
"family": "profile-sample",
|
||||
"module": "profile-pipeline-command-router.cjs",
|
||||
"router": "routeProfileSample"
|
||||
},
|
||||
{
|
||||
"family": "write-profile",
|
||||
"module": "profile-pipeline-command-router.cjs",
|
||||
"router": "routeWriteProfile"
|
||||
},
|
||||
{
|
||||
"family": "profile-questionnaire",
|
||||
"module": "profile-pipeline-command-router.cjs",
|
||||
"router": "routeProfileQuestionnaire"
|
||||
},
|
||||
{
|
||||
"family": "generate-dev-preferences",
|
||||
"module": "profile-pipeline-command-router.cjs",
|
||||
"router": "routeGenerateDevPreferences"
|
||||
},
|
||||
{
|
||||
"family": "generate-claude-profile",
|
||||
"module": "profile-pipeline-command-router.cjs",
|
||||
"router": "routeGenerateClaudeProfile"
|
||||
},
|
||||
{
|
||||
"family": "generate-claude-md",
|
||||
"module": "profile-pipeline-command-router.cjs",
|
||||
"router": "routeGenerateClaudeMd"
|
||||
}
|
||||
],
|
||||
"hooks": [],
|
||||
"steps": [],
|
||||
"contributions": [],
|
||||
"gates": []
|
||||
},
|
||||
"qwen": {
|
||||
"id": "qwen",
|
||||
"role": "runtime",
|
||||
@@ -1153,6 +1336,48 @@ const capabilities = {
|
||||
"contributions": [],
|
||||
"gates": []
|
||||
},
|
||||
"schema-gate": {
|
||||
"id": "schema-gate",
|
||||
"role": "feature",
|
||||
"title": "Schema push detection gate",
|
||||
"description": "Detects ORM schema-relevant files in the phase scope during planning and injects a mandatory [BLOCKING] schema push task into the plan. Prevents false-positive verification where build/types pass because TypeScript types come from config, not the live database.",
|
||||
"tier": "full",
|
||||
"requires": [],
|
||||
"runtimeCompat": {
|
||||
"supported": [
|
||||
"*"
|
||||
],
|
||||
"unsupported": []
|
||||
},
|
||||
"skills": [],
|
||||
"agents": [],
|
||||
"hooks": [],
|
||||
"config": {
|
||||
"workflow.schema_push_detection": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Enable ORM schema push detection during planning. When schema-relevant files are detected in the phase scope, a [BLOCKING] push task is injected into the plan."
|
||||
}
|
||||
},
|
||||
"steps": [],
|
||||
"contributions": [
|
||||
{
|
||||
"point": "plan:pre",
|
||||
"into": "planner",
|
||||
"fragment": {
|
||||
"path": "fragments/plan-pre.md",
|
||||
"inline": "# Schema Push Detection Gate\n\n> Detects schema-relevant files in the phase scope and injects a mandatory `[BLOCKING]` schema push task into the plan. Prevents false-positive verification where build/types pass because TypeScript types come from config, not the live database.\n\nCheck if any files in the phase scope match schema patterns:\n\n```bash\nPHASE_SECTION=$(gsd_run query roadmap.get-phase \"${PHASE}\" --pick section 2>/dev/null)\n```\n\nScan `PHASE_SECTION`, `CONTEXT.md` (if loaded), and `RESEARCH.md` (if exists) for file paths matching these ORM patterns:\n\n| ORM | File Patterns |\n|-----|--------------|\n| Payload CMS | `src/collections/**/*.ts`, `src/globals/**/*.ts` |\n| Prisma | `prisma/schema.prisma`, `prisma/schema/*.prisma` |\n| Drizzle | `drizzle/schema.ts`, `src/db/schema.ts`, `drizzle/*.ts` |\n| Supabase | `supabase/migrations/*.sql` |\n| TypeORM | `src/entities/**/*.ts`, `src/migrations/**/*.ts` |\n\nAlso check if any existing PLAN.md files for this phase already reference these file patterns in `files_modified`.\n\n**If schema-relevant files detected:**\n\nSet `SCHEMA_PUSH_REQUIRED=true` and `SCHEMA_ORM={detected_orm}`.\n\nDetermine the push command for the detected ORM:\n\n| ORM | Push Command | Non-TTY Workaround |\n|-----|-------------|-------------------|\n| Payload CMS | `npx payload migrate` | `CI=true PAYLOAD_MIGRATING=true npx payload migrate` |\n| Prisma | `npx prisma db push` | `npx prisma db push --accept-data-loss` (if destructive) |\n| Drizzle | `npx drizzle-kit push` | `npx drizzle-kit push` |\n| Supabase | `supabase db push` | Set `SUPABASE_ACCESS_TOKEN` env var |\n| TypeORM | `npx typeorm migration:run` | `npx typeorm migration:run -d src/data-source.ts` |\n\nInject the following into the planner prompt (step 8) as an additional constraint:\n\n```markdown\n<schema_push_requirement>\n**[BLOCKING] Schema Push Required**\n\nThis phase modifies schema-relevant files ({detected_files}). The planner MUST include\na `[BLOCKING]` task that runs the database schema push command AFTER all schema file\nmodifications are complete but BEFORE verification.\n\n- ORM detected: {SCHEMA_ORM}\n- Push command: {push_command}\n- Non-TTY workaround: {env_hint}\n- If push requires interactive prompts that cannot be suppressed, flag the task for\n manual intervention with `autonomous: false`\n\nThis task is mandatory — the phase CANNOT pass verification without it. Build and\ntype checks will pass without the push (types come from config, not the live database),\ncreating a false-positive verification state.\n</schema_push_requirement>\n```\n\nDisplay: `Schema files detected ({SCHEMA_ORM}) — [BLOCKING] push task will be injected into plans`\n\n**If no schema-relevant files detected:** Skip silently.\n"
|
||||
},
|
||||
"produces": [],
|
||||
"consumes": [
|
||||
"CONTEXT.md"
|
||||
],
|
||||
"when": "workflow.schema_push_detection",
|
||||
"onError": "skip"
|
||||
}
|
||||
],
|
||||
"gates": []
|
||||
},
|
||||
"security": {
|
||||
"id": "security",
|
||||
"role": "feature",
|
||||
@@ -1220,6 +1445,10 @@ const capabilities = {
|
||||
"fragment": {
|
||||
"inline": "Each PLAN.md must include a <threat_model> block when security enforcement is active. Use the configured ASVS level and blocking threshold from workflow.security_asvs_level and workflow.security_block_on."
|
||||
},
|
||||
"configValues": {
|
||||
"security_asvs_level": "workflow.security_asvs_level",
|
||||
"security_block_on": "workflow.security_block_on"
|
||||
},
|
||||
"produces": [],
|
||||
"consumes": [
|
||||
"CONTEXT.md"
|
||||
@@ -1244,6 +1473,55 @@ const capabilities = {
|
||||
}
|
||||
]
|
||||
},
|
||||
"tdd": {
|
||||
"id": "tdd",
|
||||
"role": "feature",
|
||||
"title": "Test-driven development",
|
||||
"description": "Injects TDD heuristics into the planner and enforces RED/GREEN gate compliance on type:tdd plans after execution. Owns workflow.tdd_mode; the --tdd CLI flag is the ephemeral override.",
|
||||
"tier": "full",
|
||||
"requires": [],
|
||||
"runtimeCompat": {
|
||||
"supported": [
|
||||
"*"
|
||||
],
|
||||
"unsupported": []
|
||||
},
|
||||
"skills": [],
|
||||
"agents": [],
|
||||
"hooks": [],
|
||||
"config": {
|
||||
"workflow.tdd_mode": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Enable TDD mode: planner annotates eligible tasks type:tdd and executor enforces RED/GREEN/REFACTOR gate sequence."
|
||||
}
|
||||
},
|
||||
"steps": [],
|
||||
"contributions": [
|
||||
{
|
||||
"point": "plan:pre",
|
||||
"into": "planner",
|
||||
"fragment": {
|
||||
"inline": "<tdd_mode_active>\n**TDD Mode is ENABLED.** Apply TDD heuristics to all eligible tasks:\n- Business logic with defined I/O → type: tdd\n- API endpoints with request/response contracts → type: tdd\n- Data transformations, validation, algorithms → type: tdd\n- UI, config, glue code, CRUD → standard plan (type: execute)\nEach TDD plan gets one feature with RED/GREEN/REFACTOR gate sequence.\n</tdd_mode_active>"
|
||||
},
|
||||
"produces": [],
|
||||
"consumes": [],
|
||||
"when": "workflow.tdd_mode",
|
||||
"onError": "skip"
|
||||
}
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"point": "execute:post",
|
||||
"check": {
|
||||
"query": "tdd.review-checkpoint"
|
||||
},
|
||||
"when": "workflow.tdd_mode",
|
||||
"blocking": false,
|
||||
"onError": "skip"
|
||||
}
|
||||
]
|
||||
},
|
||||
"trae": {
|
||||
"id": "trae",
|
||||
"role": "runtime",
|
||||
@@ -1439,6 +1717,7 @@ const bySkill = {
|
||||
"code-review": "code-review",
|
||||
"graphify": "graphify",
|
||||
"validate-phase": "nyquist",
|
||||
"profile-user": "profile-pipeline",
|
||||
"secure-phase": "security",
|
||||
"ui-phase": "ui",
|
||||
"ui-review": "ui"
|
||||
@@ -1453,6 +1732,7 @@ const byAgent = {
|
||||
"gsd-code-fixer": "code-review",
|
||||
"gsd-nyquist-auditor": "nyquist",
|
||||
"gsd-pattern-mapper": "pattern-mapper",
|
||||
"gsd-user-profiler": "profile-pipeline",
|
||||
"gsd-phase-researcher": "research",
|
||||
"gsd-security-auditor": "security",
|
||||
"gsd-ui-checker": "ui",
|
||||
@@ -1487,6 +1767,19 @@ const byLoopPoint = {
|
||||
"when": "workflow.ai_integration_phase",
|
||||
"onError": "skip"
|
||||
},
|
||||
{
|
||||
"capId": "intel",
|
||||
"point": "plan:pre",
|
||||
"ref": {
|
||||
"command": "intel api-surface"
|
||||
},
|
||||
"produces": [
|
||||
".planning/intel/API-SURFACE.md"
|
||||
],
|
||||
"consumes": [],
|
||||
"when": "intel.enabled",
|
||||
"onError": "skip"
|
||||
},
|
||||
{
|
||||
"capId": "research",
|
||||
"point": "plan:pre",
|
||||
@@ -1542,6 +1835,21 @@ const byLoopPoint = {
|
||||
}
|
||||
],
|
||||
"contributions": [
|
||||
{
|
||||
"capId": "schema-gate",
|
||||
"point": "plan:pre",
|
||||
"into": "planner",
|
||||
"fragment": {
|
||||
"path": "fragments/plan-pre.md",
|
||||
"inline": "# Schema Push Detection Gate\n\n> Detects schema-relevant files in the phase scope and injects a mandatory `[BLOCKING]` schema push task into the plan. Prevents false-positive verification where build/types pass because TypeScript types come from config, not the live database.\n\nCheck if any files in the phase scope match schema patterns:\n\n```bash\nPHASE_SECTION=$(gsd_run query roadmap.get-phase \"${PHASE}\" --pick section 2>/dev/null)\n```\n\nScan `PHASE_SECTION`, `CONTEXT.md` (if loaded), and `RESEARCH.md` (if exists) for file paths matching these ORM patterns:\n\n| ORM | File Patterns |\n|-----|--------------|\n| Payload CMS | `src/collections/**/*.ts`, `src/globals/**/*.ts` |\n| Prisma | `prisma/schema.prisma`, `prisma/schema/*.prisma` |\n| Drizzle | `drizzle/schema.ts`, `src/db/schema.ts`, `drizzle/*.ts` |\n| Supabase | `supabase/migrations/*.sql` |\n| TypeORM | `src/entities/**/*.ts`, `src/migrations/**/*.ts` |\n\nAlso check if any existing PLAN.md files for this phase already reference these file patterns in `files_modified`.\n\n**If schema-relevant files detected:**\n\nSet `SCHEMA_PUSH_REQUIRED=true` and `SCHEMA_ORM={detected_orm}`.\n\nDetermine the push command for the detected ORM:\n\n| ORM | Push Command | Non-TTY Workaround |\n|-----|-------------|-------------------|\n| Payload CMS | `npx payload migrate` | `CI=true PAYLOAD_MIGRATING=true npx payload migrate` |\n| Prisma | `npx prisma db push` | `npx prisma db push --accept-data-loss` (if destructive) |\n| Drizzle | `npx drizzle-kit push` | `npx drizzle-kit push` |\n| Supabase | `supabase db push` | Set `SUPABASE_ACCESS_TOKEN` env var |\n| TypeORM | `npx typeorm migration:run` | `npx typeorm migration:run -d src/data-source.ts` |\n\nInject the following into the planner prompt (step 8) as an additional constraint:\n\n```markdown\n<schema_push_requirement>\n**[BLOCKING] Schema Push Required**\n\nThis phase modifies schema-relevant files ({detected_files}). The planner MUST include\na `[BLOCKING]` task that runs the database schema push command AFTER all schema file\nmodifications are complete but BEFORE verification.\n\n- ORM detected: {SCHEMA_ORM}\n- Push command: {push_command}\n- Non-TTY workaround: {env_hint}\n- If push requires interactive prompts that cannot be suppressed, flag the task for\n manual intervention with `autonomous: false`\n\nThis task is mandatory — the phase CANNOT pass verification without it. Build and\ntype checks will pass without the push (types come from config, not the live database),\ncreating a false-positive verification state.\n</schema_push_requirement>\n```\n\nDisplay: `Schema files detected ({SCHEMA_ORM}) — [BLOCKING] push task will be injected into plans`\n\n**If no schema-relevant files detected:** Skip silently.\n"
|
||||
},
|
||||
"produces": [],
|
||||
"consumes": [
|
||||
"CONTEXT.md"
|
||||
],
|
||||
"when": "workflow.schema_push_detection",
|
||||
"onError": "skip"
|
||||
},
|
||||
{
|
||||
"capId": "security",
|
||||
"point": "plan:pre",
|
||||
@@ -1549,11 +1857,27 @@ const byLoopPoint = {
|
||||
"fragment": {
|
||||
"inline": "Each PLAN.md must include a <threat_model> block when security enforcement is active. Use the configured ASVS level and blocking threshold from workflow.security_asvs_level and workflow.security_block_on."
|
||||
},
|
||||
"configValues": {
|
||||
"security_asvs_level": "workflow.security_asvs_level",
|
||||
"security_block_on": "workflow.security_block_on"
|
||||
},
|
||||
"produces": [],
|
||||
"consumes": [
|
||||
"CONTEXT.md"
|
||||
],
|
||||
"when": "workflow.security_enforcement"
|
||||
},
|
||||
{
|
||||
"capId": "tdd",
|
||||
"point": "plan:pre",
|
||||
"into": "planner",
|
||||
"fragment": {
|
||||
"inline": "<tdd_mode_active>\n**TDD Mode is ENABLED.** Apply TDD heuristics to all eligible tasks:\n- Business logic with defined I/O → type: tdd\n- API endpoints with request/response contracts → type: tdd\n- Data transformations, validation, algorithms → type: tdd\n- UI, config, glue code, CRUD → standard plan (type: execute)\nEach TDD plan gets one feature with RED/GREEN/REFACTOR gate sequence.\n</tdd_mode_active>"
|
||||
},
|
||||
"produces": [],
|
||||
"consumes": [],
|
||||
"when": "workflow.tdd_mode",
|
||||
"onError": "skip"
|
||||
}
|
||||
],
|
||||
"gates": [
|
||||
@@ -1572,7 +1896,18 @@ const byLoopPoint = {
|
||||
"plan:post": {
|
||||
"steps": [],
|
||||
"contributions": [],
|
||||
"gates": []
|
||||
"gates": [
|
||||
{
|
||||
"capId": "gap-analysis",
|
||||
"point": "plan:post",
|
||||
"check": {
|
||||
"query": "gap-analysis.plan-post"
|
||||
},
|
||||
"when": "workflow.post_planning_gaps",
|
||||
"blocking": false,
|
||||
"onError": "skip"
|
||||
}
|
||||
]
|
||||
},
|
||||
"execute:pre": {
|
||||
"steps": [],
|
||||
@@ -1588,6 +1923,26 @@ const byLoopPoint = {
|
||||
"steps": [],
|
||||
"contributions": [],
|
||||
"gates": [
|
||||
{
|
||||
"capId": "drift",
|
||||
"point": "execute:wave:post",
|
||||
"check": {
|
||||
"query": "verify.schema-drift"
|
||||
},
|
||||
"when": "workflow.schema_drift_gate",
|
||||
"blocking": true,
|
||||
"onError": "skip"
|
||||
},
|
||||
{
|
||||
"capId": "drift",
|
||||
"point": "execute:wave:post",
|
||||
"check": {
|
||||
"query": "verify.codebase-drift"
|
||||
},
|
||||
"when": "workflow.schema_drift_gate",
|
||||
"blocking": false,
|
||||
"onError": "skip"
|
||||
},
|
||||
{
|
||||
"capId": "ui",
|
||||
"point": "execute:wave:post",
|
||||
@@ -1619,7 +1974,18 @@ const byLoopPoint = {
|
||||
}
|
||||
],
|
||||
"contributions": [],
|
||||
"gates": []
|
||||
"gates": [
|
||||
{
|
||||
"capId": "tdd",
|
||||
"point": "execute:post",
|
||||
"check": {
|
||||
"query": "tdd.review-checkpoint"
|
||||
},
|
||||
"when": "workflow.tdd_mode",
|
||||
"blocking": false,
|
||||
"onError": "skip"
|
||||
}
|
||||
]
|
||||
},
|
||||
"verify:pre": {
|
||||
"steps": [],
|
||||
@@ -1709,14 +2075,21 @@ const configKeys = {
|
||||
"workflow.ai_integration_phase": "ai-integration",
|
||||
"workflow.code_review": "code-review",
|
||||
"workflow.code_review_depth": "code-review",
|
||||
"workflow.drift_threshold": "drift",
|
||||
"workflow.drift_action": "drift",
|
||||
"workflow.schema_drift_gate": "drift",
|
||||
"workflow.post_planning_gaps": "gap-analysis",
|
||||
"graphify.enabled": "graphify",
|
||||
"intel.enabled": "intel",
|
||||
"workflow.nyquist_validation": "nyquist",
|
||||
"workflow.pattern_mapper": "pattern-mapper",
|
||||
"profile-pipeline.enabled": "profile-pipeline",
|
||||
"workflow.research": "research",
|
||||
"workflow.schema_push_detection": "schema-gate",
|
||||
"workflow.security_enforcement": "security",
|
||||
"workflow.security_asvs_level": "security",
|
||||
"workflow.security_block_on": "security",
|
||||
"workflow.tdd_mode": "tdd",
|
||||
"workflow.ui_phase": "ui",
|
||||
"workflow.ui_review": "ui",
|
||||
"workflow.ui_safety_gate": "ui"
|
||||
@@ -1746,6 +2119,34 @@ const configSchema = {
|
||||
"deep"
|
||||
]
|
||||
},
|
||||
"workflow.drift_threshold": {
|
||||
"owner": "drift",
|
||||
"type": "number",
|
||||
"default": 3,
|
||||
"description": "Minimum number of new structural elements (directories, barrel exports, migrations, routes) before the codebase drift gate triggers a warn or auto-remap action."
|
||||
},
|
||||
"workflow.drift_action": {
|
||||
"owner": "drift",
|
||||
"type": "enum",
|
||||
"default": "warn",
|
||||
"description": "Action taken by the codebase drift gate when the threshold is exceeded: warn (advisory message) or auto-remap (spawn gsd-codebase-mapper agent to refresh STRUCTURE.md).",
|
||||
"values": [
|
||||
"warn",
|
||||
"auto-remap"
|
||||
]
|
||||
},
|
||||
"workflow.schema_drift_gate": {
|
||||
"owner": "drift",
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Enable the drift gates at execute:wave:post. When enabled, the schema drift gate blocks verification if schema-relevant files changed during execution but no database push command was executed; the codebase drift gate (non-blocking) warns when structural additions exceed the drift_threshold."
|
||||
},
|
||||
"workflow.post_planning_gaps": {
|
||||
"owner": "gap-analysis",
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Run the post-planning gap analysis report after plans are generated."
|
||||
},
|
||||
"graphify.enabled": {
|
||||
"owner": "graphify",
|
||||
"type": "boolean",
|
||||
@@ -1770,12 +2171,24 @@ const configSchema = {
|
||||
"default": true,
|
||||
"description": "Run the pattern mapper before planning when context or research is available."
|
||||
},
|
||||
"profile-pipeline.enabled": {
|
||||
"owner": "profile-pipeline",
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Enable the developer profiling pipeline commands (scan-sessions, extract-messages, profile-sample, write-profile, etc.)."
|
||||
},
|
||||
"workflow.research": {
|
||||
"owner": "research",
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Run phase research before planning when research artifacts are missing or explicitly refreshed."
|
||||
},
|
||||
"workflow.schema_push_detection": {
|
||||
"owner": "schema-gate",
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Enable ORM schema push detection during planning. When schema-relevant files are detected in the phase scope, a [BLOCKING] push task is injected into the plan."
|
||||
},
|
||||
"workflow.security_enforcement": {
|
||||
"owner": "security",
|
||||
"type": "boolean",
|
||||
@@ -1801,6 +2214,12 @@ const configSchema = {
|
||||
"none"
|
||||
]
|
||||
},
|
||||
"workflow.tdd_mode": {
|
||||
"owner": "tdd",
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Enable TDD mode: planner annotates eligible tasks type:tdd and executor enforces RED/GREEN/REFACTOR gate sequence."
|
||||
},
|
||||
"workflow.ui_phase": {
|
||||
"owner": "ui",
|
||||
"type": "boolean",
|
||||
@@ -2725,6 +3144,26 @@ const commandFamilies = {
|
||||
"module": "audit-command-router.cjs",
|
||||
"router": "routeAuditUat"
|
||||
},
|
||||
"extract-messages": {
|
||||
"capId": "profile-pipeline",
|
||||
"module": "profile-pipeline-command-router.cjs",
|
||||
"router": "routeExtractMessages"
|
||||
},
|
||||
"generate-claude-md": {
|
||||
"capId": "profile-pipeline",
|
||||
"module": "profile-pipeline-command-router.cjs",
|
||||
"router": "routeGenerateClaudeMd"
|
||||
},
|
||||
"generate-claude-profile": {
|
||||
"capId": "profile-pipeline",
|
||||
"module": "profile-pipeline-command-router.cjs",
|
||||
"router": "routeGenerateClaudeProfile"
|
||||
},
|
||||
"generate-dev-preferences": {
|
||||
"capId": "profile-pipeline",
|
||||
"module": "profile-pipeline-command-router.cjs",
|
||||
"router": "routeGenerateDevPreferences"
|
||||
},
|
||||
"graphify": {
|
||||
"capId": "graphify",
|
||||
"module": "graphify-command-router.cjs",
|
||||
@@ -2734,6 +3173,26 @@ const commandFamilies = {
|
||||
"capId": "intel",
|
||||
"module": "intel-command-router.cjs",
|
||||
"router": "routeIntelCommand"
|
||||
},
|
||||
"profile-questionnaire": {
|
||||
"capId": "profile-pipeline",
|
||||
"module": "profile-pipeline-command-router.cjs",
|
||||
"router": "routeProfileQuestionnaire"
|
||||
},
|
||||
"profile-sample": {
|
||||
"capId": "profile-pipeline",
|
||||
"module": "profile-pipeline-command-router.cjs",
|
||||
"router": "routeProfileSample"
|
||||
},
|
||||
"scan-sessions": {
|
||||
"capId": "profile-pipeline",
|
||||
"module": "profile-pipeline-command-router.cjs",
|
||||
"router": "routeScanSessions"
|
||||
},
|
||||
"write-profile": {
|
||||
"capId": "profile-pipeline",
|
||||
"module": "profile-pipeline-command-router.cjs",
|
||||
"router": "routeWriteProfile"
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2750,6 +3209,9 @@ const capabilityClusters = {
|
||||
"nyquist": [
|
||||
"validate-phase"
|
||||
],
|
||||
"profile-pipeline": [
|
||||
"profile-user"
|
||||
],
|
||||
"security": [
|
||||
"secure-phase"
|
||||
],
|
||||
@@ -2784,6 +3246,12 @@ const profileMembership = {
|
||||
"full"
|
||||
]
|
||||
},
|
||||
"profile-pipeline": {
|
||||
"tier": "full",
|
||||
"profiles": [
|
||||
"full"
|
||||
]
|
||||
},
|
||||
"security": {
|
||||
"tier": "full",
|
||||
"profiles": [
|
||||
@@ -2810,6 +3278,8 @@ const _requiresGraph = {
|
||||
"codex": [],
|
||||
"copilot": [],
|
||||
"cursor": [],
|
||||
"drift": [],
|
||||
"gap-analysis": [],
|
||||
"gemini": [],
|
||||
"graphify": [],
|
||||
"hermes": [],
|
||||
@@ -2821,9 +3291,12 @@ const _requiresGraph = {
|
||||
"pattern-mapper": [
|
||||
"research"
|
||||
],
|
||||
"profile-pipeline": [],
|
||||
"qwen": [],
|
||||
"research": [],
|
||||
"schema-gate": [],
|
||||
"security": [],
|
||||
"tdd": [],
|
||||
"trae": [],
|
||||
"ui": [],
|
||||
"windsurf": []
|
||||
|
||||
138
gsd-core/bin/lib/profile-pipeline-command-router.cjs
Normal file
138
gsd-core/bin/lib/profile-pipeline-command-router.cjs
Normal file
@@ -0,0 +1,138 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Profile-pipeline command router — CLI dispatcher for gsd-tools profiling commands.
|
||||
*
|
||||
* ADR-857 phase 6 / ADR-959: profile-pipeline capability command cutover.
|
||||
* Extracted from hardcoded case arms in gsd-tools.cjs (lines 1324-1410).
|
||||
* Dispatch path: default → dispatchCapabilityCommand →
|
||||
* require(profile-pipeline-command-router.cjs) → route<X>.
|
||||
*
|
||||
* Router signature: { args, cwd, raw, error } — identical to existing routers.
|
||||
* Test seams: _pipeline / _output inject mock modules; _core injects mock core.
|
||||
*
|
||||
* Async note: cmdExtractMessages and cmdProfileSample are async functions.
|
||||
* dispatchCapabilityCommand (gsd-tools.cjs:366-371) explicitly errors if a
|
||||
* router returns a Promise. Therefore these router functions call the async
|
||||
* function WITHOUT await and WITHOUT returning the Promise. The async functions
|
||||
* end with output() or process.exit() so the process terminates correctly once
|
||||
* the event loop drains. Unhandled rejections are caught by the .catch() wrapper
|
||||
* to surface errors via the error() callback.
|
||||
*/
|
||||
const { ERROR_REASON } = require('./io.cjs');
|
||||
|
||||
// ─── Pipeline phase commands ───────────────────────────────────────────────────
|
||||
|
||||
function routeScanSessions({ args, cwd, raw, error, _pipeline }) {
|
||||
void cwd; void error;
|
||||
const p = _pipeline ?? require('./profile-pipeline.cjs');
|
||||
const pathIdx = args.indexOf('--path');
|
||||
const sessionsPath = pathIdx !== -1 ? args[pathIdx + 1] : null;
|
||||
const verboseFlag = args.includes('--verbose');
|
||||
const jsonFlag = args.includes('--json');
|
||||
// cmdScanSessions is synchronous — call directly.
|
||||
p.cmdScanSessions(sessionsPath, { verbose: verboseFlag, json: jsonFlag }, raw);
|
||||
}
|
||||
|
||||
function routeExtractMessages({ args, cwd, raw, error, _pipeline }) {
|
||||
const p = _pipeline ?? require('./profile-pipeline.cjs');
|
||||
const sessionIdx = args.indexOf('--session');
|
||||
const sessionId = sessionIdx !== -1 ? args[sessionIdx + 1] : null;
|
||||
const limitIdx = args.indexOf('--limit');
|
||||
const limit = limitIdx !== -1 ? parseInt(args[limitIdx + 1], 10) : null;
|
||||
const pathIdx = args.indexOf('--path');
|
||||
const sessionsPath = pathIdx !== -1 ? args[pathIdx + 1] : null;
|
||||
// args[0] = 'extract-messages' (family name), args[1] = project positional
|
||||
const projectArg = args[1];
|
||||
if (!projectArg || projectArg.startsWith('--')) {
|
||||
error('Usage: gsd-tools extract-messages <project> [--session <id>] [--limit N] [--path <dir>]\nRun scan-sessions first to see available projects.', ERROR_REASON.USAGE);
|
||||
return;
|
||||
}
|
||||
// cmdExtractMessages is async — do NOT return the Promise.
|
||||
// The function ends with output() or process.exit(); the event loop will drain.
|
||||
void cwd;
|
||||
p.cmdExtractMessages(projectArg, { sessionId, limit }, raw, sessionsPath)
|
||||
.catch(e => { error(e && e.message ? e.message : String(e)); });
|
||||
}
|
||||
|
||||
function routeProfileSample({ args, cwd, raw, error, _pipeline }) {
|
||||
void cwd; void error;
|
||||
const p = _pipeline ?? require('./profile-pipeline.cjs');
|
||||
const pathIdx = args.indexOf('--path');
|
||||
const sessionsPath = pathIdx !== -1 ? args[pathIdx + 1] : null;
|
||||
const limitIdx = args.indexOf('--limit');
|
||||
const limit = limitIdx !== -1 ? parseInt(args[limitIdx + 1], 10) : 150;
|
||||
const maxPerIdx = args.indexOf('--max-per-project');
|
||||
const maxPerProject = maxPerIdx !== -1 ? parseInt(args[maxPerIdx + 1], 10) : null;
|
||||
const maxCharsIdx = args.indexOf('--max-chars');
|
||||
const maxChars = maxCharsIdx !== -1 ? parseInt(args[maxCharsIdx + 1], 10) : 500;
|
||||
// cmdProfileSample is async — do NOT return the Promise.
|
||||
p.cmdProfileSample(sessionsPath, { limit, maxPerProject, maxChars }, raw)
|
||||
.catch(e => { error(e && e.message ? e.message : String(e)); });
|
||||
}
|
||||
|
||||
// ─── Output phase commands ─────────────────────────────────────────────────────
|
||||
|
||||
function routeWriteProfile({ args, cwd, raw, error, _output }) {
|
||||
const o = _output ?? require('./profile-output.cjs');
|
||||
const inputIdx = args.indexOf('--input');
|
||||
const inputPath = inputIdx !== -1 ? args[inputIdx + 1] : null;
|
||||
if (!inputPath) {
|
||||
error('--input <analysis-json-path> is required', ERROR_REASON.USAGE);
|
||||
return;
|
||||
}
|
||||
const outputIdx = args.indexOf('--output');
|
||||
const outputPath = outputIdx !== -1 ? args[outputIdx + 1] : null;
|
||||
o.cmdWriteProfile(cwd, { input: inputPath, output: outputPath }, raw);
|
||||
}
|
||||
|
||||
function routeProfileQuestionnaire({ args, cwd, raw, error, _output }) {
|
||||
void cwd; void error;
|
||||
const o = _output ?? require('./profile-output.cjs');
|
||||
const answersIdx = args.indexOf('--answers');
|
||||
const answers = answersIdx !== -1 ? args[answersIdx + 1] : null;
|
||||
o.cmdProfileQuestionnaire({ answers }, raw);
|
||||
}
|
||||
|
||||
function routeGenerateDevPreferences({ args, cwd, raw, error, _output }) {
|
||||
void error;
|
||||
const o = _output ?? require('./profile-output.cjs');
|
||||
const analysisIdx = args.indexOf('--analysis');
|
||||
const analysisPath = analysisIdx !== -1 ? args[analysisIdx + 1] : null;
|
||||
const outputIdx = args.indexOf('--output');
|
||||
const outputPath = outputIdx !== -1 ? args[outputIdx + 1] : null;
|
||||
const stackIdx = args.indexOf('--stack');
|
||||
const stack = stackIdx !== -1 ? args[stackIdx + 1] : null;
|
||||
o.cmdGenerateDevPreferences(cwd, { analysis: analysisPath, output: outputPath, stack }, raw);
|
||||
}
|
||||
|
||||
function routeGenerateClaudeProfile({ args, cwd, raw, error, _output }) {
|
||||
void error;
|
||||
const o = _output ?? require('./profile-output.cjs');
|
||||
const analysisIdx = args.indexOf('--analysis');
|
||||
const analysisPath = analysisIdx !== -1 ? args[analysisIdx + 1] : null;
|
||||
const outputIdx = args.indexOf('--output');
|
||||
const outputPath = outputIdx !== -1 ? args[outputIdx + 1] : null;
|
||||
const globalFlag = args.includes('--global');
|
||||
o.cmdGenerateClaudeProfile(cwd, { analysis: analysisPath, output: outputPath, global: globalFlag }, raw);
|
||||
}
|
||||
|
||||
function routeGenerateClaudeMd({ args, cwd, raw, error, _output }) {
|
||||
void error;
|
||||
const o = _output ?? require('./profile-output.cjs');
|
||||
const outputIdx = args.indexOf('--output');
|
||||
const outputPath = outputIdx !== -1 ? args[outputIdx + 1] : null;
|
||||
const autoFlag = args.includes('--auto');
|
||||
const forceFlag = args.includes('--force');
|
||||
o.cmdGenerateClaudeMd(cwd, { output: outputPath, auto: autoFlag, force: forceFlag }, raw);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
routeScanSessions,
|
||||
routeExtractMessages,
|
||||
routeProfileSample,
|
||||
routeWriteProfile,
|
||||
routeProfileQuestionnaire,
|
||||
routeGenerateDevPreferences,
|
||||
routeGenerateClaudeProfile,
|
||||
routeGenerateClaudeMd,
|
||||
};
|
||||
@@ -28,7 +28,6 @@
|
||||
"verifier": true,
|
||||
"nyquist_validation": true,
|
||||
"ai_integration_phase": true,
|
||||
"tdd_mode": false,
|
||||
"human_verify_mode": "end-of-phase",
|
||||
"auto_advance": false,
|
||||
"_auto_chain_active": false,
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
"workflow.auto_advance",
|
||||
"workflow.node_repair",
|
||||
"workflow.node_repair_budget",
|
||||
"workflow.tdd_mode",
|
||||
"workflow.human_verify_mode",
|
||||
"workflow.text_mode",
|
||||
"workflow.research_before_questions",
|
||||
@@ -30,9 +29,6 @@
|
||||
"workflow.plan_bounce_passes",
|
||||
"workflow.plan_chunked",
|
||||
"workflow.plan_review_convergence",
|
||||
"workflow.post_planning_gaps",
|
||||
"workflow.drift_threshold",
|
||||
"workflow.drift_action",
|
||||
"code_quality.fallow.enabled",
|
||||
"code_quality.fallow.scope",
|
||||
"code_quality.fallow.profile",
|
||||
|
||||
@@ -177,7 +177,8 @@ Resolve `MVP_MODE` once via the centralized `phase.mvp-mode` query verb (precede
|
||||
MVP_FLAG_ARG=""
|
||||
if [[ "$ARGUMENTS" =~ (^|[[:space:]])--mvp([[:space:]]|$) ]]; then MVP_FLAG_ARG="--cli-flag"; fi
|
||||
MVP_MODE=$(gsd_run query phase.mvp-mode "${PHASE_NUMBER}" $MVP_FLAG_ARG --pick active)
|
||||
TDD_MODE=$(gsd_run query config-get workflow.tdd_mode 2>/dev/null || echo "false")
|
||||
EXECUTE_POST_HOOKS_JSON=$(gsd_run loop render-hooks execute:post --raw)
|
||||
TDD_MODE=$(gsd_run loop render-hooks execute:post --active-cap tdd)
|
||||
```
|
||||
|
||||
<step name="safe_resume_gate">
|
||||
@@ -898,6 +899,51 @@ increases monotonically across waves. `{status}` is `complete` (success),
|
||||
|
||||
**If no plan in this wave used worktrees** (project-level `USE_WORKTREES=false` OR `WAVE_WORKTREE_PLANS` is empty): sequential agents already updated STATE.md and ROADMAP.md themselves — skip this step.
|
||||
|
||||
5.75. **Execute:wave:post capability dispatch:**
|
||||
|
||||
After worktree merge, post-merge tests, and tracking updates, dispatch capability hooks registered at `execute:wave:post`. The primary hook is the `ui.safety-gate` gate from the UI capability — it verifies that any frontend files changed in this wave conform to the UI-SPEC contract.
|
||||
|
||||
```bash
|
||||
WAVE_POST_HOOKS_JSON=$(gsd_run loop render-hooks execute:wave:post --raw)
|
||||
```
|
||||
|
||||
Read the `activeHooks` array from `WAVE_POST_HOOKS_JSON` in-context (do NOT pipe through a shell parser).
|
||||
|
||||
**If `activeHooks` is empty or absent:** Skip silently to step 5.8.
|
||||
|
||||
**For each active entry where `kind == "gate"`** (process in array order), run the gate check:
|
||||
|
||||
```bash
|
||||
GATE_RESULT=$(gsd_run check ${hook.check.query} "${PHASE_NUMBER}" --raw)
|
||||
CHECK_EXIT=$?
|
||||
```
|
||||
|
||||
**Step 1 — did the CHECK COMMAND itself succeed?**
|
||||
|
||||
If the check command failed (non-zero `CHECK_EXIT`, empty output, or unparseable JSON):
|
||||
- `onError == "halt"` → treat as a fatal error: stop wave completion, do NOT proceed to step 5.8, and surface: `⚠ Gate check command failed ({hook.capId}): command error. Resolve before continuing.`
|
||||
- `onError == "skip"` → log a warning and continue to the next hook. Do NOT read `GATE_RESULT.block`.
|
||||
|
||||
**Step 2 — read `GATE_RESULT.block` (boolean).** This step is only reached when the command succeeded.
|
||||
|
||||
- **Blocking gate (`hook.blocking == true`) AND `GATE_RESULT.block == true`:** HALT — stop wave completion, do NOT proceed to step 5.8, and present:
|
||||
|
||||
```
|
||||
⚠ Wave {N} blocked by capability gate ({hook.capId}): {GATE_RESULT.message}
|
||||
Resolve before continuing to next wave.
|
||||
```
|
||||
|
||||
This halt is **not** bypassed by `onError` — `onError` only covers command errors (step 1 above), not the gate's block decision.
|
||||
|
||||
- **Non-blocking gate (`hook.blocking == false`):** never halts. If `GATE_RESULT.block` is `true` (or non-empty `message`), print `⚠ {hook.capId} advisory (wave {N}): {GATE_RESULT.message}`, then:
|
||||
- If `GATE_RESULT.spawn_mapper == true` OR `GATE_RESULT.directive == "auto-remap"`: spawn `gsd-codebase-mapper` per `execute-phase/steps/codebase-drift-gate.md`; pass `--paths {GATE_RESULT.affected_paths}`. Continue regardless (wave NOT failed by remap failure).
|
||||
- Otherwise: continue after advisory.
|
||||
- If block `false` and no `message`: continue silently.
|
||||
|
||||
- **Blocking gate (`hook.blocking == true`) AND `GATE_RESULT.block == false`:** continue silently.
|
||||
|
||||
**When all active gates are processed without a blocking halt:** continue to step 5.8.
|
||||
|
||||
5.8. **Handle test gate failures (when `WAVE_FAILURE_COUNT > 0`):**
|
||||
|
||||
```
|
||||
@@ -1083,59 +1129,6 @@ If an active secure-phase step hook exists AND SECURITY.md exists: check frontma
|
||||
```
|
||||
</step>
|
||||
|
||||
<step name="tdd_review_checkpoint">
|
||||
**Optional step — TDD collaborative review.**
|
||||
|
||||
```bash
|
||||
TDD_MODE=$(gsd_run query config-get workflow.tdd_mode 2>/dev/null || echo "false")
|
||||
```
|
||||
|
||||
**Skip if `TDD_MODE` is `false`.**
|
||||
|
||||
When `TDD_MODE` is `true`, check whether any completed plans in this phase have `type: tdd` in their frontmatter:
|
||||
|
||||
```bash
|
||||
TDD_PLANS=$(grep -rl "^type: tdd" "${PHASE_DIR}"/*-PLAN.md 2>/dev/null | wc -l | tr -d ' ')
|
||||
```
|
||||
|
||||
**If `TDD_PLANS` > 0:** Insert end-of-phase collaborative review checkpoint.
|
||||
|
||||
1. Collect all SUMMARY.md files for TDD plans
|
||||
2. For each TDD plan summary, verify the RED/GREEN/REFACTOR gate sequence:
|
||||
- RED gate: A failing test commit exists (`test(...)` commit with MUST-fail evidence)
|
||||
- GREEN gate: An implementation commit exists (`feat(...)` commit making tests pass)
|
||||
- REFACTOR gate: Optional cleanup commit (`refactor(...)` commit, tests still pass)
|
||||
3. If any TDD plan is missing the RED or GREEN gate commits, flag it:
|
||||
```
|
||||
⚠ TDD gate violation: Plan {plan_id} missing {RED|GREEN} phase commit.
|
||||
Expected commit pattern: test({phase}-{plan}): ... → feat({phase}-{plan}): ...
|
||||
```
|
||||
4. Present collaborative review summary:
|
||||
```
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
TDD REVIEW — Phase {X}
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
TDD Plans: {TDD_PLANS} | Gate violations: {count}
|
||||
|
||||
| Plan | RED | GREEN | REFACTOR | Status |
|
||||
|------|-----|-------|----------|--------|
|
||||
| {id} | ✓ | ✓ | ✓ | Pass |
|
||||
| {id} | ✓ | ✗ | — | FAIL |
|
||||
```
|
||||
|
||||
**Escalation under MVP+TDD.** When `MVP_MODE=true` AND `TDD_MODE=true`, the review verdict escalates from advisory to **blocking**: missing RED or GREEN gate commits prevent marking the phase complete.
|
||||
```text
|
||||
Phase blocked: {N} TDD plan(s) violate the RED→GREEN gate sequence under MVP+TDD.
|
||||
Resolve and re-run /gsd execute-phase, or override with
|
||||
/gsd execute-phase {phase} --force-mvp-gate to ship anyway.
|
||||
```
|
||||
`--force-mvp-gate` is the escape hatch (documented, not yet implemented). Policy is:
|
||||
- `MVP_MODE=true` AND `TDD_MODE=true`: violations are **blocking** unless explicitly overridden.
|
||||
- otherwise: violations are advisory/non-blocking and are surfaced for review.
|
||||
The verifier agent (step `verify_phase_goal`) still checks TDD discipline in both cases.
|
||||
</step>
|
||||
|
||||
<step name="handle_partial_wave_execution">
|
||||
If `WAVE_FILTER` was used, re-run plan discovery after execution:
|
||||
|
||||
@@ -1168,16 +1161,16 @@ Selected wave finished successfully. This phase still has incomplete plans, so p
|
||||
</step>
|
||||
|
||||
<step name="code_review_gate" required="true">
|
||||
**This step is REQUIRED to evaluate the capability hook.** When the code-review capability is active, auto-invoke code review on the phase's source changes. Advisory only — never blocks execution flow.
|
||||
**This step is REQUIRED to evaluate the capability hook.** When the code-review capability is active, auto-invoke code review on the phase's source changes. Advisory only — never blocks execution flow. Also dispatches advisory execute:post gate hooks (e.g. tdd.review-checkpoint).
|
||||
|
||||
**Capability gate:**
|
||||
```bash
|
||||
EXECUTE_POST_HOOKS_JSON=$(gsd_run loop render-hooks execute:post --raw)
|
||||
EXECUTE_POST_HOOKS_JSON=${EXECUTE_POST_HOOKS_JSON:-$(gsd_run loop render-hooks execute:post --raw)}
|
||||
```
|
||||
|
||||
Resolve active step hooks from `EXECUTE_POST_HOOKS_JSON` where `kind == "step"` and `ref.skill == "code-review"`.
|
||||
|
||||
If no active code-review step hook exists: display "Code review skipped (code-review capability inactive)" and proceed to next step.
|
||||
If no active code-review step hook exists: display "Code review skipped (code-review capability inactive)" and proceed to gate dispatch.
|
||||
|
||||
**Invoke review:**
|
||||
```
|
||||
@@ -1197,9 +1190,28 @@ Code review found issues. Consider running:
|
||||
/gsd:code-review ${PHASE_NUMBER} --fix
|
||||
```
|
||||
|
||||
**Error handling:** If the Skill invocation fails or throws, catch the error, display "Code review encountered an error (non-blocking): {error}" and proceed to next step. Review failures must never block execution.
|
||||
**Error handling:** If the Skill invocation fails or throws, catch the error, display "Code review encountered an error (non-blocking): {error}" and proceed to gate dispatch. Review failures must never block execution.
|
||||
|
||||
Regardless of review result, ALWAYS proceed to close_parent_artifacts → regression_gate → verify_phase_goal.
|
||||
**Execute:post gate hook dispatch.** After code review, dispatch all active gate hooks from `EXECUTE_POST_HOOKS_JSON` where `kind == "gate"`:
|
||||
|
||||
For each active gate hook:
|
||||
```bash
|
||||
GATE_RESULT=$(gsd_run check ${hook.check.query} "${PHASE_NUMBER}" --raw)
|
||||
CHECK_EXIT=$?
|
||||
```
|
||||
|
||||
**Gate evaluation** uses the same two-step contract as `execute:wave:post` above: **Step 1** — if the check command failed (non-zero `CHECK_EXIT`, empty/unparseable output), `onError == "halt"` stops and surfaces the error, `onError == "skip"` warns and continues to the next hook (do not read `block`). **Step 2** (command succeeded) — a blocking gate (`hook.blocking == true`) halts on `GATE_RESULT.block == true` with its message/table (never bypassed by `onError`); an advisory gate (`hook.blocking == false`) shows its `table`/summary when `block == true` or `message` is non-empty, then continues; a blocking gate with `block == false` continues silently.
|
||||
|
||||
**TDD review escalation (overrides the advisory default for the `tdd.review-checkpoint` gate only).** The tdd `execute:post` gate is declared `blocking: false`, so by the generic contract above it displays its `message`/table and continues. There is ONE documented exception (see `~/.claude/gsd-core/references/execute-mvp-tdd.md`): when `MVP_MODE=true` AND `TDD_MODE=true` AND `GATE_RESULT.block == true` (one or more TDD plans miss a RED or GREEN gate commit), the end-of-phase TDD review escalates from advisory to **blocking under MVP+TDD** — refuse to mark the phase complete and present:
|
||||
|
||||
```
|
||||
Phase blocked: {N} TDD plan(s) violate the RED→GREEN gate sequence under MVP+TDD.
|
||||
Resolve and re-run /gsd execute-phase, or override with /gsd execute-phase {phase} --force-mvp-gate to ship anyway.
|
||||
```
|
||||
|
||||
(`--force-mvp-gate` is the documented, not-yet-implemented escape hatch.) Outside MVP+TDD, TDD-review violations remain advisory (table shown, execution continues).
|
||||
|
||||
**Proceed rule:** If `MVP_MODE && TDD_MODE && GATE_RESULT.block == true` for `tdd.review-checkpoint`: STOP — do NOT proceed to `close_parent_artifacts`, `regression_gate`, `verify_phase_goal`, or `phase.complete`. Otherwise proceed normally.
|
||||
</step>
|
||||
|
||||
<step name="close_parent_artifacts">
|
||||
@@ -1322,84 +1334,7 @@ Options:
|
||||
3. Abort phase — roll back and re-plan
|
||||
```
|
||||
|
||||
Use AskUserQuestion to present the options.
|
||||
</step>
|
||||
|
||||
<step name="schema_drift_gate">
|
||||
Post-execution schema drift detection. Catches false-positive verification where
|
||||
build/types pass because TypeScript types come from config, not the live database.
|
||||
|
||||
**Run after execution completes but BEFORE verification marks success.**
|
||||
|
||||
```bash
|
||||
SCHEMA_DRIFT=$(gsd_run query verify.schema-drift "${PHASE_NUMBER}" 2>/dev/null)
|
||||
```
|
||||
|
||||
Parse JSON result for: `drift_detected`, `blocking`, `schema_files`, `orms`, `unpushed_orms`, `message`.
|
||||
|
||||
**If `drift_detected` is false:** Skip to verify_phase_goal.
|
||||
|
||||
**If `drift_detected` is true AND `blocking` is true:**
|
||||
|
||||
Check for override:
|
||||
```bash
|
||||
SKIP_SCHEMA=$(echo "${GSD_SKIP_SCHEMA_CHECK:-false}")
|
||||
```
|
||||
|
||||
**If `SKIP_SCHEMA` is `true`:**
|
||||
|
||||
Display:
|
||||
```
|
||||
⚠ Schema drift detected but GSD_SKIP_SCHEMA_CHECK=true — bypassing gate.
|
||||
|
||||
Schema files changed: {schema_files}
|
||||
ORMs requiring push: {unpushed_orms}
|
||||
|
||||
Proceeding to verification (database may be out of sync).
|
||||
```
|
||||
→ Continue to verify_phase_goal.
|
||||
|
||||
**If `SKIP_SCHEMA` is not `true`:**
|
||||
|
||||
BLOCK verification. Display:
|
||||
|
||||
```
|
||||
## BLOCKED: Schema Drift Detected
|
||||
|
||||
Schema-relevant files changed during this phase but no database push command
|
||||
was executed. Build and type checks pass because TypeScript types come from
|
||||
config, not the live database — verification would produce a false positive.
|
||||
|
||||
Schema files changed: {schema_files}
|
||||
ORMs requiring push: {unpushed_orms}
|
||||
|
||||
Required push commands:
|
||||
{For each unpushed ORM, show the push command from the message}
|
||||
|
||||
Options:
|
||||
1. Run push command now (recommended) — execute the push, then re-verify
|
||||
2. Skip schema check (GSD_SKIP_SCHEMA_CHECK=true) — bypass this gate
|
||||
3. Abort — stop execution and investigate
|
||||
```
|
||||
|
||||
If `TEXT_MODE` is true, present as a plain-text numbered list. Otherwise use AskUserQuestion.
|
||||
|
||||
**If user selects option 1:** Present the specific push command(s) to run. After user confirms execution, re-run the schema drift check. If it passes, continue to verify_phase_goal.
|
||||
|
||||
**If user selects option 2:** Set override and continue to verify_phase_goal.
|
||||
|
||||
**If user selects option 3:** Stop execution. Report partial completion.
|
||||
</step>
|
||||
|
||||
<step name="codebase_drift_gate">
|
||||
Post-execution structural drift detection (#2003). Non-blocking by contract:
|
||||
any internal error here MUST fall through to `verify_phase_goal`. The phase
|
||||
is never failed by this gate.
|
||||
|
||||
Load and follow the full step spec from
|
||||
`gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md` —
|
||||
covers the SDK call, JSON contract, `warn` vs `auto-remap` branches, mapper
|
||||
spawn template, and the two `workflow.drift_*` config keys.
|
||||
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>
|
||||
|
||||
<step name="verify_phase_goal">
|
||||
|
||||
@@ -74,11 +74,10 @@ AGENT_SKILLS_RESEARCHER=$(gsd_run query agent-skills gsd-phase-researcher)
|
||||
AGENT_SKILLS_PLANNER=$(gsd_run query agent-skills gsd-planner)
|
||||
AGENT_SKILLS_CHECKER=$(gsd_run query agent-skills gsd-plan-checker)
|
||||
CONTEXT_WINDOW=$(gsd_run query config-get context_window 2>/dev/null || echo "200000")
|
||||
TDD_MODE=$(gsd_run query config-get workflow.tdd_mode 2>/dev/null || echo "false")
|
||||
MVP_MODE_CFG=$(gsd_run query config-get workflow.mvp_mode 2>/dev/null || echo "false")
|
||||
```
|
||||
|
||||
When `TDD_MODE` is `true`, the planner agent is instructed to apply `type: tdd` to eligible tasks using heuristics from `references/tdd.md`. The planner's `<required_reading>` is extended to include `@~/.claude/gsd-core/references/tdd.md` so gate enforcement rules are available during planning.
|
||||
When the tdd capability's `workflow.tdd_mode` is active (resolved via the plan:pre render-hooks), the planner agent is instructed to apply `type: tdd` to eligible tasks using heuristics from `references/tdd.md`. The TDD guidance is injected via the tdd capability's contribution hook at §5.6; no inline config-get is needed.
|
||||
|
||||
When `CONTEXT_WINDOW >= 500000`, the planner prompt includes the 3 most recent prior phase CONTEXT.md and SUMMARY.md files PLUS any phases explicitly listed in the current phase's `Depends on:` field in ROADMAP.md. Explicit dependencies always load regardless of recency (e.g., Phase 7 declaring `Depends on: Phase 2` always sees Phase 2's context). Bounded recency keeps the planner's context budget focused on recent work.
|
||||
|
||||
@@ -165,7 +164,9 @@ Set `TEXT_MODE=true` if `--text` is present in $ARGUMENTS OR `text_mode` from in
|
||||
```bash
|
||||
MVP_FLAG_ARG=""
|
||||
if [[ "$ARGUMENTS" =~ (^|[[:space:]])--mvp([[:space:]]|$) ]]; then MVP_FLAG_ARG="--cli-flag"; fi
|
||||
if [[ "$ARGUMENTS" =~ (^|[[:space:]])--tdd([[:space:]]|$) ]]; then TDD_MODE=true; fi
|
||||
if [[ "$ARGUMENTS" =~ (^|[[:space:]])--tdd([[:space:]]|$) ]]; then
|
||||
gsd_run query config-set workflow.tdd_mode true 2>/dev/null || true
|
||||
fi
|
||||
```
|
||||
|
||||
Defer the `phase.mvp-mode` query until `PHASE` is finalized (after explicit argument parsing/fallback phase detection + validation). The verb returns `true|false`; full result also exposes `source` (`cli_flag` | `roadmap` | `config` | `none`) for diagnostics. Mode is **all-or-nothing per phase** (PRD decision Q1).
|
||||
@@ -581,15 +582,15 @@ test -f "${PHASE_DIR}/${PADDED_PHASE}-VALIDATION.md" && echo "VALIDATION_CREATED
|
||||
|
||||
```bash
|
||||
PLAN_PRE_HOOKS_JSON=$(gsd_run loop render-hooks plan:pre --raw)
|
||||
SECURITY_ASVS=$(gsd_run query config-get workflow.security_asvs_level --raw 2>/dev/null || echo "1")
|
||||
SECURITY_BLOCK=$(gsd_run query config-get workflow.security_block_on --raw 2>/dev/null || echo "high")
|
||||
```
|
||||
|
||||
Resolve active contribution hooks from `PLAN_PRE_HOOKS_JSON` where `kind == "contribution"` and `capId == "security"`.
|
||||
|
||||
**If no active security contribution hook exists:** Skip to step 5.6.
|
||||
|
||||
**If an active security contribution hook exists:** Display banner:
|
||||
**If an active security contribution hook exists:** Read `SECURITY_ASVS` from the active hook's `configValues.security_asvs_level` (default: `1`) and `SECURITY_BLOCK` from `configValues.security_block_on` (default: `"high"`). These values are resolved by the capability registry from user config using the same four-level precedence as hook activation — no inline `config-get` is needed.
|
||||
|
||||
Display banner:
|
||||
|
||||
```
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
@@ -635,7 +636,7 @@ GATE=$(gsd_run check ui-plan-gate "${PHASE}" --raw)
|
||||
|
||||
Read `frontend`, `hasUiSpec`, and `block` from `GATE`.
|
||||
|
||||
**Branch 2 — no frontend indicators (`frontend` is `false`):** Skip silently to step 5.7.
|
||||
**Branch 2 — no frontend indicators (`frontend` is `false`):** Skip silently to step 6.
|
||||
|
||||
**Branch 3 — UI-SPEC already exists (`hasUiSpec` is `true`):**
|
||||
|
||||
@@ -686,68 +687,6 @@ Also available:
|
||||
|
||||
**Exit the plan-phase workflow. Do not continue.**
|
||||
|
||||
## 5.7. Schema Push Detection Gate
|
||||
|
||||
> Detects schema-relevant files in the phase scope and injects a mandatory `[BLOCKING]` schema push task into the plan. Prevents false-positive verification where build/types pass because TypeScript types come from config, not the live database.
|
||||
|
||||
Check if any files in the phase scope match schema patterns:
|
||||
|
||||
```bash
|
||||
PHASE_SECTION=$(gsd_run query roadmap.get-phase "${PHASE}" --pick section 2>/dev/null)
|
||||
```
|
||||
|
||||
Scan `PHASE_SECTION`, `CONTEXT.md` (if loaded), and `RESEARCH.md` (if exists) for file paths matching these ORM patterns:
|
||||
|
||||
| ORM | File Patterns |
|
||||
|-----|--------------|
|
||||
| Payload CMS | `src/collections/**/*.ts`, `src/globals/**/*.ts` |
|
||||
| Prisma | `prisma/schema.prisma`, `prisma/schema/*.prisma` |
|
||||
| Drizzle | `drizzle/schema.ts`, `src/db/schema.ts`, `drizzle/*.ts` |
|
||||
| Supabase | `supabase/migrations/*.sql` |
|
||||
| TypeORM | `src/entities/**/*.ts`, `src/migrations/**/*.ts` |
|
||||
|
||||
Also check if any existing PLAN.md files for this phase already reference these file patterns in `files_modified`.
|
||||
|
||||
**If schema-relevant files detected:**
|
||||
|
||||
Set `SCHEMA_PUSH_REQUIRED=true` and `SCHEMA_ORM={detected_orm}`.
|
||||
|
||||
Determine the push command for the detected ORM:
|
||||
|
||||
| ORM | Push Command | Non-TTY Workaround |
|
||||
|-----|-------------|-------------------|
|
||||
| Payload CMS | `npx payload migrate` | `CI=true PAYLOAD_MIGRATING=true npx payload migrate` |
|
||||
| Prisma | `npx prisma db push` | `npx prisma db push --accept-data-loss` (if destructive) |
|
||||
| Drizzle | `npx drizzle-kit push` | `npx drizzle-kit push` |
|
||||
| Supabase | `supabase db push` | Set `SUPABASE_ACCESS_TOKEN` env var |
|
||||
| TypeORM | `npx typeorm migration:run` | `npx typeorm migration:run -d src/data-source.ts` |
|
||||
|
||||
Inject the following into the planner prompt (step 8) as an additional constraint:
|
||||
|
||||
```markdown
|
||||
<schema_push_requirement>
|
||||
**[BLOCKING] Schema Push Required**
|
||||
|
||||
This phase modifies schema-relevant files ({detected_files}). The planner MUST include
|
||||
a `[BLOCKING]` task that runs the database schema push command AFTER all schema file
|
||||
modifications are complete but BEFORE verification.
|
||||
|
||||
- ORM detected: {SCHEMA_ORM}
|
||||
- Push command: {push_command}
|
||||
- Non-TTY workaround: {env_hint}
|
||||
- If push requires interactive prompts that cannot be suppressed, flag the task for
|
||||
manual intervention with `autonomous: false`
|
||||
|
||||
This task is mandatory — the phase CANNOT pass verification without it. Build and
|
||||
type checks will pass without the push (types come from config, not the live database),
|
||||
creating a false-positive verification state.
|
||||
</schema_push_requirement>
|
||||
```
|
||||
|
||||
Display: `Schema files detected ({SCHEMA_ORM}) — [BLOCKING] push task will be injected into plans`
|
||||
|
||||
**If no schema-relevant files detected:** Skip silently to step 6.
|
||||
|
||||
## 6. Check Existing Plans
|
||||
|
||||
```bash
|
||||
@@ -853,16 +792,21 @@ PATTERNS_PATH="${PHASE_DIR}/${PADDED_PHASE}-PATTERNS.md"
|
||||
|
||||
## 7.9. Regenerate API-SURFACE.md (intel gate)
|
||||
|
||||
> Capability-driven dispatch. Resolves active `plan:pre` step hooks via the capability registry; the intel hook's `when: intel.enabled` condition is evaluated by the registry — no inline config-get needed.
|
||||
|
||||
Read the active intel step hook from `PLAN_PRE_HOOKS_JSON` where `kind == "step"` and `capId == "intel"`.
|
||||
|
||||
**If no active intel step hook exists:** `API_SURFACE_PATH` stays empty; skip to step 8. The step-8 planner entry for API Surface is omitted when `API_SURFACE_PATH` is empty.
|
||||
|
||||
**If an active intel step hook exists:**
|
||||
```bash
|
||||
INTEL_CFG=$(gsd_run query config-get intel.enabled 2>/dev/null || echo "false")
|
||||
# false (absent = false) → API_SURFACE_PATH stays empty; step-8 planner entry omitted
|
||||
if [ "$INTEL_CFG" = "true" ]; then
|
||||
gsd_run intel api-surface
|
||||
API_SURFACE_PATH=".planning/intel/API-SURFACE.md"
|
||||
echo "✓ API surface regenerated: ${API_SURFACE_PATH}" # injected into step 8 as HINT
|
||||
fi
|
||||
gsd_run intel api-surface
|
||||
API_SURFACE_PATH=".planning/intel/API-SURFACE.md"
|
||||
echo "✓ API surface regenerated: ${API_SURFACE_PATH}" # injected into step 8 as HINT
|
||||
```
|
||||
|
||||
Continue to step 8.
|
||||
|
||||
## 8. Spawn gsd-planner Agent
|
||||
|
||||
Display banner:
|
||||
@@ -896,7 +840,7 @@ Planner prompt:
|
||||
- {SPEC_PATH} (Phase SPEC — carries the ## Edge Coverage section to lift covered/backstop edges from, if exists)
|
||||
- {SPIKE_FINDINGS_PATH} (Spike Findings — validated patterns, constraints, landmines from experiments, if exists)
|
||||
- {SKETCH_FINDINGS_PATH} (Sketch Findings — validated design decisions, CSS patterns, visual direction, if exists)
|
||||
- {API_SURFACE_PATH} (API Surface — HINT ONLY, if intel.enabled; see <intel_surface_hint> below)
|
||||
- {API_SURFACE_PATH} (API Surface — HINT ONLY, when intel capability is active; see <intel_surface_hint> below)
|
||||
${CONTEXT_WINDOW >= 500000 ? `
|
||||
**Cross-phase context (1M model enrichment):**
|
||||
- CONTEXT.md files from the 3 most recent completed phases (locked decisions — maintain consistency)
|
||||
@@ -928,16 +872,7 @@ Historical findings already incorporated, explicitly deferred/rejected in PLAN.m
|
||||
**Project instructions:** Read ./CLAUDE.md or ./.claude/CLAUDE.md if either exists — follow project-specific guidelines
|
||||
**Project skills:** Check .claude/skills/ or .agents/skills/ directory (if either exists) — read SKILL.md files, plans should account for project skill rules
|
||||
|
||||
${TDD_MODE === 'true' ? `
|
||||
<tdd_mode_active>
|
||||
**TDD Mode is ENABLED.** Apply TDD heuristics from @~/.claude/gsd-core/references/tdd.md to all eligible tasks:
|
||||
- Business logic with defined I/O → type: tdd
|
||||
- API endpoints with request/response contracts → type: tdd
|
||||
- Data transformations, validation, algorithms → type: tdd
|
||||
- UI, config, glue code, CRUD → standard plan (type: execute)
|
||||
Each TDD plan gets one feature with RED/GREEN/REFACTOR gate sequence.
|
||||
</tdd_mode_active>
|
||||
` : ''}
|
||||
{For each active entry in `PLAN_PRE_HOOKS_JSON` where `kind == "contribution"` and `into == "planner"` (in array order): inject the entry's `fragment.inline` verbatim here. This delivers all planner-targeted contributions — including tdd's `<tdd_mode_active>` block (type:tdd heuristics), schema-gate's schema-push detection guidance (if active at plan:pre), and security's threat-model guidance. For the security contribution, also surface the resolved `configValues`: `security_asvs_level` (ASVS enforcement level) and `security_block_on` (severity threshold) so the planner uses the configured values when generating `<threat_model>` blocks. If no active planner contributions exist, omit this block entirely.}
|
||||
|
||||
**MVP_MODE:** ${MVP_MODE} (when true, follow vertical-slice rules from `~/.claude/gsd-core/references/planner-mvp-mode.md`; when false, ignore MVP guidance entirely.)
|
||||
**WALKING_SKELETON:** ${WALKING_SKELETON} (when true, the first deliverable must be a Walking Skeleton — Read the template at `~/.claude/gsd-core/references/skeleton-template.md` and produce SKELETON.md alongside PLAN.md.)
|
||||
@@ -1630,53 +1565,38 @@ gsd_run query commit "docs(${PADDED_PHASE}): create phase plan" --files "${PHASE
|
||||
|
||||
This commits all PLAN.md files for the phase plus the updated STATE.md and ROADMAP.md to version-control the planning artifacts. Skip this step if `commit_docs` is false.
|
||||
|
||||
## 13e. Post-Planning Gap Analysis
|
||||
## 13e. Post-Planning Gap Analysis (plan:post capability gate dispatch)
|
||||
|
||||
After all plans are generated, committed, and the Requirements Coverage Gate (§13)
|
||||
has run, emit a single unified gap report covering both REQUIREMENTS.md and the
|
||||
CONTEXT.md `<decisions>` section. This is a **proactive, post-hoc report** — it
|
||||
does not block phase advancement and does not re-plan. It exists so that any
|
||||
requirement or decision that slipped through the per-plan checks is surfaced in
|
||||
one place before execution begins.
|
||||
|
||||
**Skip if:** `workflow.post_planning_gaps` is `false`. Default is `true`.
|
||||
Proactive, non-blocking coverage report gated on `workflow.post_planning_gaps`
|
||||
(default `true`). Dispatched via the `plan:post` capability gate owned by the
|
||||
`gap-analysis` capability (ADR-857 §53). Reads REQUIREMENTS.md and CONTEXT.md
|
||||
`<decisions>` and cross-references each REQ-ID / D-ID against `${PHASE_DIR}/*-PLAN.md`.
|
||||
|
||||
```bash
|
||||
POST_PLANNING_GAPS=$(gsd_run query config-get workflow.post_planning_gaps --default true 2>/dev/null || echo true)
|
||||
if [ "$POST_PLANNING_GAPS" = "true" ]; then
|
||||
# Scope to this phase's mapped REQ-IDs (#447); null/TBD skips the requirements comparison (CONTEXT.md decisions still reported), mirroring §13.
|
||||
gsd_run gap-analysis --phase-dir "${PHASE_DIR}" --phase-req-ids "$(gsd_run query init.plan-phase "$PHASE" --pick phase_req_ids 2>/dev/null || echo TBD)"
|
||||
fi
|
||||
PLAN_POST_HOOKS_JSON=$(gsd_run loop render-hooks plan:post --raw)
|
||||
PHASE_REQ_IDS=$(gsd_run query init.plan-phase "$PHASE" --pick phase_req_ids 2>/dev/null || echo TBD)
|
||||
```
|
||||
|
||||
(`gsd-tools.cjs gap-analysis` reads `.planning/REQUIREMENTS.md`, `${PHASE_DIR}/CONTEXT.md`,
|
||||
and `${PHASE_DIR}/*-PLAN.md`, then prints a markdown table with one row per
|
||||
REQ-ID and D-ID. Word-boundary matching prevents `REQ-1` from being mistaken for
|
||||
`REQ-10`.)
|
||||
Read the `activeHooks` array from `PLAN_POST_HOOKS_JSON` in-context. If the
|
||||
`gap-analysis` gate hook is absent (capability inactive), skip this step.
|
||||
|
||||
**Output format (deterministic; sorted REQUIREMENTS.md → CONTEXT.md, then natural
|
||||
sort within source):**
|
||||
**For each active entry where `kind == "gate"`** (process in array order):
|
||||
|
||||
```
|
||||
## Post-Planning Gap Analysis
|
||||
|
||||
| Source | Item | Status |
|
||||
|--------|------|--------|
|
||||
| REQUIREMENTS.md | REQ-01 | ✓ Covered |
|
||||
| REQUIREMENTS.md | REQ-02 | ✗ Not covered |
|
||||
| CONTEXT.md | D-01 | ✓ Covered |
|
||||
| CONTEXT.md | D-02 | ✗ Not covered |
|
||||
|
||||
⚠ N items not covered by any plan
|
||||
```bash
|
||||
GATE_RESULT=$(gsd_run check ${hook.check.query} "${PHASE_DIR}" "${PHASE_REQ_IDS}" --raw)
|
||||
CHECK_EXIT=$?
|
||||
```
|
||||
|
||||
**Skip-gracefully behavior:**
|
||||
- REQUIREMENTS.md missing → CONTEXT-only report.
|
||||
- CONTEXT.md missing → REQUIREMENTS-only report.
|
||||
- Both missing or `<decisions>` block missing → "No requirements or decisions to check" line, no error.
|
||||
**Step 1 — did the CHECK COMMAND itself succeed?**
|
||||
If the check command failed (non-zero `CHECK_EXIT`, empty output, or unparseable JSON):
|
||||
- `onError == "halt"` → halt and surface command error.
|
||||
- `onError == "skip"` → log a warning and continue to the next hook.
|
||||
|
||||
This step is non-blocking. If items are reported as not covered, the user may
|
||||
re-run `/gsd:plan-phase --gaps` to add plans, or proceed to execute-phase as-is.
|
||||
**Step 2 — read `GATE_RESULT.block` (boolean).** Only reached when command succeeded.
|
||||
|
||||
- If `hook.blocking == true` and `GATE_RESULT.block == true`: halt. (gap-analysis is always `blocking: false` so this branch is informational only.)
|
||||
- If `hook.blocking == false` (advisory): if `GATE_RESULT.block == true` or non-empty `table`/`summary`, output the gap table and continue. Advisory gates never block phase completion.
|
||||
- If `hook.blocking == true` and `GATE_RESULT.block == false`: continue silently.
|
||||
|
||||
## 14. Present Final Status
|
||||
|
||||
|
||||
@@ -1030,15 +1030,17 @@ function validateStep(step, prefix, declaredSkills, declaredAgents) {
|
||||
}
|
||||
|
||||
if (typeof step.ref !== 'object' || step.ref === null) {
|
||||
errors.push(prefix + '.ref must be an object with skill or agent key');
|
||||
errors.push(prefix + '.ref must be an object with skill, agent, or command key');
|
||||
} else {
|
||||
const hasSkill = Object.prototype.hasOwnProperty.call(step.ref, 'skill');
|
||||
const hasAgent = Object.prototype.hasOwnProperty.call(step.ref, 'agent');
|
||||
if (!hasSkill && !hasAgent) {
|
||||
errors.push(prefix + '.ref must have a "skill" or "agent" key');
|
||||
} else if (hasSkill && hasAgent) {
|
||||
// Fix #4: ref must be exclusive {skill} XOR {agent}
|
||||
errors.push(prefix + '.ref must have exactly one of "skill" or "agent", not both');
|
||||
const hasCommand = Object.prototype.hasOwnProperty.call(step.ref, 'command');
|
||||
const dispatchCount = [hasSkill, hasAgent, hasCommand].filter(Boolean).length;
|
||||
if (dispatchCount === 0) {
|
||||
errors.push(prefix + '.ref must have a "skill", "agent", or "command" key');
|
||||
} else if (dispatchCount > 1) {
|
||||
// ref must be exclusive: skill XOR agent XOR command
|
||||
errors.push(prefix + '.ref must have exactly one of "skill", "agent", or "command", not multiple');
|
||||
}
|
||||
if (hasSkill && typeof step.ref.skill !== 'string') {
|
||||
errors.push(prefix + '.ref.skill must be a string');
|
||||
@@ -1068,6 +1070,9 @@ function validateStep(step, prefix, declaredSkills, declaredAgents) {
|
||||
[...declaredAgents].join(', ') + ']',
|
||||
);
|
||||
}
|
||||
if (hasCommand && typeof step.ref.command !== 'string') {
|
||||
errors.push(prefix + '.ref.command must be a string');
|
||||
}
|
||||
}
|
||||
|
||||
if (!Array.isArray(step.produces)) {
|
||||
|
||||
@@ -160,6 +160,14 @@
|
||||
"edge-probe.test.cjs"
|
||||
],
|
||||
"issue": "550"
|
||||
},
|
||||
"federated-config": {
|
||||
"files": [
|
||||
"federated-config.test.cjs",
|
||||
"federated-config-loadconfig.test.cjs",
|
||||
"federated-config-key-removal.test.cjs"
|
||||
],
|
||||
"issue": "TBD"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,8 +16,14 @@ import { parseDecisions } from './decisions.cjs';
|
||||
import type { Decision } from './decisions.cjs';
|
||||
import { checkUiPresence } from './ui-safety-gate.cjs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import verifyModule = require('./verify.cjs');
|
||||
const { cmdVerifySchemaDrift, cmdVerifyCodebaseDrift } = verifyModule;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import roadmapModule = require('./roadmap.cjs');
|
||||
const { getRoadmapPhaseWithFallback } = roadmapModule;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import gapCheckerModule = require('./gap-checker.cjs');
|
||||
const { runGapAnalysis } = gapCheckerModule;
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -462,6 +468,367 @@ function cmdUiPlanGate(projectDir: string, args: string[], raw: boolean): void {
|
||||
output(computeUiPlanGate(projectDir, phase), raw, undefined);
|
||||
}
|
||||
|
||||
// ─── ui-safety-gate ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* ui-safety-gate: post-wave check that verifies UI-changed files conform to
|
||||
* the active UI-SPEC for the phase. Called after each wave by execute:wave:post.
|
||||
*
|
||||
* Returns JSON: { frontend: boolean, hasUiFiles: boolean, hasUiSpec: boolean, block: boolean, message?: string }
|
||||
* block = frontend && hasUiFiles && !hasUiSpec
|
||||
*
|
||||
* Args: check ui-safety-gate <phase>
|
||||
* Invocable as: gsd_run check ui-safety-gate <phase>
|
||||
* or gsd_run check ui.safety-gate <phase> (dots normalized to hyphens)
|
||||
*
|
||||
* Uses checkUiPresence from ui-safety-gate.cjs — does NOT reimplement frontend detection.
|
||||
* Checks whether any files changed in recent git history match frontend file patterns.
|
||||
* Also checks whether a *-UI-SPEC.md exists in the phase directory (same as ui-plan-gate).
|
||||
*
|
||||
* Limitation: uses git diff HEAD~1..HEAD which covers only the last commit; in a
|
||||
* multi-plan wave the wave-start commit would be more accurate but is not yet stored
|
||||
* in the wave manifest. This is tracked as a known limitation.
|
||||
*/
|
||||
const UI_FILE_EXTENSIONS_RE = /\.(tsx|jsx|css|scss|sass|less|vue|svelte|html)$/i;
|
||||
const UI_PATH_PATTERNS_RE = /\/(components|pages|views|screens|layouts|ui|frontend)\//i;
|
||||
|
||||
/**
|
||||
* Pure logic for ui-safety-gate — exposed for direct behavioral testing.
|
||||
*
|
||||
* Given a projectDir and phase number:
|
||||
* (a) Reads the phase section from ROADMAP.md via getRoadmapPhaseWithFallback —
|
||||
* same lookup as computeUiPlanGate — to determine if this is a frontend phase.
|
||||
* (b) Runs checkUiPresence (frontend detection) — no reimplementation.
|
||||
* (c) Checks git diff HEAD~1..HEAD for UI file changes in the current worktree.
|
||||
* (d) Resolves the phase directory via core.findPhaseInternal; checks for *-UI-SPEC.md.
|
||||
*
|
||||
* Returns: { frontend, hasUiFiles, hasUiSpec, block, message?, phaseLookupFailed? }
|
||||
* block = frontend && hasUiFiles && !hasUiSpec
|
||||
* phaseLookupFailed = ROADMAP.md present but phase header not found
|
||||
*/
|
||||
function computeUiSafetyGate(projectDir: string, phase: string): {
|
||||
frontend: boolean;
|
||||
hasUiFiles: boolean;
|
||||
hasUiSpec: boolean;
|
||||
block: boolean;
|
||||
message?: string;
|
||||
phaseLookupFailed?: boolean;
|
||||
} {
|
||||
// (a) Read the phase section text (same two-pass lookup as computeUiPlanGate)
|
||||
let phaseSection = '';
|
||||
let phaseLookupFailed: boolean | undefined;
|
||||
try {
|
||||
const section = getRoadmapPhaseWithFallback(projectDir, phase);
|
||||
if (section === null) {
|
||||
const planDir: string = typeof (core as unknown as Record<string, unknown>)['planningDir'] === 'function'
|
||||
? (core as unknown as Record<string, (cwd: string) => string>)['planningDir'](projectDir)
|
||||
: path.join(projectDir, '.planning');
|
||||
const roadmapPath = path.join(planDir, 'ROADMAP.md');
|
||||
if (fs.existsSync(roadmapPath)) {
|
||||
phaseLookupFailed = true;
|
||||
}
|
||||
} else {
|
||||
phaseSection = section;
|
||||
}
|
||||
} catch { /* roadmap read failure → treat as empty (non-frontend) */ }
|
||||
|
||||
// (b) Run checkUiPresence (frontend detection) — reuse existing helper; no reimplementation
|
||||
const presenceResult = checkUiPresence(phaseSection);
|
||||
const frontend = presenceResult.hasUI;
|
||||
|
||||
// (c) Check whether any UI files were changed in recent git commits
|
||||
// Uses git diff HEAD~1..HEAD to detect frontend file changes since last commit.
|
||||
// Known limitation: multi-plan waves may need the wave-start commit for full coverage.
|
||||
let hasUiFiles = false;
|
||||
try {
|
||||
const changed = execFileSync('git', ['diff', '--name-only', 'HEAD~1', 'HEAD'], {
|
||||
cwd: projectDir,
|
||||
encoding: 'utf-8',
|
||||
maxBuffer: 2 * 1024 * 1024,
|
||||
windowsHide: true,
|
||||
});
|
||||
hasUiFiles = changed.split('\n').some((f) =>
|
||||
f.trim() && (UI_FILE_EXTENSIONS_RE.test(f) || UI_PATH_PATTERNS_RE.test(f)),
|
||||
);
|
||||
} catch { /* git unavailable or no prior commit — treat as no UI files changed */ }
|
||||
|
||||
// (d) Resolve phase directory and check for *-UI-SPEC.md (same as computeUiPlanGate)
|
||||
const coreModule = core as unknown as Record<string, unknown>;
|
||||
let phaseDir = '';
|
||||
try {
|
||||
const findPhase = coreModule['findPhaseInternal'] as ((cwd: string, phase: string) => Record<string, unknown> | string | null) | undefined;
|
||||
if (typeof findPhase === 'function') {
|
||||
const result = findPhase(projectDir, phase);
|
||||
if (result && typeof result === 'object') {
|
||||
const relDir = typeof result['directory'] === 'string' ? result['directory'] : '';
|
||||
if (relDir) {
|
||||
phaseDir = path.resolve(projectDir, relDir);
|
||||
}
|
||||
} else if (typeof result === 'string') {
|
||||
phaseDir = result;
|
||||
}
|
||||
}
|
||||
} catch { /* phase dir lookup failure → hasUiSpec=false */ }
|
||||
|
||||
const uiSpecPath = findUiSpecInDir(phaseDir);
|
||||
const hasUiSpec = uiSpecPath !== '';
|
||||
|
||||
// block only when: this is a frontend phase AND UI files were changed AND no UI-SPEC exists
|
||||
const block = frontend && hasUiFiles && !hasUiSpec;
|
||||
|
||||
const result: {
|
||||
frontend: boolean;
|
||||
hasUiFiles: boolean;
|
||||
hasUiSpec: boolean;
|
||||
block: boolean;
|
||||
message?: string;
|
||||
phaseLookupFailed?: boolean;
|
||||
} = { frontend, hasUiFiles, hasUiSpec, block };
|
||||
|
||||
if (block) {
|
||||
result.message = `UI files changed in this wave but no UI-SPEC.md exists for Phase ${phase}. ` +
|
||||
`Run /gsd:ui-phase ${phase} to generate the design contract before continuing.`;
|
||||
}
|
||||
if (phaseLookupFailed) result.phaseLookupFailed = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
function cmdUiSafetyGate(projectDir: string, args: string[], raw: boolean): void {
|
||||
// args[0] = 'check', args[1] = 'ui-safety-gate', args[2] = phase
|
||||
const phase = args[2] || '';
|
||||
if (!phase) {
|
||||
error('ui-safety-gate requires a phase argument: check ui-safety-gate <phase>', ERROR_REASON.SDK_MISSING_ARG);
|
||||
return;
|
||||
}
|
||||
output(computeUiSafetyGate(projectDir, phase), raw, undefined);
|
||||
}
|
||||
|
||||
// ─── tdd-review-checkpoint ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* tdd-review-checkpoint: end-of-phase advisory check that scans type:tdd plans
|
||||
* for RED/GREEN/REFACTOR gate-sequence compliance and surfaces a review table.
|
||||
*
|
||||
* Logic from gsd-core/references/tdd.md <end_of_phase_review> and
|
||||
* execute-phase.md <step name="tdd_review_checkpoint"> (now removed).
|
||||
*
|
||||
* Returns JSON:
|
||||
* { passed: true, tddPlans: N, violations: N, table: string, rows: PlanRow[] }
|
||||
* where passed is always true (advisory gate — never blocks).
|
||||
*
|
||||
* Args: check tdd.review-checkpoint <phase>
|
||||
* Phase can be a number or phase-dir path; if not resolvable the check
|
||||
* returns passed:true with tddPlans:0 (no plans to review).
|
||||
*/
|
||||
interface TddPlanRow {
|
||||
planId: string;
|
||||
red: boolean;
|
||||
green: boolean;
|
||||
refactor: boolean;
|
||||
status: 'Pass' | 'FAIL';
|
||||
missing: string[];
|
||||
}
|
||||
|
||||
function cmdTddReviewCheckpoint(projectDir: string, args: string[], raw: boolean): void {
|
||||
// args[0] = 'check', args[1] = 'tdd-review-checkpoint' (normalized), args[2] = phase
|
||||
const phase = args[2] || '';
|
||||
if (!phase) {
|
||||
error('tdd.review-checkpoint requires a phase argument: check tdd.review-checkpoint <phase>', ERROR_REASON.SDK_MISSING_ARG);
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve phase directory
|
||||
const coreModule = core as unknown as Record<string, unknown>;
|
||||
let phaseDir = '';
|
||||
try {
|
||||
const findPhase = coreModule['findPhaseInternal'] as ((cwd: string, phase: string) => Record<string, unknown> | string | null) | undefined;
|
||||
if (typeof findPhase === 'function') {
|
||||
const result = findPhase(projectDir, phase);
|
||||
if (result && typeof result === 'object') {
|
||||
const relDir = typeof result['directory'] === 'string' ? result['directory'] : '';
|
||||
if (relDir) phaseDir = path.resolve(projectDir, relDir);
|
||||
} else if (typeof result === 'string') {
|
||||
phaseDir = result;
|
||||
}
|
||||
}
|
||||
} catch { /* phase dir lookup failure */ }
|
||||
|
||||
// Find all PLAN.md files with type: tdd in frontmatter
|
||||
const tddPlanFiles: string[] = [];
|
||||
if (phaseDir) {
|
||||
try {
|
||||
const files = fs.readdirSync(phaseDir).filter(f => f.endsWith('-PLAN.md'));
|
||||
for (const file of files) {
|
||||
const planPath = path.join(phaseDir, file);
|
||||
const content = readIfExists(planPath);
|
||||
// Check frontmatter for type: tdd
|
||||
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
||||
if (frontmatterMatch) {
|
||||
const fm = frontmatterMatch[1];
|
||||
if (/^type:\s*tdd\s*$/m.test(fm)) {
|
||||
tddPlanFiles.push(planPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* directory read failure */ }
|
||||
}
|
||||
|
||||
if (tddPlanFiles.length === 0) {
|
||||
const result = {
|
||||
// Uniform gate contract: block = violations > 0 (advisory; never truly blocks).
|
||||
block: false,
|
||||
passed: true,
|
||||
tddPlans: 0,
|
||||
violations: 0,
|
||||
table: '',
|
||||
rows: [] as TddPlanRow[],
|
||||
message: `No type:tdd plans found in phase ${phase}. TDD review skipped.`,
|
||||
};
|
||||
// Pass undefined as rawValue so --raw emits JSON (not plain text).
|
||||
// The human-readable report is carried in `result.message` for the
|
||||
// dispatch's advisory branch to surface.
|
||||
output(result, raw, undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
// For each TDD plan, extract the plan ID (padded plan number) and check git log
|
||||
const rows: TddPlanRow[] = [];
|
||||
for (const planPath of tddPlanFiles) {
|
||||
// Extract plan ID from filename (e.g. "01-02-PLAN.md" → "01-02", or "03-PLAN.md" → "03")
|
||||
const basename = path.basename(planPath, '-PLAN.md');
|
||||
// planId for commit grep: phase-plan format, e.g. "01-02"
|
||||
const planId = basename;
|
||||
|
||||
// Check for RED gate commit: test({planId}):
|
||||
let red = false;
|
||||
let green = false;
|
||||
let refactor = false;
|
||||
try {
|
||||
const redCommit = execFileSync(
|
||||
'git', ['log', '--oneline', `--grep=^test(${planId}):`, '--', '.'],
|
||||
{ cwd: projectDir, encoding: 'utf-8', maxBuffer: 1024 * 1024, windowsHide: true },
|
||||
);
|
||||
red = redCommit.trim().length > 0;
|
||||
} catch { /* git unavailable or no match */ }
|
||||
|
||||
try {
|
||||
const greenCommit = execFileSync(
|
||||
'git', ['log', '--oneline', `--grep=^feat(${planId}):`, '--', '.'],
|
||||
{ cwd: projectDir, encoding: 'utf-8', maxBuffer: 1024 * 1024, windowsHide: true },
|
||||
);
|
||||
green = greenCommit.trim().length > 0;
|
||||
} catch { /* git unavailable or no match */ }
|
||||
|
||||
try {
|
||||
const refactorCommit = execFileSync(
|
||||
'git', ['log', '--oneline', `--grep=^refactor(${planId}):`, '--', '.'],
|
||||
{ cwd: projectDir, encoding: 'utf-8', maxBuffer: 1024 * 1024, windowsHide: true },
|
||||
);
|
||||
refactor = refactorCommit.trim().length > 0;
|
||||
} catch { /* git unavailable or no match */ }
|
||||
|
||||
const missing: string[] = [];
|
||||
if (!red) missing.push('RED');
|
||||
if (!green) missing.push('GREEN');
|
||||
const status: 'Pass' | 'FAIL' = missing.length === 0 ? 'Pass' : 'FAIL';
|
||||
|
||||
rows.push({ planId, red, green, refactor, status, missing });
|
||||
}
|
||||
|
||||
const violations = rows.filter(r => r.status === 'FAIL').length;
|
||||
|
||||
// Build review table
|
||||
const sep = '━'.repeat(53);
|
||||
const tableHeader = '| Plan | RED | GREEN | REFACTOR | Status |';
|
||||
const tableDivider = '|------|-----|-------|----------|--------|';
|
||||
const tableRows = rows.map(r =>
|
||||
`| ${r.planId.padEnd(4)} | ${r.red ? ' ✓ ' : ' ✗ '} | ${r.green ? ' ✓ ' : ' ✗ '} | ${r.refactor ? ' ✓ ' : ' — '} | ${r.status.padEnd(6)} |`,
|
||||
);
|
||||
|
||||
let table = [
|
||||
sep,
|
||||
` TDD REVIEW — Phase ${phase}`,
|
||||
sep,
|
||||
'',
|
||||
`TDD Plans: ${tddPlanFiles.length} | Gate violations: ${violations}`,
|
||||
'',
|
||||
tableHeader,
|
||||
tableDivider,
|
||||
...tableRows,
|
||||
].join('\n');
|
||||
|
||||
if (violations > 0) {
|
||||
table += '\n\n⚠ Gate violations are advisory — review before advancing.';
|
||||
for (const r of rows.filter(row => row.status === 'FAIL')) {
|
||||
table += `\n Plan ${r.planId} missing: ${r.missing.join(', ')} gate commit(s).`;
|
||||
table += `\n Expected commit pattern: test(${r.planId}): ... → feat(${r.planId}): ...`;
|
||||
}
|
||||
}
|
||||
|
||||
const result = {
|
||||
// Uniform gate contract: block = violations > 0.
|
||||
// This gate is advisory (blocking: false in capability.json) so block:true
|
||||
// only surfaces as a warning, never halts. Kept here so the host-loop
|
||||
// dispatch can read a single consistent `block` field.
|
||||
block: violations > 0,
|
||||
passed: true,
|
||||
tddPlans: tddPlanFiles.length,
|
||||
violations,
|
||||
table,
|
||||
rows,
|
||||
// Human-readable report in `message` so the dispatch's advisory branch
|
||||
// can surface it. --raw emits JSON (rawValue=undefined), not plain text.
|
||||
message: table,
|
||||
};
|
||||
// Pass undefined as rawValue so --raw emits JSON (not the raw table text).
|
||||
// The review table is carried in `result.message` and `result.table` so
|
||||
// the host-loop dispatch's advisory branch can surface it.
|
||||
output(result, raw, undefined);
|
||||
}
|
||||
|
||||
// ─── gap-analysis-plan-post ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* gap-analysis-plan-post: non-blocking advisory check that runs the post-planning
|
||||
* gap analysis after all PLAN.md files are generated for a phase.
|
||||
*
|
||||
* Cross-references every REQ-ID and D-ID from REQUIREMENTS.md and CONTEXT.md
|
||||
* against the concatenated text of all *-PLAN.md files, emitting a coverage table.
|
||||
*
|
||||
* This gate is always advisory (passed: true) — it never blocks phase advancement.
|
||||
*
|
||||
* Args: check gap-analysis.plan-post <phase-dir> [phase-req-ids]
|
||||
* Invocable as: gsd_run check gap-analysis.plan-post <phase-dir> [phase-req-ids]
|
||||
*/
|
||||
function cmdGapAnalysisPlanPost(projectDir: string, args: string[], raw: boolean): void {
|
||||
// args[0] = 'check', args[1] = 'gap-analysis-plan-post' (normalized), args[2] = phaseDir, args[3] = phaseReqIds
|
||||
const phaseDir = args[2] || '';
|
||||
if (!phaseDir) {
|
||||
error('gap-analysis.plan-post requires a phase-dir argument: check gap-analysis.plan-post <phase-dir> [phase-req-ids]', ERROR_REASON.SDK_MISSING_ARG);
|
||||
return;
|
||||
}
|
||||
const phaseReqIds = args[3] ?? undefined;
|
||||
const result = runGapAnalysis(projectDir, phaseDir, { phaseReqIds });
|
||||
// Uniform gate contract: block = false (gap-analysis is always advisory, never blocks).
|
||||
// `message` carries the human-readable gap analysis report so the dispatch's
|
||||
// advisory branch can surface it. --raw emits JSON (rawValue=undefined), not
|
||||
// plain markdown text.
|
||||
output(
|
||||
{
|
||||
block: false,
|
||||
passed: true,
|
||||
enabled: result.enabled,
|
||||
table: result.table,
|
||||
summary: result.summary,
|
||||
counts: result.counts,
|
||||
// Human-readable report in `message` for the host-loop advisory branch.
|
||||
message: result.table || result.summary || '',
|
||||
},
|
||||
raw,
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
|
||||
interface RouteCheckCommandOptions {
|
||||
args: string[];
|
||||
cwd: string;
|
||||
@@ -493,7 +860,34 @@ function routeCheckCommand({ args, cwd, raw }: RouteCheckCommandOptions): void {
|
||||
cmdUiPlanGate(cwd, args, raw);
|
||||
return;
|
||||
}
|
||||
error('Unknown check subcommand. Available: auto-mode, decision-coverage-plan, decision-coverage-verify, ui-plan-gate', ERROR_REASON.SDK_UNKNOWN_COMMAND);
|
||||
if (subcommand === 'gap-analysis-plan-post') {
|
||||
cmdGapAnalysisPlanPost(cwd, args, raw);
|
||||
return;
|
||||
}
|
||||
if (subcommand === 'tdd-review-checkpoint') {
|
||||
cmdTddReviewCheckpoint(cwd, args, raw);
|
||||
return;
|
||||
}
|
||||
if (subcommand === 'ui-safety-gate') {
|
||||
cmdUiSafetyGate(cwd, args, raw);
|
||||
return;
|
||||
}
|
||||
if (subcommand === 'verify-schema-drift') {
|
||||
// Delegates to verify.schema-drift — drift capability gate at execute:wave:post (blocking).
|
||||
// Dot-to-hyphen normalization means query "verify.schema-drift" routes here.
|
||||
// Honor GSD_SKIP_SCHEMA_CHECK=true to bypass the gate (preserves the original inline gate behavior).
|
||||
const phaseArg = typeof args[2] === 'string' ? args[2] : '';
|
||||
const skipSchemaCheck = process.env['GSD_SKIP_SCHEMA_CHECK'] === 'true';
|
||||
cmdVerifySchemaDrift(cwd, phaseArg, skipSchemaCheck, raw);
|
||||
return;
|
||||
}
|
||||
if (subcommand === 'verify-codebase-drift') {
|
||||
// Delegates to verify.codebase-drift — drift capability gate at execute:wave:post (non-blocking).
|
||||
// Dot-to-hyphen normalization means query "verify.codebase-drift" routes here.
|
||||
cmdVerifyCodebaseDrift(cwd, raw);
|
||||
return;
|
||||
}
|
||||
error('Unknown check subcommand. Available: auto-mode, decision-coverage-plan, decision-coverage-verify, gap-analysis-plan-post, tdd-review-checkpoint, ui-plan-gate, ui-safety-gate, verify-schema-drift, verify-codebase-drift', ERROR_REASON.SDK_UNKNOWN_COMMAND);
|
||||
}
|
||||
|
||||
export = {
|
||||
@@ -501,4 +895,7 @@ export = {
|
||||
decisionMentioned,
|
||||
extractPlanDesignatedSections,
|
||||
computeUiPlanGate,
|
||||
computeUiSafetyGate,
|
||||
cmdGapAnalysisPlanPost,
|
||||
cmdTddReviewCheckpoint,
|
||||
};
|
||||
|
||||
@@ -576,7 +576,6 @@ function loadConfig(cwd: string, options: Record<string, unknown> = {}): Record<
|
||||
brave_search: get('brave_search') ?? defaults.brave_search,
|
||||
firecrawl: get('firecrawl') ?? defaults.firecrawl,
|
||||
exa_search: get('exa_search') ?? defaults.exa_search,
|
||||
tdd_mode: get('tdd_mode', { section: 'workflow', field: 'tdd_mode' }) ?? false,
|
||||
mvp_mode: get('mvp_mode', { section: 'workflow', field: 'mvp_mode' }) ?? false,
|
||||
text_mode: get('text_mode', { section: 'workflow', field: 'text_mode' }) ?? defaults.text_mode,
|
||||
auto_advance: get('auto_advance', { section: 'workflow', field: 'auto_advance' }) ?? false,
|
||||
|
||||
@@ -235,7 +235,6 @@ function buildNewProjectConfig(userChoices: Record<string, unknown>): Record<str
|
||||
ui_phase: true,
|
||||
ui_safety_gate: true,
|
||||
ai_integration_phase: true,
|
||||
tdd_mode: false,
|
||||
human_verify_mode: 'end-of-phase',
|
||||
text_mode: false,
|
||||
research_before_questions: false,
|
||||
|
||||
16
src/init.cts
16
src/init.cts
@@ -229,11 +229,13 @@ function cmdInitExecutePhase(
|
||||
: null;
|
||||
const phase_req_ids = reqExtracted && reqExtracted !== 'TBD' ? reqExtracted : null;
|
||||
|
||||
const wf = (config.workflow ?? {}) as Record<string, unknown>;
|
||||
|
||||
const result: Record<string, unknown> = {
|
||||
executor_model: resolveModelInternal(cwd, 'gsd-executor'),
|
||||
verifier_model: resolveModelInternal(cwd, 'gsd-verifier'),
|
||||
|
||||
tdd_mode: options['tdd'] || config.tdd_mode || false,
|
||||
tdd_mode: options['tdd'] || Boolean(wf['tdd_mode']) || false,
|
||||
commit_docs: config.commit_docs,
|
||||
sub_repos: config.sub_repos,
|
||||
parallelization: config.parallelization,
|
||||
@@ -381,16 +383,18 @@ function cmdInitPlanPhase(
|
||||
assertValidGranularityOverride(granularityOverride, error);
|
||||
const granularity = resolveGranularityInternal(cwd, 'planning', granularityOverride || undefined);
|
||||
|
||||
const wf = (config.workflow ?? {}) as Record<string, unknown>;
|
||||
|
||||
const result: Record<string, unknown> = {
|
||||
researcher_model: resolveModelInternal(cwd, 'gsd-phase-researcher'),
|
||||
planner_model: resolveModelInternal(cwd, 'gsd-planner'),
|
||||
checker_model: resolveModelInternal(cwd, 'gsd-plan-checker'),
|
||||
|
||||
tdd_mode: options['tdd'] || config.tdd_mode || false,
|
||||
tdd_mode: options['tdd'] || Boolean(wf['tdd_mode']) || false,
|
||||
granularity,
|
||||
research_enabled: config.research,
|
||||
research_enabled: wf['research'],
|
||||
plan_checker_enabled: config.plan_checker,
|
||||
nyquist_validation_enabled: config.nyquist_validation,
|
||||
nyquist_validation_enabled: wf['nyquist_validation'],
|
||||
commit_docs: config.commit_docs,
|
||||
text_mode: config.text_mode,
|
||||
auto_advance: !!(config.auto_advance),
|
||||
@@ -641,13 +645,15 @@ function cmdInitNewMilestone(cwd: string, raw: boolean): void {
|
||||
/* intentionally empty */
|
||||
}
|
||||
|
||||
const wf = (config.workflow ?? {}) as Record<string, unknown>;
|
||||
|
||||
const result: Record<string, unknown> = {
|
||||
researcher_model: resolveModelInternal(cwd, 'gsd-project-researcher'),
|
||||
synthesizer_model: resolveModelInternal(cwd, 'gsd-research-synthesizer'),
|
||||
roadmapper_model: resolveModelInternal(cwd, 'gsd-roadmapper'),
|
||||
|
||||
commit_docs: config.commit_docs,
|
||||
research_enabled: config.research,
|
||||
research_enabled: wf['research'],
|
||||
|
||||
current_milestone: milestone['version'],
|
||||
current_milestone_name: milestone['name'],
|
||||
|
||||
@@ -259,6 +259,8 @@ interface ActiveHook {
|
||||
blocking?: boolean;
|
||||
check?: unknown;
|
||||
onError?: string;
|
||||
/** Resolved capability-owned config values declared in the contribution's configValues map. */
|
||||
configValues?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface ResolveLoopHooksInput {
|
||||
@@ -352,6 +354,47 @@ function resolveLoopHooks(input: ResolveLoopHooksInput): ResolveLoopHooksResult
|
||||
return Object.keys(fragment).length > 0 ? fragment : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve declared configValues for a contribution hook.
|
||||
* The hook may carry `configValues: { alias: "dotted.key", ... }`.
|
||||
* Each key is resolved using the same four-level precedence as activation resolution,
|
||||
* but returning the raw value (not coerced to boolean) so numeric/string config values
|
||||
* are preserved (e.g. security_asvs_level: 2, security_block_on: "medium").
|
||||
*/
|
||||
function resolveConfigValues(hook: RawHook): Record<string, unknown> | undefined {
|
||||
const raw = (hook as Record<string, unknown>)['configValues'];
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined;
|
||||
const rawMap = raw as Record<string, unknown>;
|
||||
const resolved: Record<string, unknown> = {};
|
||||
for (const [alias, dotKey] of Object.entries(rawMap)) {
|
||||
// Prototype-pollution guard (inline literal, CodeQL barrier)
|
||||
if (alias === '__proto__' || alias === 'constructor' || alias === 'prototype') continue;
|
||||
if (typeof dotKey !== 'string') continue;
|
||||
// Level 1: loadConfig result
|
||||
const fromConfig = _getNestedConfigValue(config, dotKey);
|
||||
if (fromConfig.found) { resolved[alias] = fromConfig.value; continue; }
|
||||
// Level 2 + 3: raw config.json files
|
||||
if (cwd) {
|
||||
const wsConfigPath = path.join(planningDir(cwd), 'config.json');
|
||||
const rootConfigPath = path.join(planningRoot(cwd), 'config.json');
|
||||
const fromWs = _readRawConfigKey(wsConfigPath, dotKey);
|
||||
if (fromWs.found) { resolved[alias] = fromWs.value; continue; }
|
||||
if (wsConfigPath !== rootConfigPath) {
|
||||
const fromRoot = _readRawConfigKey(rootConfigPath, dotKey);
|
||||
if (fromRoot.found) { resolved[alias] = fromRoot.value; continue; }
|
||||
}
|
||||
}
|
||||
// Level 4: registry configSchema default
|
||||
const schemaEntry = (registry['configSchema'] as Record<string, unknown> | undefined)?.[dotKey];
|
||||
if (schemaEntry && typeof schemaEntry === 'object' && schemaEntry !== null) {
|
||||
const def = (schemaEntry as Record<string, unknown>)['default'];
|
||||
if (def !== undefined) { resolved[alias] = def; continue; }
|
||||
}
|
||||
// Level 5: absent → undefined (omit from resolved map)
|
||||
}
|
||||
return Object.keys(resolved).length > 0 ? resolved : undefined;
|
||||
}
|
||||
|
||||
// Process steps
|
||||
const stepsRaw = entryMap['steps'];
|
||||
const steps: RawHook[] = Array.isArray(stepsRaw) ? (stepsRaw as RawHook[]) : [];
|
||||
@@ -392,6 +435,7 @@ function resolveLoopHooks(input: ResolveLoopHooksInput): ResolveLoopHooksResult
|
||||
const produces = toStringArray(hook['produces']);
|
||||
const consumes = toStringArray(hook['consumes']);
|
||||
const onError = typeof hook['onError'] === 'string' ? hook['onError'] : undefined;
|
||||
const configValuesResolved = resolveConfigValues(hook);
|
||||
const active: ActiveHook = { capId, kind: 'contribution' };
|
||||
if (into !== undefined) active.into = into;
|
||||
if (fragment !== undefined) active.fragment = fragment;
|
||||
@@ -399,6 +443,7 @@ function resolveLoopHooks(input: ResolveLoopHooksInput): ResolveLoopHooksResult
|
||||
if (produces.length > 0) active.produces = produces;
|
||||
if (consumes.length > 0) active.consumes = consumes;
|
||||
if (onError !== undefined) active.onError = onError;
|
||||
if (configValuesResolved !== undefined) active.configValues = configValuesResolved;
|
||||
activeHooks.push(active);
|
||||
}
|
||||
|
||||
@@ -535,6 +580,13 @@ function renderLoopHooks(resolved: ResolveLoopHooksResult): string {
|
||||
* merged-object-from-untrusted-keys security concern and correctly handles
|
||||
* pre-cutover keys like `workflow.ui_phase` that live in config.json but are not
|
||||
* yet exposed through loadConfig's whitelist.
|
||||
*
|
||||
* --active-cap <capId>: when present, resolves hooks for <point> exactly as the
|
||||
* normal path does, then prints exactly `true` (if any resolved activeHook has
|
||||
* capId === <capId>) or `false` followed by a single newline, and exits 0.
|
||||
* No JSON envelope is emitted — output is clean for shell $(…) capture.
|
||||
* Missing <capId> value → coreError + non-zero exit.
|
||||
* Unknown/inactive capId → `false` (not an error).
|
||||
*/
|
||||
function cmdLoopRenderHooks(
|
||||
cwd: string,
|
||||
@@ -547,6 +599,13 @@ function cmdLoopRenderHooks(
|
||||
return;
|
||||
}
|
||||
|
||||
// --active-cap <capId> mode: emit 'true' or 'false' only (scanner-safe, no JSON envelope)
|
||||
const activeCapId = typeof options['activeCap'] === 'string' ? options['activeCap'] : undefined;
|
||||
if (activeCapId !== undefined && activeCapId === '') {
|
||||
coreError('--active-cap requires a <capId> value (e.g. --active-cap tdd)');
|
||||
return;
|
||||
}
|
||||
|
||||
const runtimeConfigDir = typeof options['configDir'] === 'string'
|
||||
? options['configDir']
|
||||
: undefined;
|
||||
@@ -572,6 +631,13 @@ function cmdLoopRenderHooks(
|
||||
return;
|
||||
}
|
||||
|
||||
// --active-cap mode: print exactly 'true' or 'false' with no envelope
|
||||
if (activeCapId !== undefined) {
|
||||
const isActive = resolved.activeHooks.some((h) => h.capId === activeCapId);
|
||||
process.stdout.write(isActive ? 'true\n' : 'false\n');
|
||||
return;
|
||||
}
|
||||
|
||||
const rendered = renderLoopHooks(resolved);
|
||||
const envelope: {
|
||||
point: string;
|
||||
|
||||
@@ -48,20 +48,6 @@ const claudeToOpencodeTools = {
|
||||
WebSearch: 'websearch', // Plugin/MCP - keep for compatibility
|
||||
};
|
||||
|
||||
// Tool name mapping from Claude Code to Gemini CLI
|
||||
// Gemini CLI uses snake_case built-in tool names
|
||||
const claudeToGeminiTools = {
|
||||
Read: 'read_file',
|
||||
Write: 'write_file',
|
||||
Edit: 'replace',
|
||||
Bash: 'run_shell_command',
|
||||
Glob: 'glob',
|
||||
Grep: 'search_file_content',
|
||||
WebSearch: 'google_web_search',
|
||||
WebFetch: 'web_fetch',
|
||||
TodoWrite: 'write_todos',
|
||||
};
|
||||
|
||||
// Tool name mapping from Claude/GSD agents to Kimi CLI module paths.
|
||||
// Kimi custom agent YAML requires fully-qualified module paths.
|
||||
const claudeToKimiTools = {
|
||||
@@ -109,37 +95,6 @@ function convertToolName(claudeTool) {
|
||||
return claudeTool.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Claude Code tool name to Gemini CLI format
|
||||
* - Applies Claude→Gemini mapping (Read→read_file, Bash→run_shell_command, etc.)
|
||||
* - Filters out MCP tools (mcp__*) — they are auto-discovered at runtime in Gemini
|
||||
* - Filters out Task/Agent — agents are auto-registered as tools in Gemini
|
||||
* @returns {string|null} Gemini tool name, or null if tool should be excluded
|
||||
*/
|
||||
function convertGeminiToolName(claudeTool) {
|
||||
// MCP tools: exclude — auto-discovered from mcpServers config at runtime
|
||||
if (claudeTool.startsWith('mcp__')) {
|
||||
return null;
|
||||
}
|
||||
// Task/Agent: exclude — agents are auto-registered as callable tools.
|
||||
// AskUserQuestion: exclude — Gemini CLI does not expose an ask_user tool;
|
||||
// emitting it causes frontmatter validation errors (#3362).
|
||||
if (
|
||||
claudeTool === 'Task' ||
|
||||
claudeTool === 'Agent' ||
|
||||
claudeTool === 'AskUserQuestion' ||
|
||||
claudeTool === 'ask_user'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
// Check for explicit mapping
|
||||
if (claudeToGeminiTools[claudeTool]) {
|
||||
return claudeToGeminiTools[claudeTool];
|
||||
}
|
||||
// Default: lowercase
|
||||
return claudeTool.toLowerCase();
|
||||
}
|
||||
|
||||
function createKimiToolDiagnostic(reason, tool, source = null) {
|
||||
const isMcp = reason === 'mcp_managed';
|
||||
return {
|
||||
@@ -263,27 +218,6 @@ function replaceRelativePathReference(content, fromPath, toPath) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Claude Code tool name to GitHub Copilot format.
|
||||
* - Applies explicit mapping from claudeToCopilotTools
|
||||
* - Handles mcp__context7__* prefix → io.github.upstash/context7/*
|
||||
* - Falls back to lowercase for unknown tools
|
||||
*/
|
||||
function convertCopilotToolName(claudeTool) {
|
||||
// mcp__context7__* wildcard → io.github.upstash/context7/*
|
||||
if (claudeTool.startsWith('mcp__context7__')) {
|
||||
return 'io.github.upstash/context7/' + claudeTool.slice('mcp__context7__'.length);
|
||||
}
|
||||
// Check explicit mapping
|
||||
if (claudeToCopilotTools[claudeTool]) {
|
||||
return claudeToCopilotTools[claudeTool];
|
||||
}
|
||||
// mcp__{tavily,ref,jina,exa,firecrawl}__* use the generic MCP passthrough like exa/firecrawl;
|
||||
// add explicit Copilot registry mappings when the io.github ids are confirmed (#657 follow-up)
|
||||
// Default: lowercase
|
||||
return claudeTool.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply Copilot-specific content conversion — CONV-06 (paths) + CONV-07 (command names).
|
||||
* Path mappings depend on install mode:
|
||||
@@ -755,39 +689,6 @@ function buildKimiAgentArtifacts({
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Claude agent (.md) to a Copilot agent (.agent.md).
|
||||
* Applies tool mapping + deduplication, formats tools as JSON array.
|
||||
* CONV-04: JSON array format. CONV-05: Tool name mapping.
|
||||
*/
|
||||
function convertClaudeAgentToCopilotAgent(content, isGlobal = false) {
|
||||
const converted = convertClaudeToCopilotContent(content, isGlobal);
|
||||
const { frontmatter, body } = extractFrontmatterAndBody(converted);
|
||||
if (!frontmatter) return converted;
|
||||
|
||||
const name = extractFrontmatterField(frontmatter, 'name') || 'unknown';
|
||||
const description = extractFrontmatterField(frontmatter, 'description') || '';
|
||||
const color = extractFrontmatterField(frontmatter, 'color');
|
||||
const toolsRaw = extractFrontmatterField(frontmatter, 'tools') || '';
|
||||
|
||||
// CONV-04 + CONV-05: Map tools, deduplicate, format as JSON array
|
||||
const claudeTools = toolsRaw.split(',').map(t => t.trim()).filter(Boolean);
|
||||
const mappedTools = claudeTools.map(t => convertCopilotToolName(t));
|
||||
const uniqueTools = [...new Set(mappedTools)];
|
||||
const toolsArray = uniqueTools.length > 0
|
||||
? "['" + uniqueTools.join("', '") + "']"
|
||||
: '[]';
|
||||
|
||||
// Reconstruct frontmatter in Copilot format. Quote description (#2876)
|
||||
// so a leading YAML flow indicator (`[BETA] …`, `{ … }`, etc.) doesn't
|
||||
// crash the Copilot frontmatter loader.
|
||||
let fm = `---\nname: ${name}\ndescription: ${yamlQuote(description)}\ntools: ${toolsArray}\n`;
|
||||
if (color) fm += `color: ${color}\n`;
|
||||
fm += '---';
|
||||
|
||||
return `${fm}\n${body}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply Antigravity-specific content conversion — path replacement + command name conversion.
|
||||
* Path mappings depend on install mode:
|
||||
@@ -841,32 +742,6 @@ function convertClaudeCommandToAntigravitySkill(content, skillName, _runtime = n
|
||||
return `${fm}\n${body}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Claude agent (.md) to an Antigravity agent.
|
||||
* Uses Gemini tool names since Antigravity runs on Gemini 3 backend.
|
||||
*/
|
||||
function convertClaudeAgentToAntigravityAgent(content, isGlobal = false) {
|
||||
const converted = convertClaudeToAntigravityContent(content, isGlobal);
|
||||
const { frontmatter, body } = extractFrontmatterAndBody(converted);
|
||||
if (!frontmatter) return converted;
|
||||
|
||||
const name = extractFrontmatterField(frontmatter, 'name') || 'unknown';
|
||||
const description = extractFrontmatterField(frontmatter, 'description') || '';
|
||||
const color = extractFrontmatterField(frontmatter, 'color');
|
||||
const toolsRaw = extractFrontmatterField(frontmatter, 'tools') || '';
|
||||
|
||||
// Map tools to Gemini equivalents (reuse existing convertGeminiToolName)
|
||||
const claudeTools = toolsRaw.split(',').map(t => t.trim()).filter(Boolean);
|
||||
const mappedTools = claudeTools.map(t => convertGeminiToolName(t)).filter(Boolean);
|
||||
|
||||
// #2876: quote description for the same reason as the skill variant.
|
||||
let fm = `---\nname: ${name}\ndescription: ${yamlQuote(description)}\ntools: ${mappedTools.join(', ')}\n`;
|
||||
if (color) fm += `color: ${color}\n`;
|
||||
fm += '---';
|
||||
|
||||
return `${fm}\n${body}`;
|
||||
}
|
||||
|
||||
function toSingleLine(value) {
|
||||
return value.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
@@ -906,30 +781,6 @@ function extractFrontmatterField(frontmatter, fieldName) {
|
||||
return match[1].trim().replace(/^['"]|['"]$/g, '');
|
||||
}
|
||||
|
||||
// Tool name mapping from Claude Code to Cursor CLI
|
||||
const claudeToCursorTools = {
|
||||
Bash: 'Shell',
|
||||
Edit: 'StrReplace',
|
||||
AskUserQuestion: null, // No direct equivalent — use conversational prompting
|
||||
SlashCommand: null, // No equivalent — skills are auto-discovered
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert a Claude Code tool name to Cursor CLI format
|
||||
* @returns {string|null} Cursor tool name, or null if tool should be excluded
|
||||
*/
|
||||
function convertCursorToolName(claudeTool) {
|
||||
if (claudeTool in claudeToCursorTools) {
|
||||
return claudeToCursorTools[claudeTool];
|
||||
}
|
||||
// MCP tools keep their format (Cursor supports MCP)
|
||||
if (claudeTool.startsWith('mcp__')) {
|
||||
return claudeTool;
|
||||
}
|
||||
// Most tools share the same name (Read, Write, Glob, Grep, Task, WebSearch, WebFetch, TodoWrite)
|
||||
return claudeTool;
|
||||
}
|
||||
|
||||
function convertSlashCommandsToCursorSkillMentions(content) {
|
||||
// Keep leading "/" for slash commands; only normalize gsd: -> gsd-.
|
||||
// This preserves rendered "next step" commands like "/gsd-execute-phase 17".
|
||||
@@ -1025,53 +876,10 @@ function convertClaudeCommandToCursorCommand(content, _commandName) {
|
||||
return body.trimStart();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Claude Code agent markdown to Cursor agent format.
|
||||
* Strips frontmatter fields Cursor doesn't support (color, skills),
|
||||
* converts tool references, and adds a role context header.
|
||||
*/
|
||||
function convertClaudeAgentToCursorAgent(content) {
|
||||
const converted = convertClaudeToCursorMarkdown(content);
|
||||
|
||||
const { frontmatter, body } = extractFrontmatterAndBody(converted);
|
||||
if (!frontmatter) return converted;
|
||||
|
||||
const name = extractFrontmatterField(frontmatter, 'name') || 'unknown';
|
||||
const description = extractFrontmatterField(frontmatter, 'description') || '';
|
||||
|
||||
const cleanFrontmatter = `---\nname: ${yamlIdentifier(name)}\ndescription: ${yamlQuote(toSingleLine(description))}\n---`;
|
||||
|
||||
return `${cleanFrontmatter}\n${body}`;
|
||||
}
|
||||
|
||||
// --- Windsurf converters ---
|
||||
// Windsurf uses a tool set similar to Cursor.
|
||||
// Config lives in .windsurf/ (local) and ~/.codeium/windsurf/ (global).
|
||||
|
||||
// Tool name mapping from Claude Code to Windsurf Cascade
|
||||
const claudeToWindsurfTools = {
|
||||
Bash: 'Shell',
|
||||
Edit: 'StrReplace',
|
||||
AskUserQuestion: null, // No direct equivalent — use conversational prompting
|
||||
SlashCommand: null, // No equivalent — skills are auto-discovered
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert a Claude Code tool name to Windsurf Cascade format
|
||||
* @returns {string|null} Windsurf tool name, or null if tool should be excluded
|
||||
*/
|
||||
function convertWindsurfToolName(claudeTool) {
|
||||
if (claudeTool in claudeToWindsurfTools) {
|
||||
return claudeToWindsurfTools[claudeTool];
|
||||
}
|
||||
// MCP tools keep their format (Windsurf supports MCP)
|
||||
if (claudeTool.startsWith('mcp__')) {
|
||||
return claudeTool;
|
||||
}
|
||||
// Most tools share the same name (Read, Write, Glob, Grep, Task, WebSearch, WebFetch, TodoWrite)
|
||||
return claudeTool;
|
||||
}
|
||||
|
||||
function convertSlashCommandsToWindsurfSkillMentions(content) {
|
||||
// Keep leading "/" for slash commands; only normalize gsd: -> gsd-.
|
||||
return content.replace(/gsd:/gi, 'gsd-');
|
||||
@@ -1153,56 +961,10 @@ function convertClaudeCommandToWindsurfSkill(content, skillName) {
|
||||
return `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\n---\n\n${adapter}\n\n${body.trimStart()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Claude Code agent markdown to Windsurf agent format.
|
||||
* Strips frontmatter fields Windsurf doesn't support (color, skills),
|
||||
* converts tool references, and adds a role context header.
|
||||
*/
|
||||
function convertClaudeAgentToWindsurfAgent(content) {
|
||||
const converted = convertClaudeToWindsurfMarkdown(content);
|
||||
|
||||
const { frontmatter, body } = extractFrontmatterAndBody(converted);
|
||||
if (!frontmatter) return converted;
|
||||
|
||||
const name = extractFrontmatterField(frontmatter, 'name') || 'unknown';
|
||||
const description = extractFrontmatterField(frontmatter, 'description') || '';
|
||||
|
||||
const cleanFrontmatter = `---\nname: ${yamlIdentifier(name)}\ndescription: ${yamlQuote(toSingleLine(description))}\n---`;
|
||||
|
||||
return `${cleanFrontmatter}\n${body}`;
|
||||
}
|
||||
|
||||
// --- Augment converters ---
|
||||
// Augment uses a tool set similar to Cursor/Windsurf.
|
||||
// Config lives in .augment/ (local) and ~/.augment/ (global).
|
||||
|
||||
const claudeToAugmentTools = {
|
||||
Bash: 'launch-process',
|
||||
Edit: 'str-replace-editor',
|
||||
AskUserQuestion: null,
|
||||
SlashCommand: null,
|
||||
TodoWrite: 'add_tasks',
|
||||
};
|
||||
|
||||
function convertAugmentToolName(claudeTool) {
|
||||
if (claudeTool in claudeToAugmentTools) {
|
||||
return claudeToAugmentTools[claudeTool];
|
||||
}
|
||||
if (claudeTool.startsWith('mcp__')) {
|
||||
return claudeTool;
|
||||
}
|
||||
const toolMapping = {
|
||||
Read: 'view',
|
||||
Write: 'save-file',
|
||||
Glob: 'view',
|
||||
Grep: 'grep',
|
||||
Task: null,
|
||||
WebSearch: 'web-search',
|
||||
WebFetch: 'web-fetch',
|
||||
};
|
||||
return toolMapping[claudeTool] || claudeTool;
|
||||
}
|
||||
|
||||
function convertSlashCommandsToAugmentSkillMentions(content) {
|
||||
return content.replace(/gsd:/gi, 'gsd-');
|
||||
}
|
||||
@@ -1279,30 +1041,6 @@ function convertClaudeCommandToAugmentSkill(content, skillName) {
|
||||
return `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\n---\n\n${adapter}\n\n${body.trimStart()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Claude Code agent markdown to Augment agent format.
|
||||
* Strips frontmatter fields Augment doesn't support (color, skills),
|
||||
* converts tool references, and cleans up for Augment agents.
|
||||
*/
|
||||
function convertClaudeAgentToAugmentAgent(content) {
|
||||
const converted = convertClaudeToAugmentMarkdown(content);
|
||||
|
||||
const { frontmatter, body } = extractFrontmatterAndBody(converted);
|
||||
if (!frontmatter) return converted;
|
||||
|
||||
const name = extractFrontmatterField(frontmatter, 'name') || 'unknown';
|
||||
const description = extractFrontmatterField(frontmatter, 'description') || '';
|
||||
|
||||
const cleanFrontmatter = `---\nname: ${yamlIdentifier(name)}\ndescription: ${yamlQuote(toSingleLine(description))}\n---`;
|
||||
|
||||
return `${cleanFrontmatter}\n${body}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy Claude commands as Augment skills — one folder per skill with SKILL.md.
|
||||
* Mirrors copyCommandsAsCursorSkills but uses Augment converters.
|
||||
*/
|
||||
|
||||
function convertSlashCommandsToTraeSkillMentions(content) {
|
||||
return content.replace(/\/gsd:([a-z0-9-]+)/g, (_, commandName) => {
|
||||
return `/gsd-${commandName}`;
|
||||
@@ -1352,20 +1090,6 @@ function convertClaudeCommandToTraeSkill(content, skillName) {
|
||||
return `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\n---\n${body}`;
|
||||
}
|
||||
|
||||
function convertClaudeAgentToTraeAgent(content) {
|
||||
const converted = convertClaudeToTraeMarkdown(content);
|
||||
|
||||
const { frontmatter, body } = extractFrontmatterAndBody(converted);
|
||||
if (!frontmatter) return converted;
|
||||
|
||||
const name = extractFrontmatterField(frontmatter, 'name') || 'unknown';
|
||||
const description = extractFrontmatterField(frontmatter, 'description') || '';
|
||||
|
||||
const cleanFrontmatter = `---\nname: ${yamlIdentifier(name)}\ndescription: ${yamlQuote(toSingleLine(description))}\n---`;
|
||||
|
||||
return `${cleanFrontmatter}\n${body}`;
|
||||
}
|
||||
|
||||
function convertSlashCommandsToCodebuddySkillMentions(content) {
|
||||
return content.replace(/\/gsd:([a-z0-9-]+)/g, (_, commandName) => {
|
||||
return `/gsd-${commandName}`;
|
||||
@@ -1447,20 +1171,6 @@ function convertClaudeCommandToCodebuddyCommand(content, commandName) {
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function convertClaudeAgentToCodebuddyAgent(content) {
|
||||
const converted = convertClaudeToCodebuddyMarkdown(content);
|
||||
|
||||
const { frontmatter, body } = extractFrontmatterAndBody(converted);
|
||||
if (!frontmatter) return converted;
|
||||
|
||||
const name = extractFrontmatterField(frontmatter, 'name') || 'unknown';
|
||||
const description = extractFrontmatterField(frontmatter, 'description') || '';
|
||||
|
||||
const cleanFrontmatter = `---\nname: ${yamlIdentifier(name)}\ndescription: ${yamlQuote(toSingleLine(description))}\n---`;
|
||||
|
||||
return `${cleanFrontmatter}\n${body}`;
|
||||
}
|
||||
|
||||
// ── Cline converters ────────────────────────────────────────────────────────
|
||||
|
||||
function convertClaudeToCliineMarkdown(content) {
|
||||
@@ -1485,16 +1195,6 @@ function convertClaudeToCliineMarkdown(content) {
|
||||
return converted;
|
||||
}
|
||||
|
||||
function convertClaudeAgentToClineAgent(content) {
|
||||
const converted = convertClaudeToCliineMarkdown(content);
|
||||
const { frontmatter, body } = extractFrontmatterAndBody(converted);
|
||||
if (!frontmatter) return converted;
|
||||
const name = extractFrontmatterField(frontmatter, 'name') || 'unknown';
|
||||
const description = extractFrontmatterField(frontmatter, 'description') || '';
|
||||
const cleanFrontmatter = `---\nname: ${yamlIdentifier(name)}\ndescription: ${yamlQuote(toSingleLine(description))}\n---`;
|
||||
return `${cleanFrontmatter}\n${body}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Claude command (.md) to a Cline skill (SKILL.md).
|
||||
* Emits ONLY name + description frontmatter per the Cline skills spec
|
||||
@@ -1505,7 +1205,7 @@ function convertClaudeAgentToClineAgent(content) {
|
||||
* Cline uses Claude-Code-compatible tool names, so no adapter header is needed.
|
||||
* Targets ~/.cline/skills/<name>/SKILL.md for Cline >= v3.48.0.
|
||||
*/
|
||||
function convertClaudeCommandToClineSkill(content, skillName, runtime = null, cmdNames = null) {
|
||||
function convertClaudeCommandToClineSkill(content, skillName, _runtime = null, cmdNames = null) {
|
||||
const { frontmatter, body } = extractFrontmatterAndBody(content);
|
||||
if (!frontmatter) return content;
|
||||
|
||||
|
||||
@@ -1659,7 +1659,7 @@ function cmdVerifySchemaDrift(
|
||||
const pDir = planningDir(cwd);
|
||||
const phasesDir = path.join(pDir, 'phases');
|
||||
if (!fs.existsSync(phasesDir)) {
|
||||
output({ drift_detected: false, blocking: false, message: 'No phases directory' }, raw);
|
||||
output({ block: false, drift_detected: false, blocking: false, message: 'No phases directory' }, raw);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1679,7 +1679,7 @@ function cmdVerifySchemaDrift(
|
||||
|
||||
if (!phaseDir) {
|
||||
output(
|
||||
{ drift_detected: false, blocking: false, message: `Phase directory not found: ${phaseArg}` },
|
||||
{ block: false, drift_detected: false, blocking: false, message: `Phase directory not found: ${phaseArg}` },
|
||||
raw,
|
||||
);
|
||||
return;
|
||||
@@ -1709,15 +1709,21 @@ function cmdVerifySchemaDrift(
|
||||
|
||||
const result = checkSchemaDrift(allFiles, executionLog, { skipCheck: !!skipFlag }) as unknown as Record<string, unknown>;
|
||||
|
||||
const isSkipped = !!result['skipped'];
|
||||
output(
|
||||
{
|
||||
// Uniform gate contract: `block` = true means "this gate's bad condition is met".
|
||||
// When skipCheck is true (GSD_SKIP_SCHEMA_CHECK=true), the gate is bypassed —
|
||||
// block must be false regardless of whether drift was detected.
|
||||
// drift_detected and blocking are kept for compatibility.
|
||||
block: isSkipped ? false : !!result['driftDetected'],
|
||||
drift_detected: result['driftDetected'],
|
||||
blocking: result['blocking'],
|
||||
schema_files: result['schemaFiles'],
|
||||
orms: result['orms'],
|
||||
unpushed_orms: result['unpushedOrms'],
|
||||
message: result['message'],
|
||||
skipped: result['skipped'] || false,
|
||||
skipped: isSkipped,
|
||||
},
|
||||
raw,
|
||||
);
|
||||
@@ -1735,6 +1741,8 @@ function cmdVerifyCodebaseDrift(cwd: string, raw: boolean): void {
|
||||
const structurePath = path.join(codebaseDir, 'STRUCTURE.md');
|
||||
if (!fs.existsSync(structurePath)) {
|
||||
emit({
|
||||
// Uniform gate contract: block = action_required (false when skipped).
|
||||
block: false,
|
||||
skipped: true,
|
||||
reason: 'no-structure-md',
|
||||
action_required: false,
|
||||
@@ -1749,6 +1757,7 @@ function cmdVerifyCodebaseDrift(cwd: string, raw: boolean): void {
|
||||
structureMd = fs.readFileSync(structurePath, 'utf-8');
|
||||
} catch (err) {
|
||||
emit({
|
||||
block: false,
|
||||
skipped: true,
|
||||
reason: 'cannot-read-structure-md: ' + (err instanceof Error ? err.message : String(err)),
|
||||
action_required: false,
|
||||
@@ -1763,6 +1772,7 @@ function cmdVerifyCodebaseDrift(cwd: string, raw: boolean): void {
|
||||
const revProbe = execGit(['rev-parse', 'HEAD'], { cwd }) as unknown as { exitCode: number; stdout: string };
|
||||
if (revProbe.exitCode !== 0) {
|
||||
emit({
|
||||
block: false,
|
||||
skipped: true,
|
||||
reason: 'not-a-git-repo',
|
||||
action_required: false,
|
||||
@@ -1784,6 +1794,7 @@ function cmdVerifyCodebaseDrift(cwd: string, raw: boolean): void {
|
||||
const diff = execGit(['diff', '--name-status', base, 'HEAD'], { cwd }) as unknown as { exitCode: number; stdout: string };
|
||||
if (diff.exitCode !== 0) {
|
||||
emit({
|
||||
block: false,
|
||||
skipped: true,
|
||||
reason: 'git-diff-failed',
|
||||
action_required: false,
|
||||
@@ -1825,10 +1836,13 @@ function cmdVerifyCodebaseDrift(cwd: string, raw: boolean): void {
|
||||
runtime: resolveRuntime(cwd),
|
||||
});
|
||||
|
||||
const actionRequired = !!driftResult['actionRequired'];
|
||||
emit({
|
||||
// Uniform gate contract: block = action_required.
|
||||
block: actionRequired,
|
||||
skipped: !!driftResult['skipped'],
|
||||
reason: driftResult['reason'] || null,
|
||||
action_required: !!driftResult['actionRequired'],
|
||||
action_required: actionRequired,
|
||||
directive: driftResult['directive'],
|
||||
spawn_mapper: !!driftResult['spawnMapper'],
|
||||
affected_paths: driftResult['affectedPaths'] || [],
|
||||
@@ -1840,6 +1854,7 @@ function cmdVerifyCodebaseDrift(cwd: string, raw: boolean): void {
|
||||
});
|
||||
} catch (err) {
|
||||
emit({
|
||||
block: false,
|
||||
skipped: true,
|
||||
reason: 'exception: ' + (err && err instanceof Error ? err.message : String(err)),
|
||||
action_required: false,
|
||||
|
||||
261
tests/adr857-contribution-merge.test.cjs
Normal file
261
tests/adr857-contribution-merge.test.cjs
Normal file
@@ -0,0 +1,261 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* adr857-contribution-merge.test.cjs — behavioral tests for ADR-857 deliverable J:
|
||||
* "Contribution merge" — multiple contributions at one point compose by ordered
|
||||
* concatenation in produces/consumes topological order (capId tiebreak), each
|
||||
* wrapped in <contribution from="<capability-id>">...</contribution>.
|
||||
*
|
||||
* Tests use synthetic registries built in-test plus the real
|
||||
* resolveLoopHooks/renderLoopHooks pure functions.
|
||||
*/
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const {
|
||||
resolveLoopHooks,
|
||||
renderLoopHooks,
|
||||
} = require('../gsd-core/bin/lib/loop-resolver.cjs');
|
||||
|
||||
const {
|
||||
buildRegistry,
|
||||
} = require('../scripts/gen-capability-registry.cjs');
|
||||
|
||||
// ─── Synthetic registry builder helpers ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build a capability map entry for use with buildRegistry().
|
||||
* Minimal valid shape mirroring the cycle-test fixtures in capability-registry.test.cjs.
|
||||
*/
|
||||
function makeCapEntry(id, contributions) {
|
||||
return {
|
||||
id,
|
||||
role: 'feature',
|
||||
title: id,
|
||||
tier: 'full',
|
||||
requires: [],
|
||||
skills: [],
|
||||
agents: [],
|
||||
hooks: [],
|
||||
config: {},
|
||||
steps: [],
|
||||
contributions,
|
||||
gates: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a compiled registry via buildRegistry() from a list of simple contribution
|
||||
* descriptors. Each descriptor: { capId, fragment, produces, consumes, into }.
|
||||
* buildRegistry applies the produces/consumes topo sort + capId tiebreak so the
|
||||
* resulting registry.byLoopPoint['plan:pre'].contributions are already sorted.
|
||||
*/
|
||||
function makeContribRegistry(contribs) {
|
||||
const capMap = new Map(contribs.map(c => [
|
||||
c.capId,
|
||||
makeCapEntry(c.capId, [{
|
||||
point: 'plan:pre',
|
||||
into: c.into ?? 'planner',
|
||||
fragment: c.fragment ?? { inline: `Content from ${c.capId}.` },
|
||||
produces: c.produces ?? [],
|
||||
consumes: c.consumes ?? [],
|
||||
onError: 'skip',
|
||||
}]),
|
||||
]));
|
||||
return buildRegistry(capMap);
|
||||
}
|
||||
|
||||
// ─── 1. Happy path: topological ordering (produces → consumes dependency) ─────
|
||||
|
||||
describe('ADR-857 deliverable J: contribution merge ordering', () => {
|
||||
test(
|
||||
'[happy] two contributions at plan:pre where cap-a produces and cap-b consumes: ' +
|
||||
'resolveLoopHooks orders cap-a before cap-b',
|
||||
() => {
|
||||
// cap-b consumes what cap-a produces → cap-a must come first
|
||||
const registry = makeContribRegistry([
|
||||
// Deliberately listed in reverse dependency order to prove sorting happens
|
||||
{ capId: 'cap-b', produces: [], consumes: ['A.md'], fragment: { inline: 'Consume A.md here.' } },
|
||||
{ capId: 'cap-a', produces: ['A.md'], consumes: [], fragment: { inline: 'Produce A.md here.' } },
|
||||
]);
|
||||
|
||||
const resolved = resolveLoopHooks({ point: 'plan:pre', registry, config: {} });
|
||||
|
||||
assert.strictEqual(resolved.activeHooks.length, 2, 'Both contributions must be active');
|
||||
|
||||
const capIds = resolved.activeHooks.map(h => h.capId);
|
||||
assert.strictEqual(capIds[0], 'cap-a', 'cap-a (producer) must appear first');
|
||||
assert.strictEqual(capIds[1], 'cap-b', 'cap-b (consumer) must appear second');
|
||||
|
||||
// Both must be kind=contribution
|
||||
for (const hook of resolved.activeHooks) {
|
||||
assert.strictEqual(hook.kind, 'contribution', 'Every active hook must have kind=contribution');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'[happy] renderLoopHooks produces TWO separate <contribution from=...> blocks, ' +
|
||||
'cap-a block before cap-b block, each independently opened and closed',
|
||||
() => {
|
||||
const registry = makeContribRegistry([
|
||||
{ capId: 'cap-b', produces: [], consumes: ['A.md'], fragment: { inline: 'Consumer block body.' } },
|
||||
{ capId: 'cap-a', produces: ['A.md'], consumes: [], fragment: { inline: 'Producer block body.' } },
|
||||
]);
|
||||
|
||||
const resolved = resolveLoopHooks({ point: 'plan:pre', registry, config: {} });
|
||||
const rendered = renderLoopHooks(resolved);
|
||||
|
||||
// Two separate opening tags must be present
|
||||
const openA = `<contribution from="cap-a"`;
|
||||
const openB = `<contribution from="cap-b"`;
|
||||
assert.ok(rendered.includes(openA), `rendered must contain opening tag for cap-a. Got:\n${rendered}`);
|
||||
assert.ok(rendered.includes(openB), `rendered must contain opening tag for cap-b. Got:\n${rendered}`);
|
||||
|
||||
// Two separate closing tags
|
||||
const closes = rendered.split('</contribution>');
|
||||
// splitting by </contribution> gives N+1 parts for N occurrences
|
||||
assert.ok(closes.length >= 3, `rendered must contain at least two </contribution> close tags. Got:\n${rendered}`);
|
||||
|
||||
// cap-a's block must appear before cap-b's block in document order
|
||||
const posA = rendered.indexOf(openA);
|
||||
const posB = rendered.indexOf(openB);
|
||||
assert.ok(posA < posB, `cap-a opening tag must appear before cap-b opening tag. posA=${posA}, posB=${posB}`);
|
||||
|
||||
// No merged or nested blocks — each cap's open tag must be closed before the other cap's open tag
|
||||
// i.e. the first close tag must appear after posA and before posB
|
||||
const firstClose = rendered.indexOf('</contribution>');
|
||||
assert.ok(
|
||||
firstClose > posA && firstClose < posB,
|
||||
`First </contribution> must close cap-a before cap-b opens. firstClose=${firstClose}, posA=${posA}, posB=${posB}`,
|
||||
);
|
||||
|
||||
// Fragment body content appears inside respective blocks
|
||||
assert.ok(rendered.includes('Producer block body.'), 'cap-a fragment body must appear in rendered output');
|
||||
assert.ok(rendered.includes('Consumer block body.'), 'cap-b fragment body must appear in rendered output');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// ─── 2. BVA: no produces/consumes dependency → capId tiebreak ────────────────
|
||||
|
||||
describe('ADR-857 deliverable J: capId tiebreak ordering', () => {
|
||||
test(
|
||||
'[BVA] two contributions with NO produces/consumes dependency: ' +
|
||||
'capId alphabetical tiebreak ensures a-contrib renders before z-contrib',
|
||||
() => {
|
||||
// Neither cap produces/consumes anything → tiebreak by capId
|
||||
const registry = makeContribRegistry([
|
||||
// Reverse alpha order in input to prove sort is applied
|
||||
{ capId: 'z-contrib', produces: [], consumes: [], fragment: { inline: 'Z content.' } },
|
||||
{ capId: 'a-contrib', produces: [], consumes: [], fragment: { inline: 'A content.' } },
|
||||
]);
|
||||
|
||||
const resolved = resolveLoopHooks({ point: 'plan:pre', registry, config: {} });
|
||||
|
||||
assert.strictEqual(resolved.activeHooks.length, 2, 'Both contributions must be active');
|
||||
|
||||
const capIds = resolved.activeHooks.map(h => h.capId);
|
||||
assert.strictEqual(capIds[0], 'a-contrib', 'a-contrib must appear first (alphabetical tiebreak)');
|
||||
assert.strictEqual(capIds[1], 'z-contrib', 'z-contrib must appear second (alphabetical tiebreak)');
|
||||
|
||||
// Render and confirm document order matches sort order
|
||||
const rendered = renderLoopHooks(resolved);
|
||||
const posA = rendered.indexOf('<contribution from="a-contrib"');
|
||||
const posZ = rendered.indexOf('<contribution from="z-contrib"');
|
||||
assert.ok(posA >= 0, 'a-contrib opening tag must be present in rendered output');
|
||||
assert.ok(posZ >= 0, 'z-contrib opening tag must be present in rendered output');
|
||||
assert.ok(posA < posZ, `a-contrib must render before z-contrib. posA=${posA}, posZ=${posZ}`);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// ─── 3. Happy path: provenance — each block's from= matches its capId ─────────
|
||||
|
||||
describe('ADR-857 deliverable J: contribution provenance', () => {
|
||||
test(
|
||||
'[happy] each block from= attribute matches its capId; the two from= values differ',
|
||||
() => {
|
||||
const registry = makeContribRegistry([
|
||||
{ capId: 'feature-alpha', produces: ['ALPHA.md'], consumes: [], fragment: { inline: 'Alpha content.' } },
|
||||
{ capId: 'feature-beta', produces: [], consumes: ['ALPHA.md'], fragment: { inline: 'Beta content.' } },
|
||||
]);
|
||||
|
||||
const resolved = resolveLoopHooks({ point: 'plan:pre', registry, config: {} });
|
||||
const rendered = renderLoopHooks(resolved);
|
||||
|
||||
// Both capIds appear as from= values
|
||||
assert.ok(
|
||||
rendered.includes('from="feature-alpha"'),
|
||||
'rendered must contain from="feature-alpha"',
|
||||
);
|
||||
assert.ok(
|
||||
rendered.includes('from="feature-beta"'),
|
||||
'rendered must contain from="feature-beta"',
|
||||
);
|
||||
|
||||
// The two from= values are distinct (they differ from each other)
|
||||
const fromAlpha = 'from="feature-alpha"';
|
||||
const fromBeta = 'from="feature-beta"';
|
||||
assert.notEqual(fromAlpha, fromBeta, 'the two from= attribute strings must differ');
|
||||
|
||||
// activeHooks provenance: each hook's capId matches what it will render as
|
||||
const [first, second] = resolved.activeHooks;
|
||||
assert.strictEqual(first.capId, 'feature-alpha', 'first hook capId must be feature-alpha');
|
||||
assert.strictEqual(second.capId, 'feature-beta', 'second hook capId must be feature-beta');
|
||||
assert.notEqual(first.capId, second.capId, 'the two capIds must differ from each other');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// ─── 4. Negative: produces/consumes cycle → buildRegistry throws ──────────────
|
||||
|
||||
describe('ADR-857 deliverable J: contribution cycle detection', () => {
|
||||
test(
|
||||
'[negative] a produces/consumes cycle among contributions at one point ' +
|
||||
'causes buildRegistry to throw an error mentioning "cycle"',
|
||||
() => {
|
||||
// cap-a produces A.md and consumes B.md
|
||||
// cap-b produces B.md and consumes A.md
|
||||
// → mutual dependency cycle
|
||||
const capMap = new Map([
|
||||
['cap-a', makeCapEntry('cap-a', [{
|
||||
point: 'plan:pre',
|
||||
into: 'planner',
|
||||
fragment: { inline: 'A fragment.' },
|
||||
produces: ['A.md'],
|
||||
consumes: ['B.md'],
|
||||
onError: 'skip',
|
||||
}])],
|
||||
['cap-b', makeCapEntry('cap-b', [{
|
||||
point: 'plan:pre',
|
||||
into: 'planner',
|
||||
fragment: { inline: 'B fragment.' },
|
||||
produces: ['B.md'],
|
||||
consumes: ['A.md'],
|
||||
onError: 'skip',
|
||||
}])],
|
||||
]);
|
||||
|
||||
assert.throws(
|
||||
() => buildRegistry(capMap),
|
||||
(err) => {
|
||||
assert.ok(err instanceof Error, `Expected Error, got: ${Object.prototype.toString.call(err)}`);
|
||||
assert.match(
|
||||
err.message,
|
||||
/cycle/i,
|
||||
`Error message must mention "cycle". Got: "${err.message}"`,
|
||||
);
|
||||
// Must also reference contributions (not just steps)
|
||||
assert.match(
|
||||
err.message,
|
||||
/contribution/i,
|
||||
`Error message must mention "contribution". Got: "${err.message}"`,
|
||||
);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
554
tests/adr857-core-without-capabilities.test.cjs
Normal file
554
tests/adr857-core-without-capabilities.test.cjs
Normal file
@@ -0,0 +1,554 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* adr857-core-without-capabilities.test.cjs
|
||||
*
|
||||
* ADR-857 deliverable B — "the core loop ships and runs without any plug-in"
|
||||
* (Consequences, §"Positive": "The core loop ships and runs without any plug-in;
|
||||
* plan-phase.md/execute-phase.md shrink to the irreducible five steps.")
|
||||
*
|
||||
* Verified contracts:
|
||||
* B1. All 12 canonical loop points return activeHooks:[] when every
|
||||
* capability when-key is explicitly false (real registry, all-caps-off config).
|
||||
* B2. The CLI `loop render-hooks <point>` exits 0 and emits activeHooks:[],
|
||||
* placeholder rendered for representative points with all-caps-off config.
|
||||
* B3. Init bundles for the 5-step loop's entry seam (plan-phase, execute-phase,
|
||||
* verify-work) resolve with exit 0 and valid JSON when capabilities are off.
|
||||
* B4. An EMPTY registry (byLoopPoint:{}) at all 12 points → activeHooks:[]
|
||||
* (loop tolerates a capability-less install).
|
||||
* B5. [BVA] Exactly one capability ON (tdd_mode) → that capability's points
|
||||
* non-empty, all OTHER points still empty (caps are additive; core is baseline).
|
||||
*
|
||||
* RULESET: no readFileSync + .includes() on source files (source-grep ban).
|
||||
* All assertions drive real exported functions / subprocess and inspect typed results.
|
||||
*/
|
||||
|
||||
const { describe, test, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
// ── Module under test ─────────────────────────────────────────────────────────
|
||||
|
||||
const {
|
||||
resolveLoopHooks,
|
||||
renderLoopHooks,
|
||||
CANONICAL_POINTS,
|
||||
} = require('../gsd-core/bin/lib/loop-resolver.cjs');
|
||||
|
||||
// Real registry (compiled from capabilities/ at build time)
|
||||
const realRegistry = require('../gsd-core/bin/lib/capability-registry.cjs');
|
||||
|
||||
// ── Helpers from test harness ─────────────────────────────────────────────────
|
||||
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
// ── Paths ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const GSD_TOOLS = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
|
||||
|
||||
// ── Config fixtures ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* All 12 canonical loop points. Derived from the exported constant so the
|
||||
* assertion set cannot drift from the resolver's own authoritative list.
|
||||
*/
|
||||
const ALL_12_POINTS = [...CANONICAL_POINTS];
|
||||
|
||||
/**
|
||||
* All when-keys discovered from the real registry, set to false.
|
||||
* Built by scanning every hook in every loop point's steps/contributions/gates arrays.
|
||||
* This gives us a "caps-off" config that passes through the activation resolver as
|
||||
* explicitly false rather than relying on missing-key default behaviour.
|
||||
*
|
||||
* Structure: nested (workflow.* → workflow:{...}, intel.enabled → intel:{enabled:false})
|
||||
* because _getNestedConfigValue expects a nested object, not a flat dotted key.
|
||||
*/
|
||||
function buildAllFalseConfig() {
|
||||
const workflow = {};
|
||||
const intel = {};
|
||||
for (const point of ALL_12_POINTS) {
|
||||
const entry = realRegistry.byLoopPoint[point];
|
||||
if (!entry) continue;
|
||||
for (const kind of ['steps', 'contributions', 'gates']) {
|
||||
for (const hook of entry[kind] || []) {
|
||||
const when = hook.when;
|
||||
if (typeof when !== 'string' || !when) continue;
|
||||
if (when.startsWith('workflow.')) {
|
||||
const key = when.slice('workflow.'.length);
|
||||
workflow[key] = false;
|
||||
} else if (when === 'intel.enabled') {
|
||||
intel.enabled = false;
|
||||
}
|
||||
// Any future top-level keys would need extending here.
|
||||
}
|
||||
}
|
||||
}
|
||||
return { workflow, intel };
|
||||
}
|
||||
|
||||
const ALL_FALSE_CONFIG = buildAllFalseConfig();
|
||||
|
||||
/**
|
||||
* All-false config with tdd_mode: true.
|
||||
* Only workflow.tdd_mode differs from ALL_FALSE_CONFIG.
|
||||
*/
|
||||
function buildTddOnlyConfig() {
|
||||
return {
|
||||
...ALL_FALSE_CONFIG,
|
||||
workflow: { ...ALL_FALSE_CONFIG.workflow, tdd_mode: true },
|
||||
};
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Run gsd-tools subprocess and return { exitCode, output }.
|
||||
* Does NOT throw on non-zero exit — let the test assert.
|
||||
*/
|
||||
function runCli(args, cwd) {
|
||||
try {
|
||||
const stdout = execFileSync(process.execPath, [GSD_TOOLS, ...args], {
|
||||
cwd,
|
||||
encoding: 'utf-8',
|
||||
timeout: 30000,
|
||||
});
|
||||
return { exitCode: 0, output: stdout.trim() };
|
||||
} catch (err) {
|
||||
return {
|
||||
exitCode: err.status ?? 1,
|
||||
output: err.stdout?.toString().trim() ?? '',
|
||||
error: err.stderr?.toString().trim() ?? '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Create a temp project dir with a .planning/ sub-dir. */
|
||||
function makeProject(configJson = null) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'adr857-b-'));
|
||||
const planning = path.join(dir, '.planning');
|
||||
fs.mkdirSync(planning, { recursive: true });
|
||||
if (configJson !== null) {
|
||||
fs.writeFileSync(path.join(planning, 'config.json'), JSON.stringify(configJson), 'utf8');
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
/** Remove a temp dir safely. */
|
||||
function removeTmp(dir) {
|
||||
if (dir) cleanup(dir);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// B1. [happy/aggregate] All 12 points → activeHooks:[] with all-caps-off config
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('B1 — real registry, all-caps-off config: every canonical point resolves to activeHooks:[]', () => {
|
||||
test('all 12 CANONICAL_POINTS return activeHooks:[] simultaneously when every capability when-key is false', () => {
|
||||
const failures = [];
|
||||
for (const point of ALL_12_POINTS) {
|
||||
const result = resolveLoopHooks({
|
||||
point,
|
||||
registry: realRegistry,
|
||||
config: ALL_FALSE_CONFIG,
|
||||
});
|
||||
|
||||
// Shape guard — result must be an object with an array
|
||||
assert.ok(result && typeof result === 'object', `${point}: result must be an object`);
|
||||
assert.ok(Array.isArray(result.activeHooks), `${point}: activeHooks must be an array`);
|
||||
|
||||
if (result.activeHooks.length !== 0) {
|
||||
failures.push({
|
||||
point,
|
||||
count: result.activeHooks.length,
|
||||
capIds: result.activeHooks.map(h => h.capId),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Genuine assertion: if any point has activeHooks, report them concretely.
|
||||
// This fails on regression to the specific wrong value, not just "not empty".
|
||||
assert.deepStrictEqual(
|
||||
failures,
|
||||
[],
|
||||
`Expected zero active hooks at all 12 points with all-caps-off config but got: ${JSON.stringify(failures)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('CANONICAL_POINTS exports exactly 12 points', () => {
|
||||
assert.strictEqual(
|
||||
ALL_12_POINTS.length,
|
||||
12,
|
||||
`CANONICAL_POINTS must have 12 entries (ADR-857 §"Loop Extension Points (the 12)"), got ${ALL_12_POINTS.length}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('each of the 12 known point names is present in CANONICAL_POINTS', () => {
|
||||
const expected = [
|
||||
'discuss:pre', 'discuss:post',
|
||||
'plan:pre', 'plan:post',
|
||||
'execute:pre', 'execute:wave:pre', 'execute:wave:post', 'execute:post',
|
||||
'verify:pre', 'verify:post',
|
||||
'ship:pre', 'ship:post',
|
||||
];
|
||||
for (const p of expected) {
|
||||
assert.ok(
|
||||
ALL_12_POINTS.includes(p),
|
||||
`Expected canonical point "${p}" to be in CANONICAL_POINTS`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// B2. [happy] CLI render-hooks E2E: exit 0, activeHooks:[], placeholder rendered
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('B2 — CLI loop render-hooks: exit 0, activeHooks:[], placeholder for all-caps-off project', () => {
|
||||
let tmpDir;
|
||||
|
||||
before(() => {
|
||||
tmpDir = makeProject(ALL_FALSE_CONFIG);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
removeTmp(tmpDir);
|
||||
tmpDir = null;
|
||||
});
|
||||
|
||||
for (const point of ['plan:pre', 'execute:wave:post', 'ship:post']) {
|
||||
test(`loop render-hooks ${point} → exit 0, activeHooks:[], non-empty rendered placeholder`, () => {
|
||||
const { exitCode, output, error } = runCli(['loop', 'render-hooks', point], tmpDir);
|
||||
|
||||
assert.strictEqual(
|
||||
exitCode,
|
||||
0,
|
||||
`Expected exit 0 for "loop render-hooks ${point}" with all-caps-off config; got ${exitCode}. stderr: ${error ?? ''}`,
|
||||
);
|
||||
|
||||
// Must parse as JSON
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(output);
|
||||
} catch (e) {
|
||||
assert.fail(`CLI output for ${point} is not valid JSON: ${output.slice(0, 200)}`);
|
||||
}
|
||||
|
||||
// activeHooks must be present and empty
|
||||
assert.ok(
|
||||
Array.isArray(parsed.activeHooks),
|
||||
`${point}: activeHooks must be an array`,
|
||||
);
|
||||
assert.strictEqual(
|
||||
parsed.activeHooks.length,
|
||||
0,
|
||||
`${point}: expected activeHooks:[] with all-caps-off config, got ${JSON.stringify(parsed.activeHooks)}`,
|
||||
);
|
||||
|
||||
// rendered field must be a non-empty placeholder string (loop still renders output)
|
||||
assert.ok(
|
||||
typeof parsed.rendered === 'string' && parsed.rendered.length > 0,
|
||||
`${point}: rendered must be a non-empty string, got ${JSON.stringify(parsed.rendered)}`,
|
||||
);
|
||||
|
||||
// Genuine assertion: the placeholder contains the point name so it doesn't silently
|
||||
// return a generic empty string detached from the requested point.
|
||||
assert.ok(
|
||||
parsed.rendered.includes(point),
|
||||
`${point}: rendered placeholder must reference the point name "${point}", got: "${parsed.rendered}"`,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// B3. [happy] Init bundles resolve with exit 0 and valid JSON with caps off
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('B3 — init bundles for 5-step loop entry seam: exit 0 and valid JSON with capabilities off', () => {
|
||||
// Cases: the 5-step loop's main init entry points (those available without git)
|
||||
const INIT_CASES = [
|
||||
{
|
||||
label: 'init plan-phase',
|
||||
args: ['init', 'plan-phase', '--phase', '01-stub'],
|
||||
// Required fields that prove the bundle is a real JSON object used by the loop
|
||||
requiredFields: ['tdd_mode', 'phase_found', 'planning_exists'],
|
||||
},
|
||||
{
|
||||
label: 'init execute-phase',
|
||||
args: ['init', 'execute-phase', '--phase', '01-stub'],
|
||||
requiredFields: ['tdd_mode', 'phase_found', 'config_exists'],
|
||||
},
|
||||
{
|
||||
label: 'init verify-work',
|
||||
args: ['init', 'verify-work', '--phase', '01-stub'],
|
||||
requiredFields: ['phase_found', 'commit_docs'],
|
||||
},
|
||||
];
|
||||
|
||||
for (const { label, args, requiredFields } of INIT_CASES) {
|
||||
describe(label, () => {
|
||||
let tmpDir;
|
||||
|
||||
before(() => {
|
||||
// Bare project with .planning/ but all caps off in config
|
||||
tmpDir = makeProject(ALL_FALSE_CONFIG);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
removeTmp(tmpDir);
|
||||
tmpDir = null;
|
||||
});
|
||||
|
||||
test(`${label} exits 0 with capabilities off`, () => {
|
||||
const { exitCode, error } = runCli(args, tmpDir);
|
||||
assert.strictEqual(
|
||||
exitCode,
|
||||
0,
|
||||
`${label}: expected exit 0 with all-caps-off project, got ${exitCode}. stderr: ${error ?? ''}`,
|
||||
);
|
||||
});
|
||||
|
||||
test(`${label} returns parseable JSON with expected fields`, () => {
|
||||
const { output } = runCli(args, tmpDir);
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(output);
|
||||
} catch (e) {
|
||||
assert.fail(`${label}: output is not valid JSON: ${output.slice(0, 200)}`);
|
||||
}
|
||||
assert.ok(
|
||||
parsed && typeof parsed === 'object' && !Array.isArray(parsed),
|
||||
`${label}: JSON must be a plain object`,
|
||||
);
|
||||
for (const field of requiredFields) {
|
||||
assert.ok(
|
||||
Object.prototype.hasOwnProperty.call(parsed, field),
|
||||
`${label}: bundle must contain field "${field}", got keys: ${Object.keys(parsed).join(', ')}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test(`${label} returns parseable JSON with bare project (no config at all)`, () => {
|
||||
const bareDir = makeProject(null); // no config.json
|
||||
try {
|
||||
const { exitCode, output, error } = runCli(args, bareDir);
|
||||
assert.strictEqual(
|
||||
exitCode,
|
||||
0,
|
||||
`${label}: expected exit 0 with bare project (no config), got ${exitCode}. stderr: ${error ?? ''}`,
|
||||
);
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(output);
|
||||
} catch (e) {
|
||||
assert.fail(`${label}: bare project output is not valid JSON: ${output.slice(0, 200)}`);
|
||||
}
|
||||
assert.ok(
|
||||
parsed && typeof parsed === 'object' && !Array.isArray(parsed),
|
||||
`${label}: bare project JSON must be a plain object`,
|
||||
);
|
||||
} finally {
|
||||
removeTmp(bareDir);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// B4. [negative] Empty registry → activeHooks:[] at all 12 points
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('B4 — empty registry (byLoopPoint:{}) at all 12 points → activeHooks:[]', () => {
|
||||
const EMPTY_REGISTRY = {
|
||||
byLoopPoint: {},
|
||||
capabilities: {},
|
||||
configKeys: {},
|
||||
configSchema: {},
|
||||
commandFamilies: {},
|
||||
};
|
||||
|
||||
test('loop tolerates a capability-less install: all 12 points return activeHooks:[]', () => {
|
||||
const failures = [];
|
||||
for (const point of ALL_12_POINTS) {
|
||||
const result = resolveLoopHooks({
|
||||
point,
|
||||
registry: EMPTY_REGISTRY,
|
||||
config: {},
|
||||
});
|
||||
assert.ok(
|
||||
result && typeof result === 'object',
|
||||
`${point}: result must be an object`,
|
||||
);
|
||||
assert.ok(
|
||||
Array.isArray(result.activeHooks),
|
||||
`${point}: activeHooks must be an array`,
|
||||
);
|
||||
if (result.activeHooks.length !== 0) {
|
||||
failures.push({
|
||||
point,
|
||||
count: result.activeHooks.length,
|
||||
capIds: result.activeHooks.map(h => h.capId),
|
||||
});
|
||||
}
|
||||
}
|
||||
assert.deepStrictEqual(
|
||||
failures,
|
||||
[],
|
||||
`Empty registry: expected zero active hooks at all 12 points, got non-empty at: ${JSON.stringify(failures)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('empty registry does not throw for any of the 12 canonical points', () => {
|
||||
for (const point of ALL_12_POINTS) {
|
||||
assert.doesNotThrow(
|
||||
() => resolveLoopHooks({ point, registry: EMPTY_REGISTRY, config: {} }),
|
||||
`resolveLoopHooks must not throw for empty registry at point "${point}"`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('renderLoopHooks with empty activeHooks returns a non-empty placeholder string', () => {
|
||||
const placeholder = renderLoopHooks({ point: 'plan:pre', activeHooks: [] });
|
||||
assert.ok(
|
||||
typeof placeholder === 'string' && placeholder.length > 0,
|
||||
`renderLoopHooks must return a non-empty string for empty activeHooks, got: ${JSON.stringify(placeholder)}`,
|
||||
);
|
||||
// Specific value check — genuineness: this must change if the placeholder format changes
|
||||
assert.strictEqual(
|
||||
placeholder,
|
||||
'_No active hooks at plan:pre._',
|
||||
`renderLoopHooks placeholder must be "_No active hooks at plan:pre._", got: "${placeholder}"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// B5. [BVA] One capability ON (tdd_mode) → its 2 points non-empty, 10 others empty
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('B5 — BVA: tdd_mode ON, all other caps OFF → additive: only tdd points active', () => {
|
||||
// tdd contributes at plan:pre and execute:post (verified from capability registry)
|
||||
const TDD_ACTIVE_POINTS = ['plan:pre', 'execute:post'];
|
||||
const TDD_INACTIVE_POINTS = ALL_12_POINTS.filter(p => !TDD_ACTIVE_POINTS.includes(p));
|
||||
const TDD_ON_CONFIG = buildTddOnlyConfig();
|
||||
|
||||
test('plan:pre has exactly 1 active hook and it belongs to tdd', () => {
|
||||
const result = resolveLoopHooks({
|
||||
point: 'plan:pre',
|
||||
registry: realRegistry,
|
||||
config: TDD_ON_CONFIG,
|
||||
});
|
||||
assert.ok(Array.isArray(result.activeHooks), 'activeHooks must be an array');
|
||||
assert.strictEqual(
|
||||
result.activeHooks.length,
|
||||
1,
|
||||
`plan:pre: expected 1 active hook (tdd), got ${result.activeHooks.length}: ${JSON.stringify(result.activeHooks.map(h => h.capId))}`,
|
||||
);
|
||||
assert.strictEqual(
|
||||
result.activeHooks[0].capId,
|
||||
'tdd',
|
||||
`plan:pre: expected activeHooks[0].capId to be "tdd", got "${result.activeHooks[0].capId}"`,
|
||||
);
|
||||
});
|
||||
|
||||
test('execute:post has exactly 1 active hook and it belongs to tdd', () => {
|
||||
const result = resolveLoopHooks({
|
||||
point: 'execute:post',
|
||||
registry: realRegistry,
|
||||
config: TDD_ON_CONFIG,
|
||||
});
|
||||
assert.ok(Array.isArray(result.activeHooks), 'activeHooks must be an array');
|
||||
assert.strictEqual(
|
||||
result.activeHooks.length,
|
||||
1,
|
||||
`execute:post: expected 1 active hook (tdd gate), got ${result.activeHooks.length}: ${JSON.stringify(result.activeHooks.map(h => h.capId))}`,
|
||||
);
|
||||
assert.strictEqual(
|
||||
result.activeHooks[0].capId,
|
||||
'tdd',
|
||||
`execute:post: expected activeHooks[0].capId to be "tdd", got "${result.activeHooks[0].capId}"`,
|
||||
);
|
||||
});
|
||||
|
||||
test('all 10 non-tdd points return activeHooks:[] even with tdd_mode ON', () => {
|
||||
const failures = [];
|
||||
for (const point of TDD_INACTIVE_POINTS) {
|
||||
const result = resolveLoopHooks({
|
||||
point,
|
||||
registry: realRegistry,
|
||||
config: TDD_ON_CONFIG,
|
||||
});
|
||||
assert.ok(Array.isArray(result.activeHooks), `${point}: activeHooks must be an array`);
|
||||
if (result.activeHooks.length !== 0) {
|
||||
failures.push({
|
||||
point,
|
||||
count: result.activeHooks.length,
|
||||
capIds: result.activeHooks.map(h => h.capId),
|
||||
});
|
||||
}
|
||||
}
|
||||
assert.deepStrictEqual(
|
||||
failures,
|
||||
[],
|
||||
`Expected 10 non-tdd points to be empty with tdd_mode ON, got non-zero at: ${JSON.stringify(failures)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('tdd hook at plan:pre is a contribution kind (not a gate or step)', () => {
|
||||
const result = resolveLoopHooks({
|
||||
point: 'plan:pre',
|
||||
registry: realRegistry,
|
||||
config: TDD_ON_CONFIG,
|
||||
});
|
||||
assert.strictEqual(
|
||||
result.activeHooks.length,
|
||||
1,
|
||||
'Expected exactly 1 active hook at plan:pre with tdd ON',
|
||||
);
|
||||
assert.strictEqual(
|
||||
result.activeHooks[0].kind,
|
||||
'contribution',
|
||||
`plan:pre tdd hook must be kind "contribution", got "${result.activeHooks[0].kind}"`,
|
||||
);
|
||||
});
|
||||
|
||||
test('tdd hook at execute:post is a gate kind (not a contribution or step)', () => {
|
||||
const result = resolveLoopHooks({
|
||||
point: 'execute:post',
|
||||
registry: realRegistry,
|
||||
config: TDD_ON_CONFIG,
|
||||
});
|
||||
assert.strictEqual(
|
||||
result.activeHooks.length,
|
||||
1,
|
||||
'Expected exactly 1 active hook at execute:post with tdd ON',
|
||||
);
|
||||
assert.strictEqual(
|
||||
result.activeHooks[0].kind,
|
||||
'gate',
|
||||
`execute:post tdd hook must be kind "gate", got "${result.activeHooks[0].kind}"`,
|
||||
);
|
||||
});
|
||||
|
||||
test('turning tdd_mode OFF restores both tdd points to activeHooks:[]', () => {
|
||||
// Regression check: tdd_mode OFF → both previously-active points go back to empty
|
||||
const tddOffConfig = buildAllFalseConfig(); // tdd_mode: false
|
||||
for (const point of TDD_ACTIVE_POINTS) {
|
||||
const result = resolveLoopHooks({
|
||||
point,
|
||||
registry: realRegistry,
|
||||
config: tddOffConfig,
|
||||
});
|
||||
assert.strictEqual(
|
||||
result.activeHooks.length,
|
||||
0,
|
||||
`${point}: expected activeHooks:[] with tdd_mode OFF, got ${JSON.stringify(result.activeHooks.map(h => h.capId))}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
450
tests/adr857-predicate-boundary.test.cjs
Normal file
450
tests/adr857-predicate-boundary.test.cjs
Normal file
@@ -0,0 +1,450 @@
|
||||
/**
|
||||
* ADR-857 deliverable A — predicate-boundary conformance gate.
|
||||
*
|
||||
* Amended 2026-06-12: "Verification substrate vs. plug-in tier (the predicate boundary)"
|
||||
* + Rollout §6 exception: predicate-generation is CORE substrate, NOT an off-by-default
|
||||
* Feature Capability.
|
||||
*
|
||||
* Key ADR assertions tested here:
|
||||
* - "The probe family that generates must-NOT-have and edge predicates is core
|
||||
* verification substrate, not an off-by-default Feature Capability."
|
||||
* - "no capabilities/edge-probe/ Feature Capability may remove it."
|
||||
* - "phase 6 does not migrate predicate-generation to an off-by-default Capability."
|
||||
* - "The substrate must be available even when all Feature Capabilities are off."
|
||||
*
|
||||
* Tests do NOT read source files (.md/.cjs) and .includes() on them.
|
||||
* All assertions drive the real exported functions and inspect typed return values.
|
||||
*/
|
||||
'use strict';
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
const fs = require('node:fs');
|
||||
|
||||
// ── Paths ─────────────────────────────────────────────────────────────────────
|
||||
const REPO_ROOT = path.join(__dirname, '..');
|
||||
const LIB = path.join(REPO_ROOT, 'gsd-core', 'bin', 'lib');
|
||||
const PROBE_CORE_PATH = path.join(LIB, 'probe-core.cjs');
|
||||
const EDGE_PROBE_PATH = path.join(LIB, 'edge-probe.cjs');
|
||||
const CAPABILITIES_DIR = path.join(REPO_ROOT, 'capabilities');
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Collect ids of all capability.json files under capabilities/. */
|
||||
function collectCapabilityIds() {
|
||||
const dirs = fs.readdirSync(CAPABILITIES_DIR, { withFileTypes: true });
|
||||
const ids = [];
|
||||
for (const d of dirs) {
|
||||
if (!d.isDirectory()) continue;
|
||||
const capFile = path.join(CAPABILITIES_DIR, d.name, 'capability.json');
|
||||
if (fs.existsSync(capFile)) {
|
||||
const parsed = JSON.parse(fs.readFileSync(capFile, 'utf8'));
|
||||
ids.push({ id: parsed.id, role: parsed.role });
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/** Minimal valid item for probe-core analyzeCoverage. */
|
||||
function makeItem(category, overrides = {}) {
|
||||
return {
|
||||
requirement_id: 'REQ-1',
|
||||
category,
|
||||
status: 'unresolved',
|
||||
verification: null,
|
||||
resolution: null,
|
||||
reason: null,
|
||||
probe: `probe-${category}`,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Minimal validators bundle (mirrors edge adapter shape). */
|
||||
const REPRESENTATIVE_VALIDATORS = {
|
||||
categories: ['boundary', 'adjacency', 'empty'],
|
||||
verification: ['explicit', 'backstop'],
|
||||
requiredFieldsByVerification: {
|
||||
explicit: ['resolution'],
|
||||
backstop: ['resolution'],
|
||||
},
|
||||
};
|
||||
|
||||
// ── [happy] ADR-857 §"Verification substrate vs. plug-in tier": substrate loads and
|
||||
// functions as CORE regardless of capability config with NO capabilities active ───
|
||||
describe('ADR-857 predicate boundary: substrate loads as core (no capabilities active)', () => {
|
||||
test('probe-core.cjs resolves from gsd-core/bin/lib (core path)', () => {
|
||||
// Confirm the module is loadable from the core lib path, not capabilities/
|
||||
assert.ok(
|
||||
fs.existsSync(PROBE_CORE_PATH),
|
||||
`probe-core.cjs must exist at core path ${PROBE_CORE_PATH}`
|
||||
);
|
||||
const pc = require(PROBE_CORE_PATH);
|
||||
assert.ok(pc != null, 'probe-core.cjs must export a non-null module');
|
||||
});
|
||||
|
||||
test('edge-probe.cjs resolves from gsd-core/bin/lib (core path)', () => {
|
||||
assert.ok(
|
||||
fs.existsSync(EDGE_PROBE_PATH),
|
||||
`edge-probe.cjs must exist at core path ${EDGE_PROBE_PATH}`
|
||||
);
|
||||
const ep = require(EDGE_PROBE_PATH);
|
||||
assert.ok(ep != null, 'edge-probe.cjs must export a non-null module');
|
||||
});
|
||||
|
||||
test('probe-core exports the locked VALID_STATUS set — substrate contract is present', () => {
|
||||
const pc = require(PROBE_CORE_PATH);
|
||||
// ADR: the contract is a stability contract — its shape must be consistent
|
||||
assert.ok(Array.isArray(pc.VALID_STATUS), 'VALID_STATUS must be an array');
|
||||
assert.deepEqual(
|
||||
[...pc.VALID_STATUS].sort(),
|
||||
['dismissed', 'resolved', 'unresolved'],
|
||||
'VALID_STATUS must contain exactly resolved|dismissed|unresolved (the locked re-cut)'
|
||||
);
|
||||
});
|
||||
|
||||
test('probe-core exports all four required contract functions', () => {
|
||||
const pc = require(PROBE_CORE_PATH);
|
||||
// The four deterministic substrate functions defined in probe-core
|
||||
assert.strictEqual(typeof pc.validateRequirement, 'function', 'validateRequirement must be a function');
|
||||
assert.strictEqual(typeof pc.validateResolution, 'function', 'validateResolution must be a function');
|
||||
assert.strictEqual(typeof pc.analyzeCoverage, 'function', 'analyzeCoverage must be a function');
|
||||
assert.strictEqual(typeof pc.runProbeCli, 'function', 'runProbeCli must be a function');
|
||||
});
|
||||
|
||||
test('probe-core.validateRequirement accepts a valid requirement with NO capability config', () => {
|
||||
const pc = require(PROBE_CORE_PATH);
|
||||
// No capability config passed — function must work unconditionally (non-toggleable substrate)
|
||||
assert.doesNotThrow(
|
||||
() => pc.validateRequirement({ id: 'REQ-42', text: 'the system rounds values to two decimal places' }),
|
||||
'validateRequirement must not throw for a valid requirement when no capabilities are active'
|
||||
);
|
||||
});
|
||||
|
||||
test('probe-core.analyzeCoverage returns a contract-shaped coverage report with NO capability config', () => {
|
||||
const pc = require(PROBE_CORE_PATH);
|
||||
// Drive the core merge/rollup engine with a minimal item set and NO capability config
|
||||
const items = [makeItem('boundary'), makeItem('adjacency')];
|
||||
const report = pc.analyzeCoverage(items, [], REPRESENTATIVE_VALIDATORS);
|
||||
|
||||
// Contract shape: { items[], coverage: { applicable, resolved, unresolved, byVerification } }
|
||||
assert.ok(Array.isArray(report.items), 'report.items must be an array');
|
||||
assert.strictEqual(report.items.length, 2, 'report.items must contain both proposed items');
|
||||
assert.ok(report.coverage != null && typeof report.coverage === 'object', 'report.coverage must be an object');
|
||||
assert.strictEqual(typeof report.coverage.applicable, 'number', 'coverage.applicable must be a number');
|
||||
assert.strictEqual(typeof report.coverage.resolved, 'number', 'coverage.resolved must be a number');
|
||||
assert.strictEqual(typeof report.coverage.unresolved, 'number', 'coverage.unresolved must be a number');
|
||||
assert.ok(report.coverage.byVerification != null, 'coverage.byVerification must be present');
|
||||
|
||||
// Exact values for genuineness
|
||||
assert.strictEqual(report.coverage.applicable, 2, 'applicable must equal item count (2)');
|
||||
assert.strictEqual(report.coverage.unresolved, 2, 'unresolved must be 2 (no resolutions provided)');
|
||||
assert.strictEqual(report.coverage.resolved, 0, 'resolved must be 0 (no resolutions provided)');
|
||||
assert.strictEqual(report.coverage.byVerification.explicit, 0, 'explicit count must be 0');
|
||||
assert.strictEqual(report.coverage.byVerification.backstop, 0, 'backstop count must be 0');
|
||||
});
|
||||
|
||||
test('edge-probe exports the locked shape vocabulary (VALID_SHAPES, TAXONOMY, EDGE_VALIDATORS)', () => {
|
||||
const ep = require(EDGE_PROBE_PATH);
|
||||
// VALID_SHAPES: exactly 5 shape names
|
||||
assert.ok(ep.VALID_SHAPES instanceof Set, 'VALID_SHAPES must be a Set');
|
||||
assert.strictEqual(ep.VALID_SHAPES.size, 5, 'VALID_SHAPES must have exactly 5 entries');
|
||||
for (const s of ['numeric-range', 'collection', 'text', 'stateful', 'io']) {
|
||||
assert.ok(ep.VALID_SHAPES.has(s), `VALID_SHAPES must contain "${s}"`);
|
||||
}
|
||||
// TAXONOMY: exactly 8 edge categories
|
||||
assert.ok(Array.isArray(ep.TAXONOMY), 'TAXONOMY must be an array');
|
||||
assert.strictEqual(ep.TAXONOMY.length, 8, 'TAXONOMY must have exactly 8 categories');
|
||||
// EDGE_VALIDATORS: verification tiers must be exactly explicit|backstop
|
||||
assert.deepEqual(
|
||||
[...ep.EDGE_VALIDATORS.verification].sort(),
|
||||
['backstop', 'explicit'],
|
||||
'EDGE_VALIDATORS.verification must be ["explicit","backstop"]'
|
||||
);
|
||||
});
|
||||
|
||||
test('edge-probe.classifyShape returns a typed array result with NO capability config', () => {
|
||||
const ep = require(EDGE_PROBE_PATH);
|
||||
// ADR: substrate available without any capability toggling.
|
||||
// Text chosen to trigger multiple concrete shapes:
|
||||
// "save" (word-boundary match in SHAPE_CUES.stateful) → stateful
|
||||
// "file" (SHAPE_CUES.io) → io
|
||||
// "maximum count limit" (SHAPE_CUES['numeric-range']) → numeric-range
|
||||
const shapes = ep.classifyShape('the system must save a file with a maximum count limit');
|
||||
assert.ok(Array.isArray(shapes), 'classifyShape must return an array');
|
||||
assert.ok(shapes.includes('numeric-range'), 'classifyShape must detect numeric-range from "maximum count limit"');
|
||||
assert.ok(shapes.includes('stateful'), 'classifyShape must detect stateful from "save" (word-boundary cue)');
|
||||
assert.ok(shapes.includes('io'), 'classifyShape must detect io from "file"');
|
||||
});
|
||||
|
||||
test('edge-probe.proposeEdges returns unresolved items with contract shape with NO capability config', () => {
|
||||
const ep = require(EDGE_PROBE_PATH);
|
||||
const edges = ep.proposeEdges({ id: 'R-num', text: 'the score must stay within a numeric range between 0 and 100' });
|
||||
assert.ok(Array.isArray(edges), 'proposeEdges must return an array');
|
||||
assert.ok(edges.length > 0, 'proposeEdges must propose at least one edge for a numeric-range requirement');
|
||||
// Every proposed edge must be unresolved with null verification
|
||||
for (const edge of edges) {
|
||||
assert.strictEqual(edge.requirement_id, 'R-num', 'edge.requirement_id must match input id');
|
||||
assert.strictEqual(edge.status, 'unresolved', 'proposed edge status must be unresolved');
|
||||
assert.strictEqual(edge.verification, null, 'proposed edge verification must be null');
|
||||
assert.strictEqual(typeof edge.category, 'string', 'edge.category must be a string');
|
||||
assert.strictEqual(typeof edge.probe, 'string', 'edge.probe must be a string');
|
||||
}
|
||||
// Specific: "numeric range between 0 and 100" => boundary category expected
|
||||
const cats = edges.map(e => e.category);
|
||||
assert.ok(cats.includes('boundary'), 'proposeEdges must include boundary category for numeric-range text');
|
||||
});
|
||||
});
|
||||
|
||||
// ── [negative] No off-by-default Feature Capability owns predicate-generation ──
|
||||
describe('ADR-857 predicate boundary: no capabilities/edge-probe or prohibition-probe Feature Capability exists', () => {
|
||||
test('capabilities/edge-probe directory does NOT exist (ADR-857: not an off-by-default plug-in)', () => {
|
||||
const edgeProbeCap = path.join(CAPABILITIES_DIR, 'edge-probe');
|
||||
assert.strictEqual(
|
||||
fs.existsSync(edgeProbeCap),
|
||||
false,
|
||||
'capabilities/edge-probe must not exist — ADR-857 forbids predicate-generation as an off-by-default Capability'
|
||||
);
|
||||
});
|
||||
|
||||
test('capabilities/prohibition-probe directory does NOT exist (ADR-857: not an off-by-default plug-in)', () => {
|
||||
const prohibitionProbeCap = path.join(CAPABILITIES_DIR, 'prohibition-probe');
|
||||
assert.strictEqual(
|
||||
fs.existsSync(prohibitionProbeCap),
|
||||
false,
|
||||
'capabilities/prohibition-probe must not exist — ADR-857 forbids predicate-generation as an off-by-default Capability'
|
||||
);
|
||||
});
|
||||
|
||||
test('real capability registry has NO entry with id "edge-probe" or "prohibition-probe" with role "feature"', () => {
|
||||
const { capabilities } = require(path.join(LIB, 'capability-registry.cjs'));
|
||||
const ids = Object.keys(capabilities);
|
||||
|
||||
// Assert no edge-probe feature capability
|
||||
const hasEdgeProbeFeature = ids.some(
|
||||
id => id === 'edge-probe' && capabilities[id].role === 'feature'
|
||||
);
|
||||
assert.strictEqual(
|
||||
hasEdgeProbeFeature,
|
||||
false,
|
||||
'registry must NOT contain a feature capability with id "edge-probe"'
|
||||
);
|
||||
|
||||
// Assert no prohibition-probe feature capability
|
||||
const hasProhibitionProbeFeature = ids.some(
|
||||
id => id === 'prohibition-probe' && capabilities[id].role === 'feature'
|
||||
);
|
||||
assert.strictEqual(
|
||||
hasProhibitionProbeFeature,
|
||||
false,
|
||||
'registry must NOT contain a feature capability with id "prohibition-probe"'
|
||||
);
|
||||
|
||||
// Also verify neither id exists at all (not even as a different role)
|
||||
assert.ok(
|
||||
!ids.includes('edge-probe'),
|
||||
'registry must not contain any capability with id "edge-probe"'
|
||||
);
|
||||
assert.ok(
|
||||
!ids.includes('prohibition-probe'),
|
||||
'registry must not contain any capability with id "prohibition-probe"'
|
||||
);
|
||||
});
|
||||
|
||||
test('capability.json files on disk contain no id matching edge-probe or prohibition-probe with role feature', () => {
|
||||
const allCaps = collectCapabilityIds();
|
||||
const probeFeatures = allCaps.filter(
|
||||
c => (c.id === 'edge-probe' || c.id === 'prohibition-probe') && c.role === 'feature'
|
||||
);
|
||||
assert.deepEqual(
|
||||
probeFeatures,
|
||||
[],
|
||||
`No capability.json on disk may declare id "edge-probe" or "prohibition-probe" with role "feature"; found: ${JSON.stringify(probeFeatures)}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── [happy] Predicate substrate lives in core (bin/lib), not in capabilities/ ──
|
||||
describe('ADR-857 predicate boundary: substrate lives in core, not in capabilities/', () => {
|
||||
test('probe-core.cjs is resolvable from gsd-core/bin/lib — the core module tier', () => {
|
||||
// Must resolve from core lib, not from any capability folder
|
||||
const resolved = require.resolve(PROBE_CORE_PATH);
|
||||
assert.ok(
|
||||
resolved.includes(path.join('gsd-core', 'bin', 'lib')),
|
||||
`probe-core.cjs must resolve from gsd-core/bin/lib (got: ${resolved})`
|
||||
);
|
||||
assert.ok(
|
||||
!resolved.includes('capabilities'),
|
||||
`probe-core.cjs must NOT resolve from any capabilities/ folder (got: ${resolved})`
|
||||
);
|
||||
});
|
||||
|
||||
test('edge-probe.cjs is resolvable from gsd-core/bin/lib — the core module tier', () => {
|
||||
const resolved = require.resolve(EDGE_PROBE_PATH);
|
||||
assert.ok(
|
||||
resolved.includes(path.join('gsd-core', 'bin', 'lib')),
|
||||
`edge-probe.cjs must resolve from gsd-core/bin/lib (got: ${resolved})`
|
||||
);
|
||||
assert.ok(
|
||||
!resolved.includes('capabilities'),
|
||||
`edge-probe.cjs must NOT resolve from any capabilities/ folder (got: ${resolved})`
|
||||
);
|
||||
});
|
||||
|
||||
test('no capabilities/*/capability.json declares id "edge-probe" or "prohibition-probe" as a feature', () => {
|
||||
// Scan all capability.json files on disk and confirm none are probe features
|
||||
const allCaps = collectCapabilityIds();
|
||||
const featureIds = allCaps.filter(c => c.role === 'feature').map(c => c.id);
|
||||
|
||||
assert.ok(
|
||||
!featureIds.includes('edge-probe'),
|
||||
`Feature capability ids must not include "edge-probe"; found: ${JSON.stringify(featureIds)}`
|
||||
);
|
||||
assert.ok(
|
||||
!featureIds.includes('prohibition-probe'),
|
||||
`Feature capability ids must not include "prohibition-probe"; found: ${JSON.stringify(featureIds)}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── [negative/BVA] Toggling ALL capability config keys off does NOT change substrate
|
||||
// availability or output — substrate is non-toggleable ─────────────────────────
|
||||
describe('ADR-857 predicate boundary: substrate is non-toggleable (all-off config does not affect it)', () => {
|
||||
test('probe-core functions return identical output before and after constructing an all-off config', () => {
|
||||
const ep = require(EDGE_PROBE_PATH);
|
||||
const { configKeys } = require(path.join(LIB, 'capability-registry.cjs'));
|
||||
|
||||
// Build a config object with every known workflow.* and intel/profile key set to false
|
||||
const allOffConfig = {};
|
||||
for (const key of Object.keys(configKeys)) {
|
||||
allOffConfig[key] = false;
|
||||
}
|
||||
|
||||
// "Before": call analyzeCoverage with a representative set
|
||||
const reqText = 'the API endpoint accepts a list of items with a maximum count threshold and stores each one';
|
||||
const BEFORE_shapes = ep.classifyShape(reqText);
|
||||
const BEFORE_edges = ep.proposeEdges({ id: 'R-bva', text: reqText });
|
||||
const BEFORE_report = ep.analyzeCoverage([{ id: 'R-bva', text: reqText }], []);
|
||||
|
||||
// Simulate "all capabilities off" by confirming the config object is fully false
|
||||
// (The substrate does not accept a config parameter — this BVA tests that the
|
||||
// probe functions are unconditionally available regardless of config state)
|
||||
const allOff = Object.values(allOffConfig).every(v => v === false);
|
||||
assert.strictEqual(allOff, true, 'all config keys must be set to false in the all-off config');
|
||||
|
||||
// "After all-off config": call the same functions again — results must be identical
|
||||
const AFTER_shapes = ep.classifyShape(reqText);
|
||||
const AFTER_edges = ep.proposeEdges({ id: 'R-bva', text: reqText });
|
||||
const AFTER_report = ep.analyzeCoverage([{ id: 'R-bva', text: reqText }], []);
|
||||
|
||||
// ADR-857: the substrate is non-toggleable — output must not change
|
||||
assert.deepEqual(
|
||||
AFTER_shapes,
|
||||
BEFORE_shapes,
|
||||
'classifyShape must return identical output regardless of capability config state'
|
||||
);
|
||||
assert.deepEqual(
|
||||
AFTER_edges,
|
||||
BEFORE_edges,
|
||||
'proposeEdges must return identical output regardless of capability config state'
|
||||
);
|
||||
assert.deepEqual(
|
||||
AFTER_report,
|
||||
BEFORE_report,
|
||||
'analyzeCoverage must return identical output regardless of capability config state'
|
||||
);
|
||||
|
||||
// Specific value assertion to prevent vacuous-truth: shapes must include at least two types
|
||||
assert.ok(AFTER_shapes.length >= 2, `classifyShape must detect at least 2 shapes for complex text (got ${AFTER_shapes.length})`);
|
||||
assert.ok(AFTER_edges.length >= 2, `proposeEdges must propose at least 2 edges for this requirement (got ${AFTER_edges.length})`);
|
||||
});
|
||||
|
||||
test('probe-core.validateResolution rejects an invalid status regardless of all-off config (BVA: status boundary)', () => {
|
||||
const pc = require(PROBE_CORE_PATH);
|
||||
|
||||
// BVA: exact boundary — 'unresolved' (valid, limit case) vs 'covered' (was valid pre-re-cut, now invalid)
|
||||
// Valid status (limit): must NOT throw
|
||||
assert.doesNotThrow(
|
||||
() => pc.validateResolution(
|
||||
{ requirement_id: 'R1', category: 'boundary', status: 'unresolved', verification: null, resolution: null, reason: null },
|
||||
REPRESENTATIVE_VALIDATORS
|
||||
),
|
||||
'validateResolution must accept status="unresolved" (the valid boundary case)'
|
||||
);
|
||||
|
||||
// Invalid status (just outside the locked set): must throw with a message naming the bad status
|
||||
assert.throws(
|
||||
() => pc.validateResolution(
|
||||
{ requirement_id: 'R1', category: 'boundary', status: 'covered', verification: null, resolution: null, reason: null },
|
||||
REPRESENTATIVE_VALIDATORS
|
||||
),
|
||||
(err) => {
|
||||
assert.ok(err instanceof Error, 'must throw an Error');
|
||||
assert.ok(
|
||||
err.message.includes('covered'),
|
||||
`error message must name the invalid status "covered"; got: "${err.message}"`
|
||||
);
|
||||
return true;
|
||||
},
|
||||
'validateResolution must reject status="covered" (the pre-re-cut status that is now outside the locked set)'
|
||||
);
|
||||
});
|
||||
|
||||
test('probe-core.analyzeCoverage rejects a resolved item missing verification tier (BVA: verification null boundary)', () => {
|
||||
const pc = require(PROBE_CORE_PATH);
|
||||
|
||||
// BVA: resolved + null verification is INVALID (one step below the minimum)
|
||||
const badItems = [
|
||||
makeItem('boundary', { status: 'resolved', verification: null, resolution: 'AC text' }),
|
||||
];
|
||||
assert.throws(
|
||||
() => pc.analyzeCoverage(badItems, [], REPRESENTATIVE_VALIDATORS),
|
||||
(err) => {
|
||||
assert.ok(err instanceof Error, 'must throw an Error');
|
||||
assert.ok(
|
||||
err.message.toLowerCase().includes('verification'),
|
||||
`error message must mention "verification"; got: "${err.message}"`
|
||||
);
|
||||
return true;
|
||||
},
|
||||
'analyzeCoverage must reject a resolved item with verification=null (verification required at this boundary)'
|
||||
);
|
||||
|
||||
// BVA: resolved + valid verification tier is VALID (at the minimum)
|
||||
const goodItems = [
|
||||
makeItem('boundary', { status: 'resolved', verification: 'explicit', resolution: 'acceptance criterion text' }),
|
||||
];
|
||||
const report = pc.analyzeCoverage(goodItems, [], REPRESENTATIVE_VALIDATORS);
|
||||
assert.strictEqual(report.coverage.resolved, 1, 'resolved count must be 1 for a valid resolved item');
|
||||
assert.strictEqual(report.coverage.byVerification.explicit, 1, 'byVerification.explicit must be 1');
|
||||
});
|
||||
|
||||
test('all capability config keys being false does not prevent probe-core from loading or exporting VALID_STATUS', () => {
|
||||
// This test confirms the substrate is non-conditionally loaded (not behind any
|
||||
// capability gate) — if probe-core depended on a capability config, VALID_STATUS
|
||||
// would differ or throw when the underlying capability was off.
|
||||
const pc = require(PROBE_CORE_PATH);
|
||||
const { configKeys } = require(path.join(LIB, 'capability-registry.cjs'));
|
||||
|
||||
// With every key false, VALID_STATUS must remain the locked set
|
||||
const allOffConfig = {};
|
||||
for (const key of Object.keys(configKeys)) {
|
||||
allOffConfig[key] = false;
|
||||
}
|
||||
|
||||
// probe-core does not accept a config — it must be unconditional
|
||||
// Exact value check (genuineness: flipping one would fail)
|
||||
assert.deepEqual(
|
||||
[...pc.VALID_STATUS].sort(),
|
||||
['dismissed', 'resolved', 'unresolved'],
|
||||
'VALID_STATUS must be identical regardless of all-off config (substrate is non-toggleable)'
|
||||
);
|
||||
|
||||
// Also verify configKeys has at least some keys (ensures the all-off scenario is meaningful)
|
||||
assert.ok(
|
||||
Object.keys(configKeys).length > 0,
|
||||
'configKeys must be non-empty (all-off scenario must be meaningful)'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -178,22 +178,28 @@ describe('bug-2851: workflow files must not call bare `gsd-tools` (#2245 sweep r
|
||||
);
|
||||
});
|
||||
|
||||
test('plan-phase.md §13e gap-analysis uses the gsd_run launcher (resolvable invocation, #621)', () => {
|
||||
test('plan-phase.md §13e gap-analysis dispatches via gsd_run loop render-hooks plan:post (ADR-857 capability gate, #621)', () => {
|
||||
const planPhase = fs.readFileSync(path.join(WORKFLOWS_DIR, 'plan-phase.md'), 'utf-8');
|
||||
const blocks = extractShellBlocks(planPhase);
|
||||
let foundGapAnalysisCall = false;
|
||||
let foundPlanPostDispatch = false;
|
||||
for (const blk of blocks) {
|
||||
for (const line of blk.lines) {
|
||||
if (/gap-analysis/.test(line) && !/^\s*#/.test(line)) {
|
||||
foundGapAnalysisCall = true;
|
||||
assert.match(
|
||||
line,
|
||||
/\bgsd_run\s+gap-analysis\b/,
|
||||
`gap-analysis must use the gsd_run launcher (not a hardcoded $HOME path), got: ${line.trim()}`,
|
||||
if (/gsd_run\s+loop\s+render-hooks\s+plan:post\s+--raw/.test(line) && !/^\s*#/.test(line)) {
|
||||
foundPlanPostDispatch = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ok(
|
||||
foundPlanPostDispatch,
|
||||
'expected plan-phase.md §13e to dispatch gsd_run loop render-hooks plan:post --raw (gap-analysis moved to capability gate plan:post in ADR-857 migration)',
|
||||
);
|
||||
const registry = require('../gsd-core/bin/lib/capability-registry.cjs');
|
||||
const planPostPoint = (registry.byLoopPoint || {})['plan:post'] || {};
|
||||
const gates = planPostPoint.gates || [];
|
||||
const gapAnalysisGate = gates.find((g) => g.capId === 'gap-analysis');
|
||||
assert.ok(
|
||||
gapAnalysisGate,
|
||||
'gap-analysis capability must be registered as a plan:post gate in capability-registry.cjs',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ok(foundGapAnalysisCall, 'expected at least one gap-analysis invocation in plan-phase.md');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,10 +35,10 @@ const workflow = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
||||
// ─── #621 regression: gap-analysis routes through gsd_run ────────────────────
|
||||
|
||||
describe('plan-phase workflow: post-planning-gaps gap-analysis uses gsd_run launcher (#621)', () => {
|
||||
test('gap-analysis call routes through gsd_run, not hardcoded node path', () => {
|
||||
test('gap-analysis dispatches via gsd_run loop render-hooks plan:post (ADR-857 capability gate)', () => {
|
||||
assert.ok(
|
||||
workflow.includes('gsd_run gap-analysis'),
|
||||
'workflow must invoke gap-analysis via gsd_run, not a hardcoded node path'
|
||||
workflow.includes('gsd_run loop render-hooks plan:post'),
|
||||
'workflow must dispatch gap-analysis via gsd_run loop render-hooks plan:post, not a hardcoded node path or direct gsd_run gap-analysis call'
|
||||
);
|
||||
});
|
||||
|
||||
@@ -66,7 +66,7 @@ describe('plan-phase workflow: post-planning-gaps gap-analysis uses gsd_run laun
|
||||
|
||||
test('post-planning-gaps block still gates on workflow.post_planning_gaps and preserves required args', () => {
|
||||
const hasGate = workflow.includes('workflow.post_planning_gaps');
|
||||
const hasPhaseDir = workflow.includes('--phase-dir "${PHASE_DIR}"');
|
||||
const hasPhaseDir = workflow.includes('gsd_run check ${hook.check.query} "${PHASE_DIR}" "${PHASE_REQ_IDS}"');
|
||||
const hasPickArg = workflow.includes('--pick phase_req_ids');
|
||||
assert.ok(
|
||||
hasGate,
|
||||
@@ -74,7 +74,7 @@ describe('plan-phase workflow: post-planning-gaps gap-analysis uses gsd_run laun
|
||||
);
|
||||
assert.ok(
|
||||
hasPhaseDir,
|
||||
'gap-analysis invocation must still pass --phase-dir "${PHASE_DIR}"'
|
||||
'gap-analysis check dispatch must pass "${PHASE_DIR}" (and "${PHASE_REQ_IDS}") positionally to gsd_run check'
|
||||
);
|
||||
assert.ok(
|
||||
hasPickArg,
|
||||
|
||||
507
tests/check-gap-analysis-plan-post-e2e.test.cjs
Normal file
507
tests/check-gap-analysis-plan-post-e2e.test.cjs
Normal file
@@ -0,0 +1,507 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* E2E content tests for plan:post hook — gap-analysis gate.
|
||||
*
|
||||
* ADR-857 phase 6 backlog: check-gap-analysis-plan-post-e2e.test.cjs
|
||||
*
|
||||
* Tests exercise:
|
||||
* - loop render-hooks plan:post (gate discovery)
|
||||
* - check gap-analysis.plan-post (advisory gate check)
|
||||
*
|
||||
* HARD RULES enforced here:
|
||||
* - Every test runs a real CLI subprocess or the real resolver + real registry.
|
||||
* - No readFileSync + .includes() source-grep on workflow files.
|
||||
* - Asserts TYPED CONTENT (JSON fields, counts, booleans, strings).
|
||||
* - Each test fully isolated (own fixture), cleanup in afterEach.
|
||||
*/
|
||||
|
||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const GSD_TOOLS = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
|
||||
|
||||
// ─── Shared helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Write REQUIREMENTS.md with REQ-IDs in checkbox format.
|
||||
* @param {string} planningDir
|
||||
* @param {string[]} ids
|
||||
*/
|
||||
function writeRequirements(planningDir, ids) {
|
||||
const lines = ids.map((id, i) => `- [ ] **${id}** Requirement ${i + 1} description`);
|
||||
fs.writeFileSync(
|
||||
path.join(planningDir, 'REQUIREMENTS.md'),
|
||||
`# Requirements\n\n${lines.join('\n')}\n`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write CONTEXT.md with a <decisions> block containing decisions.
|
||||
* @param {string} phaseDir
|
||||
* @param {{id: string, text: string}[]} decisions
|
||||
*/
|
||||
function writeContext(phaseDir, decisions) {
|
||||
const dLines = decisions.map(d => `- **${d.id}:** ${d.text}`).join('\n');
|
||||
fs.writeFileSync(
|
||||
path.join(phaseDir, 'CONTEXT.md'),
|
||||
`# Phase Context\n\n<decisions>\n## Implementation Decisions\n\n${dLines}\n</decisions>\n`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a PLAN.md with the given body.
|
||||
* @param {string} phaseDir
|
||||
* @param {string} name e.g. '01'
|
||||
* @param {string} body
|
||||
*/
|
||||
function writePlan(phaseDir, name, body) {
|
||||
fs.writeFileSync(path.join(phaseDir, `${name}-PLAN.md`), body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run loop render-hooks via spawnSync for low-level exit-code control.
|
||||
* @param {string} point
|
||||
* @param {string} cwd
|
||||
* @returns {{ status: number, stdout: string, stderr: string }}
|
||||
*/
|
||||
function spawnRenderHooks(point, cwd) {
|
||||
const result = spawnSync(process.execPath, [GSD_TOOLS, 'loop', 'render-hooks', point, '--raw'], {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
timeout: 60000,
|
||||
env: { ...process.env, GSD_SESSION_KEY: '', CODEX_THREAD_ID: '', CLAUDE_SESSION_ID: '' },
|
||||
});
|
||||
return {
|
||||
status: result.status,
|
||||
stdout: (result.stdout || '').trim(),
|
||||
stderr: (result.stderr || '').trim(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run check gap-analysis.plan-post via CLI with controlled args.
|
||||
* @param {string[]} extraArgs args after 'gap-analysis.plan-post'
|
||||
* @param {string} cwd
|
||||
* @returns {{ success: boolean, output: string, error: string, exitCode: number }}
|
||||
*/
|
||||
function runGapCheck(extraArgs, cwd) {
|
||||
return runGsdTools(['check', 'gap-analysis.plan-post', ...extraArgs, '--raw'], cwd);
|
||||
}
|
||||
|
||||
// ─── Section 1: render-hooks plan:post ───────────────────────────────────────
|
||||
|
||||
describe('render-hooks plan:post — gate discovery', () => {
|
||||
let tmpDir;
|
||||
let phaseDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
// Initialize a valid config so schema defaults apply
|
||||
const init = runGsdTools('config-ensure-section', tmpDir);
|
||||
assert.ok(init.success, `config-ensure-section failed: ${init.error}`);
|
||||
});
|
||||
|
||||
afterEach(() => cleanup(tmpDir));
|
||||
|
||||
test('[happy] render-hooks plan:post returns gap-analysis gate hook with correct typed shape when workflow.post_planning_gaps=true (default)', () => {
|
||||
// Default config → post_planning_gaps=true (schema default)
|
||||
const r = spawnRenderHooks('plan:post', tmpDir);
|
||||
assert.strictEqual(r.status, 0, `exit non-zero: ${r.stderr}`);
|
||||
|
||||
const envelope = JSON.parse(r.stdout);
|
||||
assert.strictEqual(envelope.point, 'plan:post');
|
||||
assert.ok(Array.isArray(envelope.activeHooks), 'activeHooks must be array');
|
||||
assert.strictEqual(envelope.activeHooks.length, 1, 'exactly one active hook expected');
|
||||
|
||||
const hook = envelope.activeHooks[0];
|
||||
assert.strictEqual(hook.capId, 'gap-analysis', 'capId must be gap-analysis');
|
||||
assert.strictEqual(hook.kind, 'gate', 'kind must be gate');
|
||||
assert.strictEqual(hook.blocking, false, 'blocking must be false (advisory)');
|
||||
assert.strictEqual(hook.onError, 'skip', 'onError must be skip');
|
||||
assert.strictEqual(hook.when, 'workflow.post_planning_gaps', 'when must be workflow.post_planning_gaps');
|
||||
assert.deepStrictEqual(hook.check, { query: 'gap-analysis.plan-post' }, 'check.query must be gap-analysis.plan-post');
|
||||
|
||||
// rendered must mention the gate
|
||||
assert.ok(typeof envelope.rendered === 'string', 'rendered must be string');
|
||||
assert.ok(envelope.rendered.includes('gap-analysis'), 'rendered must mention gap-analysis');
|
||||
assert.ok(envelope.rendered.includes('gap-analysis.plan-post'), 'rendered must include check query');
|
||||
});
|
||||
|
||||
test('[negative] render-hooks plan:post returns empty activeHooks when workflow.post_planning_gaps=false (gate deactivated)', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify({ workflow: { post_planning_gaps: false } })
|
||||
);
|
||||
|
||||
const r = spawnRenderHooks('plan:post', tmpDir);
|
||||
assert.strictEqual(r.status, 0, `exit non-zero: ${r.stderr}`);
|
||||
|
||||
const envelope = JSON.parse(r.stdout);
|
||||
assert.strictEqual(envelope.point, 'plan:post');
|
||||
// GENUINE check: must be EMPTY, not length 1
|
||||
assert.deepStrictEqual(envelope.activeHooks, [], 'activeHooks must be empty when gate disabled');
|
||||
assert.strictEqual(envelope.rendered, '_No active hooks at plan:post._',
|
||||
'rendered placeholder must match exactly when no hooks active');
|
||||
});
|
||||
|
||||
test('[happy] render-hooks plan:post with explicit post_planning_gaps=true in config returns same hook as default', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify({ workflow: { post_planning_gaps: true } })
|
||||
);
|
||||
|
||||
const r = spawnRenderHooks('plan:post', tmpDir);
|
||||
assert.strictEqual(r.status, 0, `exit non-zero: ${r.stderr}`);
|
||||
|
||||
const envelope = JSON.parse(r.stdout);
|
||||
assert.strictEqual(envelope.activeHooks.length, 1, 'exactly one hook with explicit true');
|
||||
assert.strictEqual(envelope.activeHooks[0].capId, 'gap-analysis');
|
||||
assert.strictEqual(envelope.activeHooks[0].blocking, false);
|
||||
});
|
||||
|
||||
test('[bva] render-hooks plan:post envelope has exactly 3 keys (point, activeHooks, rendered) — Hyrum\'s law shape pin', () => {
|
||||
const r = spawnRenderHooks('plan:post', tmpDir);
|
||||
assert.strictEqual(r.status, 0, `exit non-zero: ${r.stderr}`);
|
||||
|
||||
const envelope = JSON.parse(r.stdout);
|
||||
const keys = Object.keys(envelope).sort();
|
||||
assert.deepStrictEqual(keys, ['activeHooks', 'point', 'rendered'],
|
||||
`envelope must have exactly 3 keys, got: ${keys.join(',')}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Section 2: check gap-analysis.plan-post — content tests ─────────────────
|
||||
|
||||
describe('check gap-analysis.plan-post — gate content E2E', () => {
|
||||
let tmpDir;
|
||||
let phaseDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
const init = runGsdTools('config-ensure-section', tmpDir);
|
||||
assert.ok(init.success, `config-ensure-section failed: ${init.error}`);
|
||||
});
|
||||
|
||||
afterEach(() => cleanup(tmpDir));
|
||||
|
||||
// ── Coverage table tests ────────────────────────────────────────────────────
|
||||
|
||||
test('[happy] check gap-analysis.plan-post returns block:false with coverage table when phaseDir has plans covering some REQ-IDs', () => {
|
||||
writeRequirements(path.join(tmpDir, '.planning'), ['REQ-01', 'REQ-02']);
|
||||
writePlan(phaseDir, '01', '# Plan 1\n\nImplements REQ-01 only.\n');
|
||||
|
||||
const r = runGapCheck([phaseDir, 'REQ-01,REQ-02'], tmpDir);
|
||||
assert.ok(r.success, `check failed: ${r.error}`);
|
||||
|
||||
const out = JSON.parse(r.output);
|
||||
// GENUINE typed field assertions
|
||||
assert.strictEqual(out.block, false, 'block must be false (gap-analysis is always advisory)');
|
||||
assert.strictEqual(out.passed, true);
|
||||
assert.strictEqual(out.enabled, true);
|
||||
assert.strictEqual(out.counts.total, 2, 'total must be 2');
|
||||
assert.strictEqual(out.counts.covered, 1, 'covered must be 1 (REQ-01 only)');
|
||||
assert.strictEqual(out.counts.uncovered, 1, 'uncovered must be 1 (REQ-02 not in plan)');
|
||||
|
||||
// Table content — assert specific coverage rows
|
||||
assert.ok(out.table.includes('REQ-01'), 'table must include REQ-01');
|
||||
assert.ok(out.table.includes('REQ-02'), 'table must include REQ-02');
|
||||
assert.ok(out.table.includes('✓ Covered'), 'table must show covered row');
|
||||
assert.ok(out.table.includes('✗ Not covered'), 'table must show not-covered row');
|
||||
});
|
||||
|
||||
test('[happy] check gap-analysis.plan-post returns block:false with all-covered summary when all REQ-IDs and D-IDs are in plans', () => {
|
||||
writeRequirements(path.join(tmpDir, '.planning'), ['REQ-01']);
|
||||
writeContext(phaseDir, [{ id: 'D-01', text: 'Use pattern X for consistency' }]);
|
||||
writePlan(phaseDir, '01', '# Plan 1\n\nImplements REQ-01 and D-01.\n');
|
||||
|
||||
const r = runGapCheck([phaseDir], tmpDir);
|
||||
assert.ok(r.success, `check failed: ${r.error}`);
|
||||
|
||||
const out = JSON.parse(r.output);
|
||||
assert.strictEqual(out.block, false);
|
||||
assert.strictEqual(out.enabled, true);
|
||||
assert.strictEqual(out.counts.total, 2, 'total must be 2 (1 req + 1 decision)');
|
||||
assert.strictEqual(out.counts.covered, 2, 'both items must be covered');
|
||||
// GENUINE: uncovered must be 0, not 1
|
||||
assert.strictEqual(out.counts.uncovered, 0, 'uncovered must be 0 when all covered');
|
||||
assert.ok(/all 2 items covered/i.test(out.summary), `summary must say "all 2 items covered", got: ${out.summary}`);
|
||||
});
|
||||
|
||||
test('[empty-resolution] check gap-analysis.plan-post returns block:false with empty rows when no REQUIREMENTS.md and no CONTEXT.md exist', () => {
|
||||
// No REQUIREMENTS.md, no CONTEXT.md — only a PLAN.md
|
||||
writePlan(phaseDir, '01', '# Plan\n\nSome content.\n');
|
||||
|
||||
const r = runGapCheck([phaseDir], tmpDir);
|
||||
assert.ok(r.success, `check failed: ${r.error}`);
|
||||
|
||||
const out = JSON.parse(r.output);
|
||||
assert.strictEqual(out.block, false);
|
||||
assert.strictEqual(out.enabled, true);
|
||||
// GENUINE: total must be 0 (nothing to check)
|
||||
assert.strictEqual(out.counts.total, 0, 'total must be 0 with no requirements/decisions');
|
||||
assert.strictEqual(out.counts.uncovered, 0);
|
||||
assert.ok(/no requirements or decisions/i.test(out.summary),
|
||||
`summary must mention "no requirements or decisions", got: ${out.summary}`);
|
||||
});
|
||||
|
||||
// ── Disabled gate tests ─────────────────────────────────────────────────────
|
||||
|
||||
test('[negative] check gap-analysis.plan-post returns enabled:false with block:false when workflow.post_planning_gaps=false', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify({ workflow: { post_planning_gaps: false } })
|
||||
);
|
||||
writeRequirements(path.join(tmpDir, '.planning'), ['REQ-01']);
|
||||
writePlan(phaseDir, '01', '# Plan\n\nImplements REQ-01.\n');
|
||||
|
||||
const r = runGapCheck([phaseDir], tmpDir);
|
||||
assert.ok(r.success, `check failed: ${r.error}`);
|
||||
|
||||
const out = JSON.parse(r.output);
|
||||
assert.strictEqual(out.block, false, 'block must be false when disabled');
|
||||
assert.strictEqual(out.passed, true);
|
||||
// GENUINE: enabled must be FALSE when gate is disabled
|
||||
assert.strictEqual(out.enabled, false, 'enabled must be false when post_planning_gaps=false');
|
||||
assert.strictEqual(out.table, '', 'table must be empty string when disabled');
|
||||
assert.ok(/disabled/i.test(out.summary), `summary must mention disabled, got: ${out.summary}`);
|
||||
assert.strictEqual(out.counts.total, 0);
|
||||
});
|
||||
|
||||
// ── Missing arg tests ───────────────────────────────────────────────────────
|
||||
|
||||
test('[negative] check gap-analysis.plan-post exits non-zero with error string when phaseDir argument is omitted', () => {
|
||||
// Pass only --raw, no phaseDir
|
||||
const r = runGsdTools(['check', 'gap-analysis.plan-post', '--raw'], tmpDir);
|
||||
// GENUINE: must fail, not succeed
|
||||
assert.strictEqual(r.success, false, 'must fail when phaseDir omitted');
|
||||
assert.strictEqual(r.exitCode, 1, 'exit code must be 1');
|
||||
assert.ok(r.error.includes('requires a phase-dir argument'),
|
||||
`stderr must say "requires a phase-dir argument", got: ${r.error}`);
|
||||
// Output should NOT be valid JSON (it's an error message, not JSON)
|
||||
let parsed;
|
||||
try { parsed = JSON.parse(r.output); } catch (_) { parsed = null; }
|
||||
assert.strictEqual(parsed, null, 'output must not be valid JSON when phase-dir is missing');
|
||||
});
|
||||
|
||||
// ── BVA: phaseReqIds=TBD ────────────────────────────────────────────────────
|
||||
|
||||
test('[bva] check gap-analysis.plan-post with phaseReqIds=TBD returns zero requirement rows but still reports CONTEXT.md decisions', () => {
|
||||
writeRequirements(path.join(tmpDir, '.planning'), ['OTHER-01', 'OTHER-02']);
|
||||
writeContext(phaseDir, [{ id: 'D-01', text: 'Use canonical pattern for this module' }]);
|
||||
writePlan(phaseDir, '01', '# Plan\n\nNo decisions addressed here.\n');
|
||||
|
||||
// TBD means: skip requirements, but still report CONTEXT.md decisions
|
||||
const r = runGapCheck([phaseDir, 'TBD'], tmpDir);
|
||||
assert.ok(r.success, `check failed: ${r.error}`);
|
||||
|
||||
const out = JSON.parse(r.output);
|
||||
assert.strictEqual(out.enabled, true);
|
||||
// GENUINE: only D-01 (from CONTEXT.md) — OTHER-01/OTHER-02 must be excluded
|
||||
assert.strictEqual(out.counts.total, 1, 'total must be 1 (only D-01 from CONTEXT.md)');
|
||||
// REQUIREMENTS.md rows must not appear
|
||||
assert.ok(!out.table.includes('OTHER-01'), 'OTHER-01 must not appear in table when phaseReqIds=TBD');
|
||||
assert.ok(!out.table.includes('OTHER-02'), 'OTHER-02 must not appear in table when phaseReqIds=TBD');
|
||||
// D-01 must appear
|
||||
assert.ok(out.table.includes('D-01'), 'D-01 from CONTEXT.md must still appear');
|
||||
});
|
||||
|
||||
// ── BVA: mapped REQ-ID absent from REQUIREMENTS.md ─────────────────────────
|
||||
|
||||
test('[bva] check gap-analysis.plan-post with mapped REQ-ID absent from REQUIREMENTS.md emits Missing-from-REQUIREMENTS.md status in table', () => {
|
||||
// REQUIREMENTS.md has only REQ-01, but phaseReqIds includes REQ-99 (absent)
|
||||
writeRequirements(path.join(tmpDir, '.planning'), ['REQ-01']);
|
||||
writePlan(phaseDir, '01', '# Plan\n\nImplements REQ-01.\n');
|
||||
|
||||
const r = runGapCheck([phaseDir, 'REQ-01,REQ-99'], tmpDir);
|
||||
assert.ok(r.success, `check failed: ${r.error}`);
|
||||
|
||||
const out = JSON.parse(r.output);
|
||||
assert.strictEqual(out.enabled, true);
|
||||
// GENUINE: uncovered must be 1 (REQ-99 is "missing" which counts as uncovered)
|
||||
assert.strictEqual(out.counts.uncovered, 1, 'uncovered must be 1 for missing REQ-99');
|
||||
assert.strictEqual(out.counts.total, 2, 'total must be 2 (REQ-01 + REQ-99)');
|
||||
assert.ok(out.table.includes('REQ-99'), 'table must include REQ-99');
|
||||
// GENUINE: the status row for REQ-99 must say "Missing from REQUIREMENTS.md"
|
||||
assert.ok(out.table.includes('Missing from REQUIREMENTS.md'),
|
||||
`table must contain "Missing from REQUIREMENTS.md" for REQ-99, got table: ${out.table}`);
|
||||
// REQ-01 must still be covered
|
||||
assert.ok(out.table.includes('✓ Covered'), 'REQ-01 must show as covered');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Section 3: Full pipeline — render-hooks → check dispatch ─────────────────
|
||||
|
||||
describe('Full pipeline: render-hooks plan:post discovers gate, then check dispatched', () => {
|
||||
let tmpDir;
|
||||
let phaseDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
const init = runGsdTools('config-ensure-section', tmpDir);
|
||||
assert.ok(init.success, `config-ensure-section failed: ${init.error}`);
|
||||
});
|
||||
|
||||
afterEach(() => cleanup(tmpDir));
|
||||
|
||||
test('[happy] Full pipeline: render-hooks plan:post discovers gate hook, then check dispatched with hook.check.query returns advisory table — gate never blocking', () => {
|
||||
writeRequirements(path.join(tmpDir, '.planning'), ['REQ-01', 'REQ-02']);
|
||||
writePlan(phaseDir, '01', '# Plan 1\n\nImplements REQ-01 only.\n');
|
||||
|
||||
// Step 1: discover the gate hook via render-hooks
|
||||
const hookResult = spawnRenderHooks('plan:post', tmpDir);
|
||||
assert.strictEqual(hookResult.status, 0, `render-hooks exited non-zero: ${hookResult.stderr}`);
|
||||
|
||||
const envelope = JSON.parse(hookResult.stdout);
|
||||
assert.strictEqual(envelope.activeHooks.length, 1, 'must discover exactly 1 gate hook');
|
||||
const hook = envelope.activeHooks[0];
|
||||
|
||||
// GENUINE: gate must be advisory (blocking=false)
|
||||
assert.strictEqual(hook.blocking, false, 'gap-analysis gate must be non-blocking');
|
||||
assert.strictEqual(hook.check.query, 'gap-analysis.plan-post', 'check.query must be gap-analysis.plan-post');
|
||||
|
||||
// Step 2: dispatch the check using the discovered query
|
||||
const checkResult = runGapCheck([phaseDir], tmpDir);
|
||||
assert.ok(checkResult.success, `check failed: ${checkResult.error}`);
|
||||
|
||||
const out = JSON.parse(checkResult.output);
|
||||
// GENUINE: the check result must also say block:false
|
||||
assert.strictEqual(out.block, false, 'check must return block:false (advisory gate)');
|
||||
assert.strictEqual(out.counts.uncovered, 1, 'one uncovered item: REQ-02');
|
||||
assert.ok(out.table.length > 0, 'table must be non-empty');
|
||||
assert.ok(out.table.includes('REQ-01'), 'table must show REQ-01');
|
||||
assert.ok(out.table.includes('REQ-02'), 'table must show REQ-02');
|
||||
});
|
||||
|
||||
test('[happy] Full pipeline: when post_planning_gaps=true and all items covered, check returns zero uncovered', () => {
|
||||
writeRequirements(path.join(tmpDir, '.planning'), ['REQ-01']);
|
||||
writePlan(phaseDir, '01', '# Plan\n\nImplements REQ-01.\n');
|
||||
|
||||
// Confirm hook exists via render-hooks
|
||||
const hookResult = spawnRenderHooks('plan:post', tmpDir);
|
||||
assert.strictEqual(hookResult.status, 0);
|
||||
const envelope = JSON.parse(hookResult.stdout);
|
||||
assert.strictEqual(envelope.activeHooks.length, 1);
|
||||
|
||||
// Run the check
|
||||
const checkResult = runGapCheck([phaseDir], tmpDir);
|
||||
assert.ok(checkResult.success, `check failed: ${checkResult.error}`);
|
||||
const out = JSON.parse(checkResult.output);
|
||||
|
||||
assert.strictEqual(out.block, false);
|
||||
assert.strictEqual(out.enabled, true);
|
||||
assert.strictEqual(out.counts.total, 1);
|
||||
assert.strictEqual(out.counts.covered, 1);
|
||||
assert.strictEqual(out.counts.uncovered, 0);
|
||||
});
|
||||
|
||||
test('[negative] Full pipeline: when post_planning_gaps=false, render-hooks returns empty and check returns enabled:false — dual contract agreement', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify({ workflow: { post_planning_gaps: false } })
|
||||
);
|
||||
writeRequirements(path.join(tmpDir, '.planning'), ['REQ-01']);
|
||||
writePlan(phaseDir, '01', '# Plan\n\nSome content.\n');
|
||||
|
||||
// Step 1: render-hooks must return empty (gate suppressed)
|
||||
const hookResult = spawnRenderHooks('plan:post', tmpDir);
|
||||
assert.strictEqual(hookResult.status, 0);
|
||||
const envelope = JSON.parse(hookResult.stdout);
|
||||
// GENUINE: both render-hooks and check must agree on suppression
|
||||
assert.deepStrictEqual(envelope.activeHooks, [],
|
||||
'render-hooks must return empty activeHooks when gate disabled');
|
||||
assert.strictEqual(envelope.rendered, '_No active hooks at plan:post._');
|
||||
|
||||
// Step 2: check must return enabled:false, confirming dual-contract agreement
|
||||
writePlan(phaseDir, '01', '# Plan\n\nSome content.\n');
|
||||
const checkResult = runGapCheck([phaseDir], tmpDir);
|
||||
assert.ok(checkResult.success, `check failed: ${checkResult.error}`);
|
||||
const out = JSON.parse(checkResult.output);
|
||||
// GENUINE: enabled must be false (both surfaces agree gate is suppressed)
|
||||
assert.strictEqual(out.enabled, false,
|
||||
'check must return enabled:false when render-hooks also shows empty — dual contract parity');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Section 4: Pure resolver tests against real registry ────────────────────
|
||||
|
||||
describe('resolveLoopHooks plan:post — pure function against real registry', () => {
|
||||
const { resolveLoopHooks, renderLoopHooks } = require('../gsd-core/bin/lib/loop-resolver.cjs');
|
||||
const realRegistry = require('../gsd-core/bin/lib/capability-registry.cjs');
|
||||
|
||||
test('[happy] resolveLoopHooks plan:post with post_planning_gaps=true returns one gap-analysis gate', () => {
|
||||
const result = resolveLoopHooks({
|
||||
point: 'plan:post',
|
||||
registry: realRegistry,
|
||||
config: { workflow: { post_planning_gaps: true } },
|
||||
});
|
||||
assert.strictEqual(result.point, 'plan:post');
|
||||
assert.ok(Array.isArray(result.activeHooks));
|
||||
assert.strictEqual(result.activeHooks.length, 1, 'must be exactly 1 hook with post_planning_gaps=true');
|
||||
const hook = result.activeHooks[0];
|
||||
assert.strictEqual(hook.capId, 'gap-analysis');
|
||||
assert.strictEqual(hook.kind, 'gate');
|
||||
assert.strictEqual(hook.blocking, false);
|
||||
assert.strictEqual(hook.onError, 'skip');
|
||||
});
|
||||
|
||||
test('[negative] resolveLoopHooks plan:post with post_planning_gaps=false returns empty activeHooks', () => {
|
||||
const result = resolveLoopHooks({
|
||||
point: 'plan:post',
|
||||
registry: realRegistry,
|
||||
config: { workflow: { post_planning_gaps: false } },
|
||||
});
|
||||
assert.strictEqual(result.point, 'plan:post');
|
||||
// GENUINE: must be empty array (not length-1)
|
||||
assert.deepStrictEqual(result.activeHooks, [],
|
||||
'activeHooks must be empty when post_planning_gaps=false');
|
||||
});
|
||||
|
||||
test('[happy] renderLoopHooks plan:post with empty activeHooks returns exact placeholder string', () => {
|
||||
const result = resolveLoopHooks({
|
||||
point: 'plan:post',
|
||||
registry: realRegistry,
|
||||
config: { workflow: { post_planning_gaps: false } },
|
||||
});
|
||||
const rendered = renderLoopHooks(result);
|
||||
// GENUINE: must be exact placeholder, not a hook string
|
||||
assert.strictEqual(rendered, '_No active hooks at plan:post._',
|
||||
'rendered must be exact placeholder when no active hooks');
|
||||
});
|
||||
|
||||
test('[bva] resolveLoopHooks plan:post with absent config uses schema default (post_planning_gaps=true)', () => {
|
||||
// No workflow key in config → schema default should be true → hook active
|
||||
const result = resolveLoopHooks({
|
||||
point: 'plan:post',
|
||||
registry: realRegistry,
|
||||
config: {},
|
||||
});
|
||||
// GENUINE: schema default=true means the hook should activate even with empty config
|
||||
assert.strictEqual(result.activeHooks.length, 1,
|
||||
'schema default for post_planning_gaps is true — hook must activate with empty config');
|
||||
assert.strictEqual(result.activeHooks[0].capId, 'gap-analysis');
|
||||
});
|
||||
|
||||
test('[happy] real registry byLoopPoint plan:post has exactly one gate and no steps or contributions', () => {
|
||||
const entry = realRegistry.byLoopPoint['plan:post'];
|
||||
assert.ok(entry, 'plan:post must exist in byLoopPoint');
|
||||
assert.ok(Array.isArray(entry.steps), 'steps must be an array');
|
||||
assert.ok(Array.isArray(entry.contributions), 'contributions must be an array');
|
||||
assert.ok(Array.isArray(entry.gates), 'gates must be an array');
|
||||
assert.strictEqual(entry.steps.length, 0, 'plan:post must have zero steps');
|
||||
assert.strictEqual(entry.contributions.length, 0, 'plan:post must have zero contributions');
|
||||
assert.strictEqual(entry.gates.length, 1, 'plan:post must have exactly one gate');
|
||||
assert.strictEqual(entry.gates[0].capId, 'gap-analysis');
|
||||
});
|
||||
});
|
||||
463
tests/check-tdd-review-checkpoint-e2e.test.cjs
Normal file
463
tests/check-tdd-review-checkpoint-e2e.test.cjs
Normal file
@@ -0,0 +1,463 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* E2E content tests for execute:post hook resolution and tdd.review-checkpoint gate.
|
||||
*
|
||||
* Hook point: execute:post
|
||||
* Focus:
|
||||
* - loop render-hooks execute:post typed envelope (step + gate ordering, both-on / tdd-off / both-off)
|
||||
* - check tdd.review-checkpoint via CLI subprocess with real git fixtures:
|
||||
* RED+GREEN → block:false,violations:0,Pass
|
||||
* no commits → block:true,missing:[RED,GREEN]
|
||||
* RED only → block:true,missing:[GREEN]
|
||||
* no type:tdd plans → block:false,tddPlans:0
|
||||
* violations=1 boundary → block:true with advisory table
|
||||
* missing phase arg → exitCode:1
|
||||
* - rendered text format: Step 1 code-review before Gate tdd
|
||||
*
|
||||
* HARD RULES followed:
|
||||
* - CONTENT/E2E only: every test drives a real CLI subprocess or real resolver
|
||||
* - No readFileSync source-grep (scripts/lint-no-source-grep.cjs would reject it)
|
||||
* - Genuine assertions: negative/BVA cases assert the SPECIFIC differing value
|
||||
* - Fully isolated: each test has its own createTempProject / createTempGitProject
|
||||
* - Git fixtures use real file commits (not --allow-empty) so git log --grep -- . matches
|
||||
*/
|
||||
|
||||
const { describe, test, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { execFileSync, spawnSync } = require('node:child_process');
|
||||
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const TOOLS_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
|
||||
|
||||
// ─── Git fixture helper (inlined — do NOT modify helpers.cjs) ──────────────────
|
||||
|
||||
/**
|
||||
* Create a temp dir with a git repo and initial commit containing a .planning/
|
||||
* phases directory structure. Commits real files (not --allow-empty) so that
|
||||
* git log --grep -- . works correctly (the -- path filter skips empty-tree commits).
|
||||
*
|
||||
* Returns { tmpDir } — cleanup() in afterEach.
|
||||
*/
|
||||
function createTddGitFixture({ planFiles = [] } = {}) {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-tdd-e2e-'));
|
||||
|
||||
function git(...args) {
|
||||
const result = spawnSync('git', args, {
|
||||
cwd: tmpDir,
|
||||
encoding: 'utf-8',
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_AUTHOR_NAME: 'Test',
|
||||
GIT_AUTHOR_EMAIL: 'test@test.com',
|
||||
GIT_COMMITTER_NAME: 'Test',
|
||||
GIT_COMMITTER_EMAIL: 'test@test.com',
|
||||
},
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`git ${args.join(' ')} failed: ${result.stderr}`);
|
||||
}
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
git('init', '--initial-branch=main');
|
||||
git('config', 'user.email', 'test@test.com');
|
||||
git('config', 'user.name', 'Test');
|
||||
|
||||
// Create planning directory
|
||||
const planningDir = path.join(tmpDir, '.planning');
|
||||
const phasesDir = path.join(planningDir, 'phases');
|
||||
fs.mkdirSync(planningDir, { recursive: true });
|
||||
|
||||
// Write plan files
|
||||
for (const { dir, filename, content } of planFiles) {
|
||||
const phaseDir = path.join(phasesDir, dir);
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(phaseDir, filename), content, 'utf8');
|
||||
}
|
||||
|
||||
// Write a config.json with git tracking so initial commit has a real file
|
||||
fs.writeFileSync(path.join(planningDir, 'config.json'), '{}', 'utf8');
|
||||
|
||||
git('add', '.');
|
||||
git('commit', '-m', 'init: project scaffold');
|
||||
|
||||
return { tmpDir, git };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a type:tdd PLAN.md frontmatter block content.
|
||||
*/
|
||||
function tddPlan(phaseNum, planId) {
|
||||
return `---\ntype: tdd\nphase: ${phaseNum}\nslug: ${planId}\n---\n# Task: ${planId}\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a type:execute PLAN.md (non-TDD) content.
|
||||
*/
|
||||
function executePlan(phaseNum, planId) {
|
||||
return `---\ntype: execute\nphase: ${phaseNum}\nslug: ${planId}\n---\n# Task: ${planId}\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit a real file in the git fixture with the given commit message.
|
||||
* Needed because git log --grep with -- path filter only matches commits
|
||||
* that changed at least one tracked file.
|
||||
*/
|
||||
function commitFile(git, tmpDir, filename, commitMessage) {
|
||||
const filepath = path.join(tmpDir, filename);
|
||||
// Append timestamp to make each file unique
|
||||
fs.writeFileSync(filepath, `${commitMessage}\n${Date.now()}\n`, 'utf8');
|
||||
git('add', filepath);
|
||||
git('commit', '-m', commitMessage);
|
||||
}
|
||||
|
||||
// ─── Helpers for subprocess invocation ────────────────────────────────────────
|
||||
|
||||
const TEST_ENV_BASE = {
|
||||
GSD_SESSION_KEY: '',
|
||||
CODEX_THREAD_ID: '',
|
||||
CLAUDE_SESSION_ID: '',
|
||||
CLAUDE_CODE_SSE_PORT: '',
|
||||
OPENCODE_SESSION_ID: '',
|
||||
GEMINI_SESSION_ID: '',
|
||||
CURSOR_SESSION_ID: '',
|
||||
WINDSURF_SESSION_ID: '',
|
||||
TERM_SESSION_ID: '',
|
||||
WT_SESSION: '',
|
||||
TMUX_PANE: '',
|
||||
ZELLIJ_SESSION_NAME: '',
|
||||
TTY: '',
|
||||
SSH_TTY: '',
|
||||
};
|
||||
|
||||
function runTools(args, cwd) {
|
||||
const argv = Array.isArray(args)
|
||||
? args
|
||||
: (args.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || [])
|
||||
.map((t) => t.replace(/"([^"]*)"/g, '$1').replace(/'([^']*)'/g, '$1'));
|
||||
|
||||
try {
|
||||
const stdout = execFileSync(process.execPath, [TOOLS_PATH, ...argv], {
|
||||
cwd,
|
||||
encoding: 'utf-8',
|
||||
env: { ...process.env, ...TEST_ENV_BASE },
|
||||
timeout: 60000,
|
||||
});
|
||||
return { success: true, output: stdout.trim(), exitCode: 0, error: '' };
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
output: err.stdout?.toString().trim() || '',
|
||||
error: err.stderr?.toString().trim() || err.message,
|
||||
exitCode: err.status ?? 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('execute:post render-hooks — typed envelope resolution', () => {
|
||||
let tmpDir;
|
||||
|
||||
afterEach(() => { if (tmpDir) { cleanup(tmpDir); tmpDir = null; } });
|
||||
|
||||
test('[happy] tdd_mode=true and code_review=true: both hooks in typed shape with step before gate', () => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ep-'));
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify({ workflow: { code_review: true, tdd_mode: true } }),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const result = runTools('loop render-hooks execute:post --raw', tmpDir);
|
||||
assert.ok(result.success, `render-hooks should succeed. stderr: ${result.error}`);
|
||||
|
||||
const envelope = JSON.parse(result.output);
|
||||
assert.strictEqual(envelope.point, 'execute:post', 'point field must be execute:post');
|
||||
assert.ok(Array.isArray(envelope.activeHooks), 'activeHooks must be an array');
|
||||
assert.strictEqual(envelope.activeHooks.length, 2, 'both hooks (step + gate) must be active');
|
||||
|
||||
// Step 1: code-review step (must come before gate)
|
||||
const step = envelope.activeHooks[0];
|
||||
assert.strictEqual(step.kind, 'step', 'first hook must be a step');
|
||||
assert.strictEqual(step.capId, 'code-review', 'step capId must be code-review');
|
||||
assert.deepStrictEqual(step.ref, { skill: 'code-review' }, 'step ref must point to code-review skill');
|
||||
assert.ok(Array.isArray(step.produces), 'produces must be array');
|
||||
assert.ok(step.produces.includes('REVIEW.md'), 'step must produce REVIEW.md');
|
||||
assert.strictEqual(step.onError, 'skip', 'code-review step onError must be skip');
|
||||
|
||||
// Gate: tdd advisory gate (must come after step)
|
||||
const gate = envelope.activeHooks[1];
|
||||
assert.strictEqual(gate.kind, 'gate', 'second hook must be a gate');
|
||||
assert.strictEqual(gate.capId, 'tdd', 'gate capId must be tdd');
|
||||
assert.deepStrictEqual(gate.check, { query: 'tdd.review-checkpoint' }, 'gate check query must match');
|
||||
assert.strictEqual(gate.blocking, false, 'tdd gate must be advisory (blocking=false)');
|
||||
});
|
||||
|
||||
test('[negative] code_review=false and tdd_mode=false: empty activeHooks with no-hooks rendered text', () => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ep-'));
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify({ workflow: { code_review: false, tdd_mode: false } }),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const result = runTools('loop render-hooks execute:post --raw', tmpDir);
|
||||
assert.ok(result.success, `render-hooks should succeed even with both disabled. stderr: ${result.error}`);
|
||||
|
||||
const envelope = JSON.parse(result.output);
|
||||
assert.strictEqual(envelope.point, 'execute:post');
|
||||
// SPECIFIC assertion: 0 hooks, not 1 or 2
|
||||
assert.strictEqual(envelope.activeHooks.length, 0, 'both disabled: must return ZERO active hooks, not any');
|
||||
assert.ok(
|
||||
envelope.rendered.includes('_No active hooks at execute:post._'),
|
||||
`rendered must contain placeholder text, got: ${envelope.rendered}`
|
||||
);
|
||||
});
|
||||
|
||||
test('[negative] tdd_mode=false excludes tdd gate but code-review step active by schema default', () => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ep-'));
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify({ workflow: { tdd_mode: false } }),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const result = runTools('loop render-hooks execute:post --raw', tmpDir);
|
||||
assert.ok(result.success, `render-hooks should succeed. stderr: ${result.error}`);
|
||||
|
||||
const envelope = JSON.parse(result.output);
|
||||
// SPECIFIC assertion: exactly 1 hook (code-review only), not 0 or 2
|
||||
assert.strictEqual(envelope.activeHooks.length, 1, 'tdd_mode=false: exactly 1 hook (step only), not 2');
|
||||
assert.strictEqual(envelope.activeHooks[0].capId, 'code-review', 'sole hook must be code-review step');
|
||||
assert.strictEqual(envelope.activeHooks[0].kind, 'step', 'sole hook must be kind=step');
|
||||
// Confirm tdd gate is absent
|
||||
const tddHook = envelope.activeHooks.find((h) => h.capId === 'tdd');
|
||||
assert.strictEqual(tddHook, undefined, 'no tdd gate hook must be present when tdd_mode=false');
|
||||
});
|
||||
|
||||
test('[happy] rendered text format: Step 1 code-review before Gate tdd in correct markdown', () => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ep-'));
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify({ workflow: { code_review: true, tdd_mode: true } }),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const result = runTools('loop render-hooks execute:post --raw', tmpDir);
|
||||
assert.ok(result.success, `render-hooks should succeed. stderr: ${result.error}`);
|
||||
|
||||
const envelope = JSON.parse(result.output);
|
||||
const rendered = envelope.rendered;
|
||||
|
||||
// Step 1 code-review heading must appear first
|
||||
assert.ok(
|
||||
rendered.includes('### Step 1: skill:code-review (code-review)'),
|
||||
`rendered must start with Step 1 heading. got: ${rendered.slice(0, 200)}`
|
||||
);
|
||||
// produces and consumes in step section
|
||||
assert.ok(rendered.includes('produces: REVIEW.md'), 'rendered must include produces: REVIEW.md');
|
||||
assert.ok(rendered.includes('consumes: SUMMARY.md'), 'rendered must include consumes: SUMMARY.md');
|
||||
// when key for code-review step
|
||||
assert.ok(rendered.includes('when: `workflow.code_review`'), 'rendered must include when for code-review');
|
||||
// Gate tdd appears AFTER the step
|
||||
assert.ok(
|
||||
rendered.includes('**Gate** (tdd): check={"query":"tdd.review-checkpoint"}, blocking=false, onError=skip'),
|
||||
`rendered must include Gate tdd section. got: ${rendered}`
|
||||
);
|
||||
// Step 1 must come before the gate
|
||||
const step1Idx = rendered.indexOf('### Step 1');
|
||||
const gateIdx = rendered.indexOf('**Gate** (tdd)');
|
||||
assert.ok(step1Idx < gateIdx, 'Step 1 code-review must appear before Gate tdd in rendered text');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── check tdd.review-checkpoint via CLI — git fixture tests ───────────────────
|
||||
|
||||
describe('check tdd.review-checkpoint — CLI subprocess E2E with git fixtures', () => {
|
||||
test('[happy] RED+GREEN commits present: block:false, violations:0, status Pass', () => {
|
||||
const { tmpDir, git } = createTddGitFixture({
|
||||
planFiles: [
|
||||
{ dir: '01-phase1', filename: '01-01-PLAN.md', content: tddPlan(1, '01-01') },
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
// RED: failing test commit (must touch a real file for git log --grep -- . to work)
|
||||
commitFile(git, tmpDir, 'test-login.js', 'test(01-01): failing test for login');
|
||||
// GREEN: implementation commit
|
||||
commitFile(git, tmpDir, 'login.js', 'feat(01-01): implement login');
|
||||
|
||||
const result = runTools('check tdd.review-checkpoint 1 --raw', tmpDir);
|
||||
assert.ok(result.success, `check should succeed with exit 0. stderr: ${result.error}`);
|
||||
|
||||
const out = JSON.parse(result.output);
|
||||
assert.strictEqual(out.block, false, 'RED+GREEN present: block must be false, not true');
|
||||
assert.strictEqual(out.passed, true, 'passed must be true');
|
||||
assert.strictEqual(out.tddPlans, 1, 'must find 1 tdd plan');
|
||||
assert.strictEqual(out.violations, 0, 'violations must be 0 when both commits present');
|
||||
assert.ok(Array.isArray(out.rows), 'rows must be array');
|
||||
assert.strictEqual(out.rows.length, 1, 'must have 1 row');
|
||||
assert.strictEqual(out.rows[0].planId, '01-01', 'planId must be 01-01');
|
||||
assert.strictEqual(out.rows[0].red, true, 'red must be true');
|
||||
assert.strictEqual(out.rows[0].green, true, 'green must be true');
|
||||
assert.strictEqual(out.rows[0].status, 'Pass', 'status must be Pass');
|
||||
assert.strictEqual(out.rows[0].missing.length, 0, 'missing array must be empty');
|
||||
} finally {
|
||||
cleanup(tmpDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('[negative] type:tdd plan with no commits: block:true, violations:1, missing includes RED and GREEN', () => {
|
||||
const { tmpDir } = createTddGitFixture({
|
||||
planFiles: [
|
||||
{ dir: '01-phase1', filename: '01-01-PLAN.md', content: tddPlan(1, '01-01') },
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
// No additional commits — only the init commit exists
|
||||
|
||||
const result = runTools('check tdd.review-checkpoint 1 --raw', tmpDir);
|
||||
assert.ok(result.success, `check should exit 0 (advisory gate). stderr: ${result.error}`);
|
||||
|
||||
const out = JSON.parse(result.output);
|
||||
// SPECIFIC assertion: block must be TRUE (distinguishes from the passing case)
|
||||
assert.strictEqual(out.block, true, 'no commits: block must be TRUE, not false');
|
||||
assert.strictEqual(out.tddPlans, 1, 'must find 1 tdd plan');
|
||||
assert.strictEqual(out.violations, 1, 'violations must be 1');
|
||||
assert.strictEqual(out.rows[0].red, false, 'red must be false without test() commit');
|
||||
assert.strictEqual(out.rows[0].green, false, 'green must be false without feat() commit');
|
||||
assert.strictEqual(out.rows[0].status, 'FAIL', 'status must be FAIL');
|
||||
// missing must include both RED and GREEN
|
||||
assert.ok(out.rows[0].missing.includes('RED'), 'missing must include RED');
|
||||
assert.ok(out.rows[0].missing.includes('GREEN'), 'missing must include GREEN');
|
||||
} finally {
|
||||
cleanup(tmpDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('[negative] RED present but GREEN missing: block:true, violations:1, missing deepEqual [GREEN]', () => {
|
||||
const { tmpDir, git } = createTddGitFixture({
|
||||
planFiles: [
|
||||
{ dir: '01-phase1', filename: '01-01-PLAN.md', content: tddPlan(1, '01-01') },
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
// Only RED commit — no feat() commit
|
||||
commitFile(git, tmpDir, 'test-auth.js', 'test(01-01): failing auth test');
|
||||
|
||||
const result = runTools('check tdd.review-checkpoint 1 --raw', tmpDir);
|
||||
assert.ok(result.success, `check should exit 0. stderr: ${result.error}`);
|
||||
|
||||
const out = JSON.parse(result.output);
|
||||
// SPECIFIC assertion: block true, violations 1
|
||||
assert.strictEqual(out.block, true, 'RED only: block must be true');
|
||||
assert.strictEqual(out.violations, 1, 'violations must be exactly 1');
|
||||
assert.strictEqual(out.rows[0].red, true, 'red must be true (commit present)');
|
||||
assert.strictEqual(out.rows[0].green, false, 'green must be false (no feat commit)');
|
||||
assert.strictEqual(out.rows[0].status, 'FAIL', 'status must be FAIL');
|
||||
// missing must be exactly ['GREEN'] — not ['RED', 'GREEN']
|
||||
assert.deepStrictEqual(out.rows[0].missing, ['GREEN'], 'missing must deepEqual [GREEN] when RED present');
|
||||
} finally {
|
||||
cleanup(tmpDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('[empty-resolution] no type:tdd plans (type:execute only): block:false, tddPlans:0, empty rows', () => {
|
||||
const { tmpDir } = createTddGitFixture({
|
||||
planFiles: [
|
||||
{ dir: '01-phase1', filename: '01-01-PLAN.md', content: executePlan(1, '01-01') },
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
const result = runTools('check tdd.review-checkpoint 1 --raw', tmpDir);
|
||||
assert.ok(result.success, `check should succeed with exit 0. stderr: ${result.error}`);
|
||||
|
||||
const out = JSON.parse(result.output);
|
||||
// SPECIFIC assertion: block false AND tddPlans 0 (distinguishes from a plan that passes)
|
||||
assert.strictEqual(out.block, false, 'no tdd plans: block must be false');
|
||||
assert.strictEqual(out.tddPlans, 0, 'tddPlans must be 0 when no type:tdd files');
|
||||
assert.strictEqual(out.violations, 0, 'violations must be 0');
|
||||
assert.strictEqual(out.rows.length, 0, 'rows must be empty array');
|
||||
assert.strictEqual(out.table, '', 'table must be empty string when no tdd plans');
|
||||
} finally {
|
||||
cleanup(tmpDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('[bva] violations=1 boundary: exactly 1 violation sets block:true and advisory message present', () => {
|
||||
// Two tdd plans: 01-01 passes (both commits), 01-02 fails (no commits)
|
||||
// violations = 1 exactly — boundary test (violations > 0 → block:true)
|
||||
const { tmpDir, git } = createTddGitFixture({
|
||||
planFiles: [
|
||||
{ dir: '01-phase1', filename: '01-01-PLAN.md', content: tddPlan(1, '01-01') },
|
||||
{ dir: '01-phase1', filename: '01-02-PLAN.md', content: tddPlan(1, '01-02') },
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
// 01-01: both RED and GREEN commits (passes)
|
||||
commitFile(git, tmpDir, 'test1.js', 'test(01-01): failing test');
|
||||
commitFile(git, tmpDir, 'impl1.js', 'feat(01-01): implementation');
|
||||
// 01-02: no commits (fails)
|
||||
|
||||
const result = runTools('check tdd.review-checkpoint 1 --raw', tmpDir);
|
||||
assert.ok(result.success, `check should exit 0. stderr: ${result.error}`);
|
||||
|
||||
const out = JSON.parse(result.output);
|
||||
// SPECIFIC: block must be TRUE for violations=1 (not false as it would be for violations=0)
|
||||
assert.strictEqual(out.block, true, 'violations=1 boundary: block must be true');
|
||||
assert.strictEqual(out.violations, 1, 'violations must be exactly 1 (not 0, not 2)');
|
||||
assert.strictEqual(out.tddPlans, 2, 'tddPlans must be 2');
|
||||
assert.strictEqual(out.passed, true, 'advisory gate: passed stays true');
|
||||
|
||||
// Check both rows
|
||||
const passRow = out.rows.find((r) => r.planId === '01-01');
|
||||
const failRow = out.rows.find((r) => r.planId === '01-02');
|
||||
assert.ok(passRow, '01-01 row must exist');
|
||||
assert.ok(failRow, '01-02 row must exist');
|
||||
assert.strictEqual(passRow.status, 'Pass', '01-01 must Pass');
|
||||
assert.strictEqual(failRow.status, 'FAIL', '01-02 must FAIL');
|
||||
|
||||
// Advisory table must mention the warning text
|
||||
assert.ok(
|
||||
out.table.includes('⚠ Gate violations are advisory'),
|
||||
'table must include advisory warning when violations > 0'
|
||||
);
|
||||
} finally {
|
||||
cleanup(tmpDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('[negative] missing phase argument: exitCode 1 and error contains required message', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-tdd-noarg-'));
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
|
||||
|
||||
try {
|
||||
const result = runTools('check tdd.review-checkpoint --raw', tmpDir);
|
||||
// SPECIFIC: exitCode must be 1 (non-zero), not 0
|
||||
assert.strictEqual(result.success, false, 'missing phase arg must cause failure (success=false)');
|
||||
assert.strictEqual(result.exitCode, 1, 'exitCode must be 1, not 0');
|
||||
// Error message must identify the command and what's missing
|
||||
const errText = result.error + result.output;
|
||||
assert.ok(
|
||||
errText.includes('tdd.review-checkpoint') || errText.includes('phase argument'),
|
||||
`error must reference tdd.review-checkpoint or phase argument. got: ${errText}`
|
||||
);
|
||||
} finally {
|
||||
cleanup(tmpDir);
|
||||
}
|
||||
});
|
||||
});
|
||||
259
tests/check-ui-safety-gate.test.cjs
Normal file
259
tests/check-ui-safety-gate.test.cjs
Normal file
@@ -0,0 +1,259 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Behavioral tests for the `check ui-safety-gate` subcommand (#1168).
|
||||
*
|
||||
* Tests the `computeUiSafetyGate` pure function exported from check-command-router.cjs.
|
||||
* Uses in-memory tmpdir fixtures — no real CLI subprocess needed.
|
||||
*
|
||||
* Return shape: { frontend: bool, hasUiFiles: bool, hasUiSpec: bool, block: bool, message?: string }
|
||||
* Invariant: block = frontend && hasUiFiles && !hasUiSpec
|
||||
*
|
||||
* Per RULESET.TESTS.boundary-coverage: exercises all branches:
|
||||
* (a) frontend + UI files changed + no spec → block:true
|
||||
* (b) frontend + UI files changed + spec exists → block:false
|
||||
* (c) non-frontend → block:false
|
||||
* (d) frontend + no UI files changed → block:false
|
||||
*
|
||||
* Per RULESET.TESTS.coderabbit-fix-prefer: calls the exported function and asserts typed fields.
|
||||
*/
|
||||
|
||||
const { describe, test, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
const { computeUiSafetyGate } = require('../gsd-core/bin/lib/check-command-router.cjs');
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create a minimal project dir with:
|
||||
* .planning/ROADMAP.md — one phase section with `phaseSection` body
|
||||
* .planning/phases/01-test-phase/ — phase directory
|
||||
* (optionally) a *-UI-SPEC.md inside the phase dir
|
||||
*/
|
||||
function makeProject({ phaseSection = '', hasUiSpec = false } = {}) {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ui-safety-gate-test-'));
|
||||
const planningDir = path.join(tmpDir, '.planning');
|
||||
const phasesDir = path.join(planningDir, 'phases');
|
||||
const phaseDir = path.join(phasesDir, '01-test-phase');
|
||||
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(planningDir, 'config.json'), JSON.stringify({}), 'utf8');
|
||||
|
||||
// Minimal ROADMAP.md with one phase section
|
||||
const roadmapContent = [
|
||||
'# Project Roadmap',
|
||||
'',
|
||||
'## Phase 1: Test Phase',
|
||||
'',
|
||||
phaseSection,
|
||||
'',
|
||||
].join('\n');
|
||||
fs.writeFileSync(path.join(planningDir, 'ROADMAP.md'), roadmapContent, 'utf8');
|
||||
|
||||
if (hasUiSpec) {
|
||||
fs.writeFileSync(path.join(phaseDir, '01-UI-SPEC.md'), '# UI Design Contract\n', 'utf8');
|
||||
}
|
||||
|
||||
return { tmpDir, phaseDir };
|
||||
}
|
||||
|
||||
// ─── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('computeUiSafetyGate — ui.safety-gate check logic (#1168)', () => {
|
||||
let frontendNoSpec, frontendWithSpec, nonFrontend;
|
||||
|
||||
before(() => {
|
||||
// Branch (a): frontend + no UI-SPEC → tests block behavior
|
||||
frontendNoSpec = makeProject({
|
||||
phaseSection: 'Build the user interface and dashboard components for the frontend.',
|
||||
hasUiSpec: false,
|
||||
});
|
||||
// Branch (b): frontend + UI-SPEC exists → block:false
|
||||
frontendWithSpec = makeProject({
|
||||
phaseSection: 'Build the frontend dashboard with React components and UI forms.',
|
||||
hasUiSpec: true,
|
||||
});
|
||||
// Branch (c): no frontend indicators → block:false
|
||||
nonFrontend = makeProject({
|
||||
phaseSection: 'Add a REST API endpoint and database migration for the user table.',
|
||||
hasUiSpec: false,
|
||||
});
|
||||
});
|
||||
|
||||
after(() => {
|
||||
for (const { tmpDir } of [frontendNoSpec, frontendWithSpec, nonFrontend]) {
|
||||
try { cleanup(tmpDir); } catch { /* ignore */ }
|
||||
}
|
||||
});
|
||||
|
||||
describe('return shape', () => {
|
||||
test('result has required keys: frontend, hasUiFiles, hasUiSpec, block', () => {
|
||||
const result = computeUiSafetyGate(nonFrontend.tmpDir, '1');
|
||||
assert.ok(typeof result === 'object' && result !== null, 'result must be an object');
|
||||
assert.ok(typeof result.frontend === 'boolean', 'frontend must be boolean');
|
||||
assert.ok(typeof result.hasUiFiles === 'boolean', 'hasUiFiles must be boolean');
|
||||
assert.ok(typeof result.hasUiSpec === 'boolean', 'hasUiSpec must be boolean');
|
||||
assert.ok(typeof result.block === 'boolean', 'block must be boolean');
|
||||
});
|
||||
|
||||
test('block invariant: block === frontend && hasUiFiles && !hasUiSpec for all scenarios', () => {
|
||||
for (const [label, { tmpDir }] of [
|
||||
['frontendNoSpec', frontendNoSpec],
|
||||
['frontendWithSpec', frontendWithSpec],
|
||||
['nonFrontend', nonFrontend],
|
||||
]) {
|
||||
const r = computeUiSafetyGate(tmpDir, '1');
|
||||
assert.strictEqual(
|
||||
r.block,
|
||||
r.frontend && r.hasUiFiles && !r.hasUiSpec,
|
||||
`${label}: block invariant violated — frontend=${r.frontend} hasUiFiles=${r.hasUiFiles} hasUiSpec=${r.hasUiSpec} block=${r.block}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('branch (a) — frontend + no UI-SPEC → gate fires when hasUiFiles', () => {
|
||||
test('detects frontend indicators in phase section', () => {
|
||||
const r = computeUiSafetyGate(frontendNoSpec.tmpDir, '1');
|
||||
assert.strictEqual(r.frontend, true, 'should detect frontend indicators');
|
||||
});
|
||||
|
||||
test('hasUiSpec is false when no *-UI-SPEC.md exists', () => {
|
||||
const r = computeUiSafetyGate(frontendNoSpec.tmpDir, '1');
|
||||
assert.strictEqual(r.hasUiSpec, false, 'hasUiSpec must be false');
|
||||
});
|
||||
|
||||
test('block is true when frontend + hasUiFiles + no UI-SPEC', () => {
|
||||
// hasUiFiles depends on git state; when false, block must also be false (invariant).
|
||||
// We verify the invariant holds rather than hardcoding the git state.
|
||||
const r = computeUiSafetyGate(frontendNoSpec.tmpDir, '1');
|
||||
assert.strictEqual(r.block, r.frontend && r.hasUiFiles && !r.hasUiSpec,
|
||||
'block invariant: frontend && hasUiFiles && !hasUiSpec');
|
||||
});
|
||||
|
||||
test('message is present when block is true', () => {
|
||||
const r = computeUiSafetyGate(frontendNoSpec.tmpDir, '1');
|
||||
if (r.block) {
|
||||
assert.ok(typeof r.message === 'string' && r.message.length > 0,
|
||||
'message must be a non-empty string when block is true');
|
||||
assert.ok(r.message.includes('UI-SPEC'), 'message must reference UI-SPEC');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('branch (b) — frontend + UI-SPEC exists → block:false', () => {
|
||||
test('detects frontend indicators in phase section', () => {
|
||||
const r = computeUiSafetyGate(frontendWithSpec.tmpDir, '1');
|
||||
assert.strictEqual(r.frontend, true, 'should detect frontend indicators');
|
||||
});
|
||||
|
||||
test('hasUiSpec is true when *-UI-SPEC.md exists', () => {
|
||||
const r = computeUiSafetyGate(frontendWithSpec.tmpDir, '1');
|
||||
assert.strictEqual(r.hasUiSpec, true, 'hasUiSpec must be true');
|
||||
});
|
||||
|
||||
test('block is false when UI-SPEC exists (regardless of hasUiFiles)', () => {
|
||||
const r = computeUiSafetyGate(frontendWithSpec.tmpDir, '1');
|
||||
assert.strictEqual(r.block, false, 'block must be false when spec exists');
|
||||
});
|
||||
|
||||
test('message is absent when block is false', () => {
|
||||
const r = computeUiSafetyGate(frontendWithSpec.tmpDir, '1');
|
||||
assert.ok(!r.message || r.message === undefined,
|
||||
'message must be absent when block is false');
|
||||
});
|
||||
});
|
||||
|
||||
describe('branch (c) — non-frontend phase → block:false', () => {
|
||||
test('frontend is false for non-UI phase section', () => {
|
||||
const r = computeUiSafetyGate(nonFrontend.tmpDir, '1');
|
||||
assert.strictEqual(r.frontend, false, 'should NOT detect frontend indicators');
|
||||
});
|
||||
|
||||
test('block is false for non-frontend phases', () => {
|
||||
const r = computeUiSafetyGate(nonFrontend.tmpDir, '1');
|
||||
assert.strictEqual(r.block, false, 'block must be false');
|
||||
});
|
||||
});
|
||||
|
||||
describe('graceful degradation', () => {
|
||||
test('non-existent project dir returns frontend:false, block:false (no crash)', () => {
|
||||
const r = computeUiSafetyGate('/tmp/nonexistent-gsd-test-dir-xyz', '1');
|
||||
assert.strictEqual(typeof r.frontend, 'boolean', 'frontend must be boolean');
|
||||
assert.strictEqual(r.frontend, false, 'missing roadmap → no frontend indicators');
|
||||
assert.strictEqual(r.block, false, 'missing roadmap → block false');
|
||||
assert.strictEqual(typeof r.hasUiFiles, 'boolean', 'hasUiFiles must be boolean');
|
||||
assert.strictEqual(typeof r.hasUiSpec, 'boolean', 'hasUiSpec must be boolean');
|
||||
});
|
||||
|
||||
test('missing ROADMAP.md returns frontend:false gracefully (no phaseLookupFailed)', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ui-safety-nomap-'));
|
||||
try {
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-phase'), { recursive: true });
|
||||
const r = computeUiSafetyGate(tmpDir, '1');
|
||||
assert.strictEqual(r.frontend, false, 'no ROADMAP → no frontend indicators');
|
||||
assert.strictEqual(r.block, false, 'no ROADMAP → no block');
|
||||
assert.ok(
|
||||
!r.phaseLookupFailed,
|
||||
'phaseLookupFailed must NOT be set when ROADMAP.md is absent (no-roadmap project is not a lookup failure)',
|
||||
);
|
||||
} finally {
|
||||
try { cleanup(tmpDir); } catch { /* ignore */ }
|
||||
}
|
||||
});
|
||||
|
||||
test('ROADMAP.md present but phase not found → phaseLookupFailed:true (not silent false)', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ui-safety-noPhase-'));
|
||||
try {
|
||||
const planningDir = path.join(tmpDir, '.planning');
|
||||
const phasesDir = path.join(planningDir, 'phases');
|
||||
fs.mkdirSync(path.join(phasesDir, '01-test-phase'), { recursive: true });
|
||||
fs.writeFileSync(path.join(planningDir, 'ROADMAP.md'), [
|
||||
'# Project Roadmap',
|
||||
'',
|
||||
'## Phase 1: Test Phase',
|
||||
'',
|
||||
'Build the frontend dashboard with React components.',
|
||||
'',
|
||||
].join('\n'), 'utf8');
|
||||
// Phase 99 is not in the roadmap
|
||||
const r = computeUiSafetyGate(tmpDir, '99');
|
||||
assert.strictEqual(r.phaseLookupFailed, true,
|
||||
'phaseLookupFailed must be true when ROADMAP.md exists but phase is not found');
|
||||
assert.strictEqual(r.frontend, false, 'empty section → no frontend indicators');
|
||||
} finally {
|
||||
try { cleanup(tmpDir); } catch { /* ignore */ }
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('routing — ui-safety-gate is routable via check-command-router', () => {
|
||||
test('routeCheckCommand routes ui-safety-gate (hyphen form)', () => {
|
||||
const { routeCheckCommand } = require('../gsd-core/bin/lib/check-command-router.cjs');
|
||||
// Should not throw; just verify routing works (output goes to stdout)
|
||||
let threw = false;
|
||||
try {
|
||||
routeCheckCommand({ args: ['check', 'ui-safety-gate', '1'], cwd: nonFrontend.tmpDir, raw: true });
|
||||
} catch (err) {
|
||||
threw = true;
|
||||
}
|
||||
assert.strictEqual(threw, false, 'routeCheckCommand must not throw for ui-safety-gate');
|
||||
});
|
||||
|
||||
test('routeCheckCommand routes ui.safety-gate (dot form — normalized to hyphens)', () => {
|
||||
const { routeCheckCommand } = require('../gsd-core/bin/lib/check-command-router.cjs');
|
||||
let threw = false;
|
||||
try {
|
||||
routeCheckCommand({ args: ['check', 'ui.safety-gate', '1'], cwd: nonFrontend.tmpDir, raw: true });
|
||||
} catch (err) {
|
||||
threw = true;
|
||||
}
|
||||
assert.strictEqual(threw, false, 'routeCheckCommand must not throw for ui.safety-gate (dot form)');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -469,17 +469,44 @@ describe('detectDrift — non-blocking guarantee', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Config validation: new keys present and restricted ──────────────────────
|
||||
// ─── Config validation: drift keys owned by the drift capability ──────────────
|
||||
//
|
||||
// After ADR-857 phase-6 migration, workflow.drift_threshold and workflow.drift_action
|
||||
// are no longer in the central config schema manifest (VALID_CONFIG_KEYS). They are
|
||||
// federated config keys owned exclusively by the `drift` capability in the registry.
|
||||
// VALID_CONFIG_KEYS covers central-only keys; capability-owned keys resolve through
|
||||
// the federated config overlay (loadConfig still returns them at their defaults).
|
||||
|
||||
const CAPABILITY_REGISTRY_PATH = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'gsd-core',
|
||||
'bin',
|
||||
'lib',
|
||||
'capability-registry.cjs',
|
||||
);
|
||||
|
||||
describe('config-schema — drift keys', () => {
|
||||
test('workflow.drift_threshold in VALID_CONFIG_KEYS', () => {
|
||||
const { VALID_CONFIG_KEYS } = require(CONFIG_SCHEMA_PATH);
|
||||
assert.ok(VALID_CONFIG_KEYS.has('workflow.drift_threshold'));
|
||||
test('workflow.drift_threshold owned by drift capability (not central)', () => {
|
||||
const { isCentralConfigKey } = require(CONFIG_SCHEMA_PATH);
|
||||
const registry = require(CAPABILITY_REGISTRY_PATH);
|
||||
// Must be owned by the drift capability
|
||||
assert.strictEqual(registry.configKeys['workflow.drift_threshold'], 'drift',
|
||||
'workflow.drift_threshold must be owned by the drift capability');
|
||||
// Must NOT be in central schema (migration complete)
|
||||
assert.strictEqual(isCentralConfigKey('workflow.drift_threshold'), false,
|
||||
'workflow.drift_threshold must not be a central config key after capability migration');
|
||||
});
|
||||
|
||||
test('workflow.drift_action in VALID_CONFIG_KEYS', () => {
|
||||
const { VALID_CONFIG_KEYS } = require(CONFIG_SCHEMA_PATH);
|
||||
assert.ok(VALID_CONFIG_KEYS.has('workflow.drift_action'));
|
||||
test('workflow.drift_action owned by drift capability (not central)', () => {
|
||||
const { isCentralConfigKey } = require(CONFIG_SCHEMA_PATH);
|
||||
const registry = require(CAPABILITY_REGISTRY_PATH);
|
||||
// Must be owned by the drift capability
|
||||
assert.strictEqual(registry.configKeys['workflow.drift_action'], 'drift',
|
||||
'workflow.drift_action must be owned by the drift capability');
|
||||
// Must NOT be in central schema (migration complete)
|
||||
assert.strictEqual(isCentralConfigKey('workflow.drift_action'), false,
|
||||
'workflow.drift_action must not be a central config key after capability migration');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -571,9 +598,19 @@ describe('gsd-codebase-mapper --paths flag', () => {
|
||||
});
|
||||
|
||||
// ─── Execute-phase workflow integration ──────────────────────────────────────
|
||||
//
|
||||
// After ADR-857 phase-6 migration, codebase_drift_gate is no longer an inline
|
||||
// step in execute-phase.md. Instead, it is declared as a gate in the `drift`
|
||||
// capability at the `execute:wave:post` hook point. The execute-phase.md
|
||||
// dispatches capability gates via `gsd_run loop render-hooks execute:wave:post`,
|
||||
// which fires the drift gates automatically.
|
||||
|
||||
describe('execute-phase integrates codebase_drift_gate', () => {
|
||||
test('workflow references a codebase drift step', () => {
|
||||
// After capability migration: the drift gate fires via execute:wave:post
|
||||
// render-hooks dispatch. Verify two things:
|
||||
// 1. execute-phase.md has the execute:wave:post render-hooks call site.
|
||||
// 2. The drift capability declares a codebase-drift gate at execute:wave:post.
|
||||
const doc = fs.readFileSync(
|
||||
path.join(
|
||||
__dirname,
|
||||
@@ -584,7 +621,25 @@ describe('execute-phase integrates codebase_drift_gate', () => {
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
assert.ok(/codebase_drift_gate|codebase-drift/.test(doc));
|
||||
// execute-phase.md must dispatch execute:wave:post hooks (the call site that fires drift gates)
|
||||
assert.ok(
|
||||
/loop render-hooks execute:wave:post/.test(doc),
|
||||
'execute-phase.md must dispatch execute:wave:post hooks (drift capability gate call site)',
|
||||
);
|
||||
// The drift capability must declare a codebase-drift gate at execute:wave:post
|
||||
const registry = require(CAPABILITY_REGISTRY_PATH);
|
||||
const driftCap = registry.capabilities['drift'];
|
||||
assert.ok(driftCap, 'drift capability must be registered');
|
||||
const codebaseDriftGate = (driftCap.gates || []).find(
|
||||
(g) => g.check && /codebase.drift/i.test(g.check.query),
|
||||
);
|
||||
assert.ok(
|
||||
codebaseDriftGate,
|
||||
'drift capability must declare a codebase-drift gate at execute:wave:post',
|
||||
);
|
||||
assert.strictEqual(codebaseDriftGate.point, 'execute:wave:post');
|
||||
assert.strictEqual(codebaseDriftGate.blocking, false,
|
||||
'codebase-drift gate must be non-blocking by contract');
|
||||
});
|
||||
|
||||
test('workflow documents non-blocking guarantee for drift', () => {
|
||||
|
||||
@@ -15,14 +15,38 @@ const WORKFLOW = path.join(__dirname, '..', 'gsd-core', 'workflows', 'execute-ph
|
||||
function parseGateContract(content) {
|
||||
const lines = content.split(/\r?\n/);
|
||||
const lowerLines = lines.map(line => line.toLowerCase());
|
||||
|
||||
// Detect whether the proceed-past-tdd-escalation is conditional on the MVP+TDD block
|
||||
// being ABSENT (correct) or unconditional (contradicts the block — regression).
|
||||
//
|
||||
// A contradicting unconditional proceed looks like:
|
||||
// "regardless ... gate results ... always proceed"
|
||||
// That pattern is illegal once the MVP+TDD block is documented, because it nullifies it.
|
||||
// The proceed must be guarded ("if ... not blocked ... proceed").
|
||||
const hasUnconditionalProceed = /regardless[\s\S]{0,60}gate results[\s\S]{0,60}always proceed/i.test(content)
|
||||
|| /always proceed[\s\S]{0,80}regardless/i.test(content);
|
||||
|
||||
// The corrected proceed must co-occur with a conditional guard near the block mention.
|
||||
// We accept either: explicit conditional keyword ("if ... not ... block" / "otherwise proceed")
|
||||
// adjacent to the block text, OR absence of the unconditional pattern altogether.
|
||||
const hasProceedConditional = !hasUnconditionalProceed;
|
||||
|
||||
return {
|
||||
hasMvpModeVariable: lowerLines.some(line => line.includes('mvp_mode')),
|
||||
hasRoadmapModeResolution: lowerLines.some(line => line.includes('phase.mvp-mode') || line.includes('roadmap') && line.includes('mode')),
|
||||
hasDualGateCondition: lowerLines.some(line => line.includes('mvp_mode') && line.includes('tdd_mode')),
|
||||
hasGateLabel: lowerLines.some(line => line.includes('mvp+tdd gate') || line.includes('mvp-tdd gate')),
|
||||
hasRedCommitRule: lowerLines.some(line => line.includes('failing-test commit') || line.includes('missing red commit') || line.includes('test(')),
|
||||
hasBlockingEscalation: lowerLines.some(line => line.includes('blocking') && line.includes('mvp+tdd')),
|
||||
// Must assert the REAL refusal semantics, not merely the words "blocking" + "mvp+tdd"
|
||||
// (which "advisory (blocking: false) ... under MVP+TDD" would satisfy as a false green).
|
||||
hasBlockingEscalation:
|
||||
content.toLowerCase().includes('mvp+tdd')
|
||||
&& (content.toLowerCase().includes('refuse to mark the phase complete')
|
||||
|| content.toLowerCase().includes('phase blocked')),
|
||||
hasReferenceDoc: lowerLines.some(line => line.includes('execute-mvp-tdd.md')),
|
||||
// Must NOT have an unconditional "proceed regardless of gate results" that overrides the block.
|
||||
// hasProceedConditional is true when the proceed is properly gated (or absent entirely).
|
||||
hasProceedConditional,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -47,6 +71,13 @@ describe('execute-phase — MVP+TDD gate', () => {
|
||||
assert.ok(contract.hasBlockingEscalation, 'must escalate end-of-phase review to blocking');
|
||||
});
|
||||
|
||||
test('proceed past TDD escalation is conditional — not an unconditional override', () => {
|
||||
assert.ok(
|
||||
contract.hasProceedConditional,
|
||||
'workflow must NOT contain an unconditional "regardless of gate results, ALWAYS proceed" that nullifies the MVP+TDD block; the proceed must be guarded by the absence of an MVP+TDD block',
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow references execute-mvp-tdd.md', () => {
|
||||
assert.ok(contract.hasReferenceDoc, 'must reference the gate semantics file');
|
||||
});
|
||||
|
||||
640
tests/execute-wave-post-gate-pipeline-e2e.test.cjs
Normal file
640
tests/execute-wave-post-gate-pipeline-e2e.test.cjs
Normal file
@@ -0,0 +1,640 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* execute-wave-post-gate-pipeline-e2e.test.cjs
|
||||
*
|
||||
* ADR-857 Phase 6 capstone E2E content tests for the execute:wave:post hook pipeline.
|
||||
*
|
||||
* Hook: execute:wave:post
|
||||
* Three gates registered in the real capability-registry.cjs:
|
||||
* 1. drift / verify.schema-drift — blocking=true, onError=skip
|
||||
* 2. drift / verify.codebase-drift — blocking=false, onError=skip
|
||||
* 3. ui / ui.safety-gate — blocking=true, onError=halt
|
||||
*
|
||||
* Focus areas:
|
||||
* A. loop render-hooks execute:wave:post — resolution full/partial/none
|
||||
* B. check verify.schema-drift — no-schema/block/GSD_SKIP_SCHEMA_CHECK bypass
|
||||
* C. check verify.codebase-drift — BVA threshold-1/threshold/auto-remap/no-STRUCTURE.md
|
||||
* D. check ui.safety-gate — frontend+UI-file/+spec/missing-arg
|
||||
* E. Full pipeline chain (render-hooks → dispatch each gate)
|
||||
*
|
||||
* All tests drive real CLI commands or real resolver functions.
|
||||
* No readFileSync source-grep.
|
||||
*/
|
||||
|
||||
const { describe, test, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const GSD_TOOLS = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
|
||||
|
||||
// ─── Test-local git helper ───────────────────────────────────────────────────
|
||||
// Inline — NOT modifying tests/helpers.cjs per task rules.
|
||||
|
||||
function gitSync(args, cwd) {
|
||||
const r = spawnSync('git', args, { cwd, encoding: 'utf8', env: { ...process.env, GIT_AUTHOR_NAME: 'Test', GIT_AUTHOR_EMAIL: 'test@test.com', GIT_COMMITTER_NAME: 'Test', GIT_COMMITTER_EMAIL: 'test@test.com' } });
|
||||
if (r.status !== 0) throw new Error(`git ${args.join(' ')} failed: ${r.stderr}`);
|
||||
return r.stdout.trim();
|
||||
}
|
||||
|
||||
function initGitRepo(dir) {
|
||||
gitSync(['init'], dir);
|
||||
gitSync(['config', 'user.email', 'test@test.com'], dir);
|
||||
gitSync(['config', 'user.name', 'Test'], dir);
|
||||
gitSync(['config', 'commit.gpgsign', 'false'], dir);
|
||||
}
|
||||
|
||||
function gitAddCommit(dir, message) {
|
||||
gitSync(['add', '-A'], dir);
|
||||
gitSync(['commit', '--allow-empty', '-m', message], dir);
|
||||
}
|
||||
|
||||
// ─── GSD CLI runner ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Run gsd-tools and return { status, stdout, stderr, parsed? }.
|
||||
* When raw=true the tool emits JSON; parsed is set on success.
|
||||
*/
|
||||
function runTool(args, { cwd, env = {} } = {}) {
|
||||
const childEnv = {
|
||||
...process.env,
|
||||
GSD_SESSION_KEY: '',
|
||||
CODEX_THREAD_ID: '',
|
||||
CLAUDE_SESSION_ID: '',
|
||||
CLAUDE_CODE_SSE_PORT: '',
|
||||
...env,
|
||||
};
|
||||
const r = spawnSync(process.execPath, [GSD_TOOLS, ...args], {
|
||||
cwd: cwd || os.tmpdir(),
|
||||
encoding: 'utf8',
|
||||
env: childEnv,
|
||||
timeout: 60000,
|
||||
});
|
||||
const result = { status: r.status, stdout: r.stdout || '', stderr: r.stderr || '' };
|
||||
if (r.stdout && r.stdout.trim().startsWith('{')) {
|
||||
try { result.parsed = JSON.parse(r.stdout.trim()); } catch { /* non-JSON or partial */ }
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── Shared fixture teardown ─────────────────────────────────────────────────
|
||||
|
||||
const tmpDirs = [];
|
||||
function makeTmpDir() {
|
||||
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-wave-post-'));
|
||||
tmpDirs.push(d);
|
||||
return d;
|
||||
}
|
||||
|
||||
after(() => { for (const d of tmpDirs) { try { cleanup(d); } catch { /* best-effort */ } } });
|
||||
|
||||
// ─── Section A: loop render-hooks execute:wave:post ──────────────────────────
|
||||
|
||||
describe('A. loop render-hooks execute:wave:post — resolution', () => {
|
||||
|
||||
test('[happy] full resolution: all 3 gates present with default config', () => {
|
||||
const dir = makeTmpDir();
|
||||
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
|
||||
// default config — schema_drift_gate and ui_safety_gate both default to true
|
||||
fs.writeFileSync(path.join(dir, '.planning', 'config.json'), '{}');
|
||||
|
||||
const r = runTool(['loop', 'render-hooks', 'execute:wave:post', '--raw'], { cwd: dir });
|
||||
assert.strictEqual(r.status, 0, `exit non-zero: ${r.stderr}`);
|
||||
assert.ok(r.parsed, `stdout not JSON: ${r.stdout}`);
|
||||
const env = r.parsed;
|
||||
|
||||
assert.strictEqual(env.point, 'execute:wave:post');
|
||||
assert.ok(Array.isArray(env.activeHooks), 'activeHooks must be an array');
|
||||
// Real registry: 3 gates (schema-drift blocking, codebase-drift non-blocking, ui-safety blocking)
|
||||
assert.strictEqual(env.activeHooks.length, 3,
|
||||
`expected 3 gates; got ${env.activeHooks.length}: ${JSON.stringify(env.activeHooks.map(h => h.capId || h.check?.query))}`);
|
||||
|
||||
// Verify the three expected gate queries
|
||||
const queries = env.activeHooks.map(h => h.check?.query);
|
||||
assert.ok(queries.includes('verify.schema-drift'), 'verify.schema-drift gate must be present');
|
||||
assert.ok(queries.includes('verify.codebase-drift'), 'verify.codebase-drift gate must be present');
|
||||
assert.ok(queries.includes('ui.safety-gate'), 'ui.safety-gate gate must be present');
|
||||
});
|
||||
|
||||
test('[negative] no gates returned when schema_drift_gate=false AND ui_safety_gate=false — all suppressed', () => {
|
||||
const dir = makeTmpDir();
|
||||
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, '.planning', 'config.json'),
|
||||
JSON.stringify({ workflow: { schema_drift_gate: false, ui_safety_gate: false } }),
|
||||
);
|
||||
|
||||
const r = runTool(['loop', 'render-hooks', 'execute:wave:post', '--raw'], { cwd: dir });
|
||||
assert.strictEqual(r.status, 0, `exit non-zero: ${r.stderr}`);
|
||||
assert.ok(r.parsed, `stdout not JSON: ${r.stdout}`);
|
||||
const env = r.parsed;
|
||||
|
||||
assert.strictEqual(env.point, 'execute:wave:post');
|
||||
// This is the SPECIFIC differing value — must be 0, not 1, 2, or 3
|
||||
assert.strictEqual(env.activeHooks.length, 0,
|
||||
`expected 0 active hooks when both gates suppressed; got ${env.activeHooks.length}`);
|
||||
assert.strictEqual(env.rendered, '_No active hooks at execute:wave:post._');
|
||||
});
|
||||
|
||||
test('[bva] partial suppression: schema_drift_gate=false → only ui gate present (1 hook)', () => {
|
||||
const dir = makeTmpDir();
|
||||
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
|
||||
// schema_drift_gate=false suppresses BOTH drift gates (both use this when key)
|
||||
// ui_safety_gate defaults to true so ui.safety-gate stays active
|
||||
fs.writeFileSync(
|
||||
path.join(dir, '.planning', 'config.json'),
|
||||
JSON.stringify({ workflow: { schema_drift_gate: false, ui_safety_gate: true } }),
|
||||
);
|
||||
|
||||
const r = runTool(['loop', 'render-hooks', 'execute:wave:post', '--raw'], { cwd: dir });
|
||||
assert.strictEqual(r.status, 0, `exit non-zero: ${r.stderr}`);
|
||||
assert.ok(r.parsed, `stdout not JSON: ${r.stdout}`);
|
||||
const env = r.parsed;
|
||||
|
||||
// Specific differing value: exactly 1 hook, not 3 or 0
|
||||
assert.strictEqual(env.activeHooks.length, 1,
|
||||
`expected 1 hook (only ui); got ${env.activeHooks.length}: ${JSON.stringify(env.activeHooks.map(h => h.check?.query))}`);
|
||||
assert.strictEqual(env.activeHooks[0].check?.query, 'ui.safety-gate',
|
||||
`remaining hook must be ui.safety-gate, got ${env.activeHooks[0].check?.query}`);
|
||||
assert.strictEqual(env.activeHooks[0].capId, 'ui');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// ─── Section B: check verify.schema-drift ────────────────────────────────────
|
||||
|
||||
describe('B. check verify.schema-drift — CLI route', () => {
|
||||
|
||||
// Helper: build a minimal git repo with a phase dir containing a PLAN.md
|
||||
function buildSchemaDriftFixture({ hasSchemaFile = false } = {}) {
|
||||
const dir = makeTmpDir();
|
||||
initGitRepo(dir);
|
||||
|
||||
fs.mkdirSync(path.join(dir, '.planning', 'phases', '01-setup'), { recursive: true });
|
||||
|
||||
// Write a PLAN.md with files_modified
|
||||
const schemaEntry = hasSchemaFile ? 'prisma/schema.prisma' : 'src/index.ts';
|
||||
const planContent = [
|
||||
'# 01 Plan',
|
||||
'',
|
||||
`files_modified: [${schemaEntry}]`,
|
||||
'',
|
||||
].join('\n');
|
||||
fs.writeFileSync(path.join(dir, '.planning', 'phases', '01-setup', '01-PLAN.md'), planContent);
|
||||
|
||||
// Write README so git has something to commit
|
||||
fs.writeFileSync(path.join(dir, 'README.md'), '# Test\n');
|
||||
gitAddCommit(dir, 'initial commit');
|
||||
|
||||
return dir;
|
||||
}
|
||||
|
||||
test('[happy] block:false when no schema files in PLAN.md — happy path', () => {
|
||||
const dir = buildSchemaDriftFixture({ hasSchemaFile: false });
|
||||
|
||||
const r = runTool(['check', 'verify.schema-drift', '1', '--raw'], { cwd: dir });
|
||||
assert.strictEqual(r.status, 0, `exit non-zero: ${r.stderr}`);
|
||||
assert.ok(r.parsed, `stdout not JSON: ${r.stdout}`);
|
||||
const result = r.parsed;
|
||||
|
||||
// Specific typed fields
|
||||
assert.strictEqual(result.block, false, `block must be false for non-schema file; got ${result.block}`);
|
||||
assert.strictEqual(result.drift_detected, false,
|
||||
`drift_detected must be false; got ${result.drift_detected}`);
|
||||
assert.strictEqual(result.skipped, false,
|
||||
`skipped must be false; got ${result.skipped}`);
|
||||
});
|
||||
|
||||
test('[negative] block:true when schema file in PLAN.md and no push executed — fail-closed', () => {
|
||||
const dir = buildSchemaDriftFixture({ hasSchemaFile: true });
|
||||
// No SUMMARY.md with push evidence is written — so schema drift detected
|
||||
|
||||
const r = runTool(['check', 'verify.schema-drift', '1', '--raw'], { cwd: dir });
|
||||
assert.strictEqual(r.status, 0, `exit non-zero: ${r.stderr}`);
|
||||
assert.ok(r.parsed, `stdout not JSON: ${r.stdout}`);
|
||||
const result = r.parsed;
|
||||
|
||||
// Specific: block must be TRUE here (not false) — FAIL if block is still false
|
||||
assert.strictEqual(result.block, true, `block must be true when schema file has no push; got ${result.block}`);
|
||||
assert.strictEqual(result.drift_detected, true,
|
||||
`drift_detected must be true; got ${result.drift_detected}`);
|
||||
// unpushed_orms must contain 'prisma'
|
||||
assert.ok(Array.isArray(result.unpushed_orms), 'unpushed_orms must be an array');
|
||||
assert.ok(result.unpushed_orms.includes('prisma'),
|
||||
`unpushed_orms must include 'prisma'; got ${JSON.stringify(result.unpushed_orms)}`);
|
||||
});
|
||||
|
||||
test('[negative] GSD_SKIP_SCHEMA_CHECK=true → block:false, skipped:true even with schema drift', () => {
|
||||
const dir = buildSchemaDriftFixture({ hasSchemaFile: true });
|
||||
|
||||
const r = runTool(['check', 'verify.schema-drift', '1', '--raw'], {
|
||||
cwd: dir,
|
||||
env: { GSD_SKIP_SCHEMA_CHECK: 'true' },
|
||||
});
|
||||
assert.strictEqual(r.status, 0, `exit non-zero: ${r.stderr}`);
|
||||
assert.ok(r.parsed, `stdout not JSON: ${r.stdout}`);
|
||||
const result = r.parsed;
|
||||
|
||||
// Specific: block must be FALSE (bypassed) even though drift was detected
|
||||
assert.strictEqual(result.block, false,
|
||||
`block must be false with GSD_SKIP_SCHEMA_CHECK=true; got ${result.block}`);
|
||||
assert.strictEqual(result.skipped, true,
|
||||
`skipped must be true; got ${result.skipped}`);
|
||||
// drift_detected should still be true (bypass doesn't mask detection)
|
||||
assert.strictEqual(result.drift_detected, true,
|
||||
`drift_detected must be true even when bypassed; got ${result.drift_detected}`);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// ─── Section C: check verify.codebase-drift — BVA ────────────────────────────
|
||||
|
||||
describe('C. check verify.codebase-drift — BVA at threshold', () => {
|
||||
|
||||
/**
|
||||
* Build a git repo with STRUCTURE.md stamped at an initial commit,
|
||||
* then add N new barrel exports in a second commit to trigger drift detection.
|
||||
*/
|
||||
function buildCodebaseDriftFixture({ barrelCount = 0, driftAction = 'warn', threshold = 3 } = {}) {
|
||||
const dir = makeTmpDir();
|
||||
initGitRepo(dir);
|
||||
|
||||
fs.mkdirSync(path.join(dir, '.planning', 'codebase'), { recursive: true });
|
||||
fs.mkdirSync(path.join(dir, '.planning', 'phases'), { recursive: true });
|
||||
|
||||
// Write config.json
|
||||
const config = {
|
||||
workflow: {
|
||||
drift_threshold: threshold,
|
||||
drift_action: driftAction,
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(path.join(dir, '.planning', 'config.json'), JSON.stringify(config));
|
||||
|
||||
// Initial commit with STRUCTURE.md + config
|
||||
fs.writeFileSync(path.join(dir, 'README.md'), '# Test\n');
|
||||
// Write STRUCTURE.md stub — will be stamped after initial commit
|
||||
fs.writeFileSync(
|
||||
path.join(dir, '.planning', 'codebase', 'STRUCTURE.md'),
|
||||
'# Structure\n\nInitial layout.\n',
|
||||
);
|
||||
gitAddCommit(dir, 'initial commit');
|
||||
|
||||
// Stamp STRUCTURE.md with last_mapped_commit = HEAD of initial commit
|
||||
const headSha = gitSync(['rev-parse', 'HEAD'], dir);
|
||||
const stampedContent = `---\nlast_mapped_commit: ${headSha}\n---\n# Structure\n\nInitial layout.\n`;
|
||||
fs.writeFileSync(path.join(dir, '.planning', 'codebase', 'STRUCTURE.md'), stampedContent);
|
||||
gitAddCommit(dir, 'stamp STRUCTURE.md with last_mapped_commit');
|
||||
|
||||
// Add the new barrel exports in a third commit (these are "new" since last map)
|
||||
if (barrelCount > 0) {
|
||||
for (let i = 0; i < barrelCount; i++) {
|
||||
const pkgName = `pkg-${i}`;
|
||||
fs.mkdirSync(path.join(dir, 'packages', pkgName, 'src'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'packages', pkgName, 'src', 'index.ts'),
|
||||
`export const val${i} = ${i};\n`,
|
||||
);
|
||||
}
|
||||
gitAddCommit(dir, `add ${barrelCount} new barrel exports`);
|
||||
|
||||
// Re-read head sha and update STRUCTURE.md stamp to the pre-barrel commit
|
||||
// (so all the barrel files are "new" relative to last_mapped_commit)
|
||||
// Actually: we want the stamp to be at the commit BEFORE the barrels were added,
|
||||
// so we need to get the second commit's SHA.
|
||||
// We already have stamped at the second commit. The third commit added barrels.
|
||||
// The stamp still points to the initial commit, so diff = all new barrel files.
|
||||
}
|
||||
|
||||
return dir;
|
||||
}
|
||||
|
||||
test('[bva] threshold-1 (2 elements) → block:false, action_required:false — just-under boundary', () => {
|
||||
// threshold=3, barrelCount=2 → 2 < 3 → no block
|
||||
const dir = buildCodebaseDriftFixture({ barrelCount: 2, threshold: 3 });
|
||||
|
||||
const r = runTool(['check', 'verify.codebase-drift', '--raw'], { cwd: dir });
|
||||
assert.strictEqual(r.status, 0, `exit non-zero: ${r.stderr}`);
|
||||
assert.ok(r.parsed, `stdout not JSON: ${r.stdout}`);
|
||||
const result = r.parsed;
|
||||
|
||||
// Specific: block must be FALSE at threshold-1
|
||||
assert.strictEqual(result.block, false,
|
||||
`block must be false at threshold-1 (2 elements); got ${result.block}`);
|
||||
assert.strictEqual(result.action_required, false,
|
||||
`action_required must be false; got ${result.action_required}`);
|
||||
assert.ok(Array.isArray(result.elements),
|
||||
`elements must be an array; got ${typeof result.elements}`);
|
||||
assert.strictEqual(result.elements.length, 2,
|
||||
`elements.length must be exactly 2; got ${result.elements.length}`);
|
||||
assert.strictEqual(result.directive, 'none',
|
||||
`directive must be 'none'; got ${result.directive}`);
|
||||
assert.strictEqual(result.skipped, false,
|
||||
`skipped must be false; got ${result.skipped}`);
|
||||
});
|
||||
|
||||
test('[bva] threshold exactly (3 elements) → block:true, action_required:true — at boundary', () => {
|
||||
// threshold=3, barrelCount=3 → 3 >= 3 → block
|
||||
const dir = buildCodebaseDriftFixture({ barrelCount: 3, threshold: 3 });
|
||||
|
||||
const r = runTool(['check', 'verify.codebase-drift', '--raw'], { cwd: dir });
|
||||
assert.strictEqual(r.status, 0, `exit non-zero: ${r.stderr}`);
|
||||
assert.ok(r.parsed, `stdout not JSON: ${r.stdout}`);
|
||||
const result = r.parsed;
|
||||
|
||||
// Specific: block must be TRUE at exactly threshold — FAIL if still false
|
||||
assert.strictEqual(result.block, true,
|
||||
`block must be true at threshold (3 elements); got ${result.block}`);
|
||||
assert.strictEqual(result.action_required, true,
|
||||
`action_required must be true; got ${result.action_required}`);
|
||||
assert.strictEqual(result.elements.length, 3,
|
||||
`elements.length must be exactly 3; got ${result.elements.length}`);
|
||||
assert.strictEqual(result.directive, 'warn',
|
||||
`directive must be 'warn'; got ${result.directive}`);
|
||||
assert.strictEqual(result.spawn_mapper, false,
|
||||
`spawn_mapper must be false for warn action; got ${result.spawn_mapper}`);
|
||||
});
|
||||
|
||||
test('[happy] drift_action=auto-remap + threshold exceeded → block:true, spawn_mapper:true', () => {
|
||||
const dir = buildCodebaseDriftFixture({ barrelCount: 3, driftAction: 'auto-remap', threshold: 3 });
|
||||
|
||||
const r = runTool(['check', 'verify.codebase-drift', '--raw'], { cwd: dir });
|
||||
assert.strictEqual(r.status, 0, `exit non-zero: ${r.stderr}`);
|
||||
assert.ok(r.parsed, `stdout not JSON: ${r.stdout}`);
|
||||
const result = r.parsed;
|
||||
|
||||
assert.strictEqual(result.block, true,
|
||||
`block must be true; got ${result.block}`);
|
||||
assert.strictEqual(result.action_required, true,
|
||||
`action_required must be true; got ${result.action_required}`);
|
||||
// Specific: spawn_mapper must be TRUE for auto-remap action
|
||||
assert.strictEqual(result.spawn_mapper, true,
|
||||
`spawn_mapper must be true for auto-remap; got ${result.spawn_mapper}`);
|
||||
assert.strictEqual(result.directive, 'auto-remap',
|
||||
`directive must be 'auto-remap'; got ${result.directive}`);
|
||||
});
|
||||
|
||||
test('[empty-resolution] STRUCTURE.md absent → block:false, skipped:true, reason:no-structure-md', () => {
|
||||
const dir = makeTmpDir();
|
||||
initGitRepo(dir);
|
||||
// Create .planning/codebase/ dir but NO STRUCTURE.md
|
||||
fs.mkdirSync(path.join(dir, '.planning', 'codebase'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'README.md'), '# Test\n');
|
||||
gitAddCommit(dir, 'initial commit');
|
||||
|
||||
const r = runTool(['check', 'verify.codebase-drift', '--raw'], { cwd: dir });
|
||||
assert.strictEqual(r.status, 0, `exit non-zero: ${r.stderr}`);
|
||||
assert.ok(r.parsed, `stdout not JSON: ${r.stdout}`);
|
||||
const result = r.parsed;
|
||||
|
||||
assert.strictEqual(result.block, false,
|
||||
`block must be false when STRUCTURE.md absent; got ${result.block}`);
|
||||
assert.strictEqual(result.skipped, true,
|
||||
`skipped must be true; got ${result.skipped}`);
|
||||
assert.strictEqual(result.reason, 'no-structure-md',
|
||||
`reason must be 'no-structure-md'; got ${result.reason}`);
|
||||
assert.strictEqual(result.action_required, false,
|
||||
`action_required must be false; got ${result.action_required}`);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// ─── Section D: check ui.safety-gate ─────────────────────────────────────────
|
||||
|
||||
describe('D. check ui.safety-gate — CLI subprocess route', () => {
|
||||
|
||||
/**
|
||||
* Build a git repo fixture for ui.safety-gate tests.
|
||||
*
|
||||
* Sequence:
|
||||
* commit 1: initial commit with README
|
||||
* commit 2: add src/components/Button.tsx (UI file)
|
||||
* Optional: create .planning/phases/01-phase/01-UI-SPEC.md
|
||||
*/
|
||||
function buildUiSafetyGateFixture({ hasUiSpec = false, frontend = true } = {}) {
|
||||
const dir = makeTmpDir();
|
||||
initGitRepo(dir);
|
||||
|
||||
// Create planning dirs
|
||||
fs.mkdirSync(path.join(dir, '.planning', 'phases', '01-phase'), { recursive: true });
|
||||
|
||||
// Write ROADMAP.md with a frontend Phase 1 section.
|
||||
// getRoadmapPhaseWithFallback requires ## or ### heading (not #) for phase lookup.
|
||||
const phaseText = frontend
|
||||
? '## Phase 1: dashboard frontend\n\nBuild the user-facing dashboard UI component.\n'
|
||||
: '## Phase 1: backend api\n\nBuild the backend API endpoints only.\n';
|
||||
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, '.planning', 'ROADMAP.md'), phaseText);
|
||||
|
||||
// Initial commit
|
||||
fs.writeFileSync(path.join(dir, 'README.md'), '# Test\n');
|
||||
gitAddCommit(dir, 'initial commit');
|
||||
|
||||
// Optionally add UI-SPEC before the UI file commit
|
||||
if (hasUiSpec) {
|
||||
fs.writeFileSync(
|
||||
path.join(dir, '.planning', 'phases', '01-phase', '01-UI-SPEC.md'),
|
||||
'# UI Spec\n\nDesign contract for Phase 1.\n',
|
||||
);
|
||||
gitAddCommit(dir, 'add UI-SPEC');
|
||||
}
|
||||
|
||||
// Second commit: add a UI file (matches UI_FILE_EXTENSIONS_RE: .tsx)
|
||||
fs.mkdirSync(path.join(dir, 'src', 'components'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'src', 'components', 'Button.tsx'),
|
||||
'export const Button = () => null;\n',
|
||||
);
|
||||
gitAddCommit(dir, 'add Button.tsx component');
|
||||
|
||||
return dir;
|
||||
}
|
||||
|
||||
test('[negative] block:true when frontend phase + UI file changed + no UI-SPEC — live block path', () => {
|
||||
const dir = buildUiSafetyGateFixture({ hasUiSpec: false, frontend: true });
|
||||
|
||||
const r = runTool(['check', 'ui.safety-gate', '1', '--raw'], { cwd: dir });
|
||||
assert.strictEqual(r.status, 0, `exit non-zero: ${r.stderr}`);
|
||||
assert.ok(r.parsed, `stdout not JSON: ${r.stdout}`);
|
||||
const result = r.parsed;
|
||||
|
||||
// Specific: block must be TRUE — fails if block is false
|
||||
assert.strictEqual(result.block, true,
|
||||
`block must be true: frontend=${result.frontend} hasUiFiles=${result.hasUiFiles} hasUiSpec=${result.hasUiSpec}`);
|
||||
assert.strictEqual(result.frontend, true,
|
||||
`frontend must be true; got ${result.frontend}`);
|
||||
assert.strictEqual(result.hasUiFiles, true,
|
||||
`hasUiFiles must be true; got ${result.hasUiFiles}`);
|
||||
assert.strictEqual(result.hasUiSpec, false,
|
||||
`hasUiSpec must be false; got ${result.hasUiSpec}`);
|
||||
});
|
||||
|
||||
test('[happy] block:false when frontend phase + UI file changed + UI-SPEC present — gate passes', () => {
|
||||
const dir = buildUiSafetyGateFixture({ hasUiSpec: true, frontend: true });
|
||||
|
||||
const r = runTool(['check', 'ui.safety-gate', '1', '--raw'], { cwd: dir });
|
||||
assert.strictEqual(r.status, 0, `exit non-zero: ${r.stderr}`);
|
||||
assert.ok(r.parsed, `stdout not JSON: ${r.stdout}`);
|
||||
const result = r.parsed;
|
||||
|
||||
// Specific: block must be FALSE when spec is present
|
||||
assert.strictEqual(result.block, false,
|
||||
`block must be false when UI-SPEC exists; got block=${result.block}`);
|
||||
assert.strictEqual(result.frontend, true,
|
||||
`frontend must be true; got ${result.frontend}`);
|
||||
assert.strictEqual(result.hasUiFiles, true,
|
||||
`hasUiFiles must be true; got ${result.hasUiFiles}`);
|
||||
assert.strictEqual(result.hasUiSpec, true,
|
||||
`hasUiSpec must be true; got ${result.hasUiSpec}`);
|
||||
});
|
||||
|
||||
test('[negative] exits non-zero with error message when phase argument is missing', () => {
|
||||
const dir = makeTmpDir();
|
||||
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
|
||||
|
||||
const r = runTool(['check', 'ui.safety-gate', '--raw'], { cwd: dir });
|
||||
|
||||
// Specific: exit must be NON-ZERO — FAIL if 0
|
||||
assert.notStrictEqual(r.status, 0,
|
||||
`expected non-zero exit for missing phase arg; got ${r.status}`);
|
||||
const combined = r.stdout + r.stderr;
|
||||
assert.ok(
|
||||
combined.includes('ui-safety-gate requires a phase argument'),
|
||||
`error message must mention 'ui-safety-gate requires a phase argument'; got: ${combined}`,
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// ─── Section E: Full execute:wave:post pipeline chain ────────────────────────
|
||||
|
||||
describe('E. Full execute:wave:post pipeline — render-hooks then dispatch gates', () => {
|
||||
|
||||
test('[happy] Full chain: render-hooks discovers 3 gates → schema-drift block:false → codebase-drift block:false', () => {
|
||||
// Build fixture: git repo, non-frontend ROADMAP, fresh STRUCTURE.md stamped at current HEAD
|
||||
const dir = makeTmpDir();
|
||||
initGitRepo(dir);
|
||||
|
||||
fs.mkdirSync(path.join(dir, '.planning', 'codebase'), { recursive: true });
|
||||
fs.mkdirSync(path.join(dir, '.planning', 'phases', '01-setup'), { recursive: true });
|
||||
|
||||
// Non-frontend ROADMAP so ui.safety-gate doesn't block (no UI files changed).
|
||||
// Use ## heading — getRoadmapPhaseWithFallback requires ## or ### (not #).
|
||||
fs.writeFileSync(
|
||||
path.join(dir, '.planning', 'ROADMAP.md'),
|
||||
'## Phase 1: backend setup\n\nConfigure server-side services.\n',
|
||||
);
|
||||
// PLAN.md with only non-schema files
|
||||
fs.writeFileSync(
|
||||
path.join(dir, '.planning', 'phases', '01-setup', '01-PLAN.md'),
|
||||
'files_modified: [src/server.ts, package.json]\n',
|
||||
);
|
||||
|
||||
// Write STRUCTURE.md stub (no frontmatter stamp initially)
|
||||
fs.writeFileSync(
|
||||
path.join(dir, '.planning', 'codebase', 'STRUCTURE.md'),
|
||||
'# Structure\n\nInitial codebase layout.\n',
|
||||
);
|
||||
fs.writeFileSync(path.join(dir, 'README.md'), '# Test\n');
|
||||
gitAddCommit(dir, 'initial commit');
|
||||
|
||||
// Stamp STRUCTURE.md with current HEAD so there is no drift since last map
|
||||
const headSha = gitSync(['rev-parse', 'HEAD'], dir);
|
||||
fs.writeFileSync(
|
||||
path.join(dir, '.planning', 'codebase', 'STRUCTURE.md'),
|
||||
`---\nlast_mapped_commit: ${headSha}\n---\n# Structure\n\nInitial codebase layout.\n`,
|
||||
);
|
||||
gitAddCommit(dir, 'stamp STRUCTURE.md');
|
||||
|
||||
// --- Step 1: render-hooks → discover gates ---
|
||||
const step1 = runTool(['loop', 'render-hooks', 'execute:wave:post', '--raw'], { cwd: dir });
|
||||
assert.strictEqual(step1.status, 0, `step1 exit non-zero: ${step1.stderr}`);
|
||||
assert.ok(step1.parsed, `step1 not JSON: ${step1.stdout}`);
|
||||
const envelope = step1.parsed;
|
||||
|
||||
assert.strictEqual(envelope.point, 'execute:wave:post');
|
||||
assert.strictEqual(envelope.activeHooks.length, 3,
|
||||
`step1: expected 3 gates, got ${envelope.activeHooks.length}`);
|
||||
|
||||
// Confirm schema-drift gate is present and has correct metadata
|
||||
const schemaDriftHook = envelope.activeHooks.find(h => h.check?.query === 'verify.schema-drift');
|
||||
assert.ok(schemaDriftHook, 'verify.schema-drift gate must be in activeHooks');
|
||||
assert.strictEqual(schemaDriftHook.blocking, true, 'schema-drift gate must be blocking');
|
||||
assert.strictEqual(schemaDriftHook.onError, 'skip', 'schema-drift onError must be skip');
|
||||
|
||||
// --- Step 2: dispatch schema-drift gate ---
|
||||
const step2 = runTool(['check', 'verify.schema-drift', '1', '--raw'], { cwd: dir });
|
||||
assert.strictEqual(step2.status, 0, `step2 exit non-zero: ${step2.stderr}`);
|
||||
assert.ok(step2.parsed, `step2 not JSON: ${step2.stdout}`);
|
||||
assert.strictEqual(step2.parsed.block, false,
|
||||
`step2 schema-drift block must be false; got ${step2.parsed.block}`);
|
||||
|
||||
// --- Step 3: dispatch codebase-drift gate ---
|
||||
const step3 = runTool(['check', 'verify.codebase-drift', '--raw'], { cwd: dir });
|
||||
assert.strictEqual(step3.status, 0, `step3 exit non-zero: ${step3.stderr}`);
|
||||
assert.ok(step3.parsed, `step3 not JSON: ${step3.stdout}`);
|
||||
// After stamping and committing with no new barrel/migration files, no drift
|
||||
// (the stamp commit itself is just config changes — not drift categories)
|
||||
assert.strictEqual(step3.parsed.block, false,
|
||||
`step3 codebase-drift block must be false; got ${step3.parsed.block}`);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// ─── Section F: real registry shape assertions (pure-function) ────────────────
|
||||
|
||||
describe('F. Real registry execute:wave:post shape — guard against accidental changes', () => {
|
||||
|
||||
const realRegistry = require('../gsd-core/bin/lib/capability-registry.cjs');
|
||||
|
||||
test('[happy] real registry execute:wave:post has exactly 3 gates with correct queries', () => {
|
||||
const point = realRegistry.byLoopPoint['execute:wave:post'];
|
||||
assert.ok(point, 'byLoopPoint must have execute:wave:post key');
|
||||
assert.ok(Array.isArray(point.gates), 'gates must be an array');
|
||||
|
||||
// Specific: exactly 3 gates — fails if someone adds or removes one
|
||||
assert.strictEqual(point.gates.length, 3,
|
||||
`execute:wave:post must have exactly 3 gates; got ${point.gates.length}`);
|
||||
|
||||
const queries = point.gates.map(g => g.check?.query);
|
||||
assert.ok(queries.includes('verify.schema-drift'), 'verify.schema-drift gate must exist');
|
||||
assert.ok(queries.includes('verify.codebase-drift'), 'verify.codebase-drift gate must exist');
|
||||
assert.ok(queries.includes('ui.safety-gate'), 'ui.safety-gate gate must exist');
|
||||
});
|
||||
|
||||
test('[happy] real registry: schema-drift gate is blocking=true, codebase-drift is blocking=false', () => {
|
||||
const gates = realRegistry.byLoopPoint['execute:wave:post'].gates;
|
||||
const schemaDrift = gates.find(g => g.check?.query === 'verify.schema-drift');
|
||||
const codebaseDrift = gates.find(g => g.check?.query === 'verify.codebase-drift');
|
||||
|
||||
assert.strictEqual(schemaDrift.blocking, true,
|
||||
`schema-drift gate must be blocking=true; got ${schemaDrift.blocking}`);
|
||||
assert.strictEqual(codebaseDrift.blocking, false,
|
||||
`codebase-drift gate must be blocking=false; got ${codebaseDrift.blocking}`);
|
||||
});
|
||||
|
||||
test('[happy] real registry: ui.safety-gate is blocking=true, onError=halt', () => {
|
||||
const gates = realRegistry.byLoopPoint['execute:wave:post'].gates;
|
||||
const uiGate = gates.find(g => g.check?.query === 'ui.safety-gate');
|
||||
|
||||
assert.ok(uiGate, 'ui.safety-gate gate must exist');
|
||||
assert.strictEqual(uiGate.blocking, true,
|
||||
`ui.safety-gate must be blocking=true; got ${uiGate.blocking}`);
|
||||
assert.strictEqual(uiGate.onError, 'halt',
|
||||
`ui.safety-gate onError must be 'halt'; got ${uiGate.onError}`);
|
||||
});
|
||||
|
||||
test('[happy] real registry: execute:wave:post has no steps and no contributions — pure gate point', () => {
|
||||
const point = realRegistry.byLoopPoint['execute:wave:post'];
|
||||
assert.strictEqual(point.steps.length, 0,
|
||||
`execute:wave:post steps must be empty; got ${point.steps.length}`);
|
||||
assert.strictEqual(point.contributions.length, 0,
|
||||
`execute:wave:post contributions must be empty; got ${point.contributions.length}`);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -36,7 +36,6 @@ const NEW_FIELDS = [
|
||||
];
|
||||
|
||||
const CENTRAL_NEW_FIELDS = [
|
||||
'workflow.tdd_mode',
|
||||
'commit_docs',
|
||||
];
|
||||
|
||||
|
||||
418
tests/federated-config-key-removal.test.cjs
Normal file
418
tests/federated-config-key-removal.test.cjs
Normal file
@@ -0,0 +1,418 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* federated-config-key-removal.test.cjs
|
||||
*
|
||||
* ADR-857 deliverable F — Decision 3:
|
||||
* "Uninstalling a Capability removes its config keys cleanly."
|
||||
*
|
||||
* Tests:
|
||||
* [happy] Capability X present → its key surfaces; X removed → key gone, others intact.
|
||||
* [happy] loadConfig after removing a capability no longer surfaces the key, but central
|
||||
* base-config keys remain.
|
||||
* [BVA] Orphaned user value for the removed key is dropped — not leaked as a phantom key.
|
||||
* [negative] Removing capability 'x' does NOT drop a differently-prefixed capability's key
|
||||
* (e.g. 'xy.enabled' survives when only 'x.enabled' is removed).
|
||||
*/
|
||||
|
||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const { mergeFederatedConfig } = require('../gsd-core/bin/lib/federated-config.cjs');
|
||||
|
||||
const configLoader = require('../gsd-core/bin/lib/config-loader.cjs');
|
||||
const {
|
||||
loadConfig,
|
||||
_setFederatedRegistryForTests,
|
||||
_resetFederatedRegistryForTests,
|
||||
} = configLoader;
|
||||
|
||||
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Never treated as a central key — all keys federated freely. */
|
||||
const neverCentral = (_key) => false;
|
||||
|
||||
/** Minimal well-formed boolean slice. */
|
||||
function boolSlice(owner, defaultValue = true) {
|
||||
return { owner, type: 'boolean', default: defaultValue, description: `Boolean key for ${owner}.` };
|
||||
}
|
||||
|
||||
// ─── Temp project helpers (mirrors federated-config-loadconfig.test.cjs) ──────
|
||||
|
||||
let tmpDirs = [];
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDirs = [];
|
||||
_resetFederatedRegistryForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
_resetFederatedRegistryForTests();
|
||||
for (const d of tmpDirs) {
|
||||
cleanup(d);
|
||||
}
|
||||
});
|
||||
|
||||
function mkTempProject() {
|
||||
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cap-removal-test-'));
|
||||
tmpDirs.push(d);
|
||||
fs.mkdirSync(path.join(d, '.planning', 'phases'), { recursive: true });
|
||||
return d;
|
||||
}
|
||||
|
||||
function writeConfig(dir, obj) {
|
||||
fs.writeFileSync(
|
||||
path.join(dir, '.planning', 'config.json'),
|
||||
JSON.stringify(obj, null, 2),
|
||||
'utf-8',
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 1. [happy] key present → surfaces; capability removed → key gone ─────────
|
||||
|
||||
describe('[happy] capability X present then removed — key lifecycle', () => {
|
||||
test('registry WITH capability X: x.enabled surfaces with default true', () => {
|
||||
const withX = {
|
||||
'x.enabled': boolSlice('cap-x', true),
|
||||
};
|
||||
|
||||
const result = mergeFederatedConfig({
|
||||
configSchema: withX,
|
||||
isCentralKey: neverCentral,
|
||||
userConfig: {},
|
||||
});
|
||||
|
||||
// Positive assertion: the key is in validKeys AND values
|
||||
assert.ok(
|
||||
result.validKeys.includes('x.enabled'),
|
||||
'x.enabled must be in validKeys when cap-x is installed',
|
||||
);
|
||||
assert.strictEqual(
|
||||
result.values['x.enabled'],
|
||||
true,
|
||||
'x.enabled default must be true',
|
||||
);
|
||||
assert.deepEqual(result.warnings, [], 'no warnings for valid federated key');
|
||||
});
|
||||
|
||||
test('registry WITHOUT capability X: x.enabled is absent from result', () => {
|
||||
// Registry after cap-x is uninstalled — its key is no longer in configSchema
|
||||
const withoutX = {};
|
||||
|
||||
const result = mergeFederatedConfig({
|
||||
configSchema: withoutX,
|
||||
isCentralKey: neverCentral,
|
||||
userConfig: {},
|
||||
});
|
||||
|
||||
assert.ok(
|
||||
!result.validKeys.includes('x.enabled'),
|
||||
'x.enabled must NOT be in validKeys after cap-x is removed',
|
||||
);
|
||||
assert.ok(
|
||||
!Object.prototype.hasOwnProperty.call(result.values, 'x.enabled'),
|
||||
'x.enabled must NOT appear in values after cap-x is removed',
|
||||
);
|
||||
assert.strictEqual(Object.keys(result.values).length, 0, 'values must be empty');
|
||||
});
|
||||
|
||||
test('[no contamination] removing cap-x leaves cap-y.flag intact', () => {
|
||||
// Before removal: both capabilities present
|
||||
const withBoth = {
|
||||
'x.enabled': boolSlice('cap-x', true),
|
||||
'y.flag': boolSlice('cap-y', false),
|
||||
};
|
||||
|
||||
const before = mergeFederatedConfig({
|
||||
configSchema: withBoth,
|
||||
isCentralKey: neverCentral,
|
||||
userConfig: {},
|
||||
});
|
||||
|
||||
assert.ok(before.validKeys.includes('x.enabled'), 'x.enabled present before removal');
|
||||
assert.ok(before.validKeys.includes('y.flag'), 'y.flag present before removal');
|
||||
|
||||
// After removal: only cap-y remains in the registry
|
||||
const withoutX = {
|
||||
'y.flag': boolSlice('cap-y', false),
|
||||
};
|
||||
|
||||
const after = mergeFederatedConfig({
|
||||
configSchema: withoutX,
|
||||
isCentralKey: neverCentral,
|
||||
userConfig: {},
|
||||
});
|
||||
|
||||
// x.enabled must be gone
|
||||
assert.ok(
|
||||
!after.validKeys.includes('x.enabled'),
|
||||
'x.enabled must be absent after cap-x removal',
|
||||
);
|
||||
assert.ok(
|
||||
!Object.prototype.hasOwnProperty.call(after.values, 'x.enabled'),
|
||||
'x.enabled must not appear in values after removal',
|
||||
);
|
||||
|
||||
// y.flag must still be present AND have the correct value
|
||||
assert.ok(
|
||||
after.validKeys.includes('y.flag'),
|
||||
'y.flag must still be in validKeys after cap-x removal',
|
||||
);
|
||||
assert.strictEqual(
|
||||
after.values['y.flag'],
|
||||
false,
|
||||
'y.flag value must remain false (its default) after cap-x removal',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 2. [happy] loadConfig: removed capability key absent, base keys intact ───
|
||||
|
||||
describe('[happy] loadConfig after capability removal — base keys survive', () => {
|
||||
test('cap-x present → loadConfig surfaces mytool.enabled; cap-x absent → key gone', () => {
|
||||
const tmpDir = mkTempProject();
|
||||
writeConfig(tmpDir, {});
|
||||
|
||||
// Phase A: cap-x installed
|
||||
_setFederatedRegistryForTests({
|
||||
configSchema: {
|
||||
'mytool.enabled': boolSlice('cap-x', true),
|
||||
},
|
||||
});
|
||||
|
||||
const resultWith = loadConfig(tmpDir);
|
||||
assert.ok(
|
||||
typeof resultWith['mytool'] === 'object' && resultWith['mytool'] !== null,
|
||||
'mytool section must exist when cap-x is installed',
|
||||
);
|
||||
assert.strictEqual(
|
||||
resultWith['mytool']['enabled'],
|
||||
true,
|
||||
'mytool.enabled must be true (cap-x default)',
|
||||
);
|
||||
|
||||
// Phase B: cap-x uninstalled — registry now empty
|
||||
_resetFederatedRegistryForTests();
|
||||
_setFederatedRegistryForTests({ configSchema: {} });
|
||||
|
||||
const resultWithout = loadConfig(tmpDir);
|
||||
// Central base-config key must still be present
|
||||
assert.ok(
|
||||
Object.prototype.hasOwnProperty.call(resultWithout, 'model_profile'),
|
||||
'model_profile (central key) must still exist after cap-x removal',
|
||||
);
|
||||
// Federated key must be absent — either undefined or not surfaced under 'mytool'
|
||||
const myToolSection = resultWithout['mytool'];
|
||||
const enabledValue = (myToolSection && typeof myToolSection === 'object')
|
||||
? myToolSection['enabled']
|
||||
: undefined;
|
||||
assert.strictEqual(
|
||||
enabledValue,
|
||||
undefined,
|
||||
'mytool.enabled must NOT be present after cap-x removal; got: ' + JSON.stringify(enabledValue),
|
||||
);
|
||||
});
|
||||
|
||||
test('base config keys (model_profile, research) survive capability removal', () => {
|
||||
const tmpDir = mkTempProject();
|
||||
writeConfig(tmpDir, { model_profile: 'fast', research: false });
|
||||
|
||||
// Install and then remove a synthetic capability
|
||||
_setFederatedRegistryForTests({
|
||||
configSchema: {
|
||||
'extra.flag': boolSlice('cap-extra', true),
|
||||
},
|
||||
});
|
||||
const before = loadConfig(tmpDir);
|
||||
assert.strictEqual(before['model_profile'], 'fast', 'model_profile from user config before removal');
|
||||
assert.strictEqual(before['research'], false, 'research from user config before removal');
|
||||
|
||||
_setFederatedRegistryForTests({ configSchema: {} });
|
||||
const after = loadConfig(tmpDir);
|
||||
|
||||
// Central keys from user's config.json must be unchanged
|
||||
assert.strictEqual(after['model_profile'], 'fast', 'model_profile must survive capability removal');
|
||||
assert.strictEqual(after['research'], false, 'research must survive capability removal');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 3. [BVA] orphaned user value not surfaced after removal ──────────────────
|
||||
|
||||
describe('[BVA] orphaned user value is silently dropped after capability removal', () => {
|
||||
test('user config sets removed key → orphaned value not surfaced as phantom', () => {
|
||||
// User has 'mytool.enabled': false in their config.json
|
||||
// BUT the capability is now uninstalled (not in registry configSchema)
|
||||
const result = mergeFederatedConfig({
|
||||
configSchema: {}, // cap-x removed — configSchema is empty
|
||||
isCentralKey: neverCentral,
|
||||
userConfig: { mytool: { enabled: false } }, // user value remains in file
|
||||
});
|
||||
|
||||
// The orphaned user value must NOT leak into validKeys or values
|
||||
assert.ok(
|
||||
!result.validKeys.includes('mytool.enabled'),
|
||||
'orphaned user key must not appear in validKeys',
|
||||
);
|
||||
assert.ok(
|
||||
!Object.prototype.hasOwnProperty.call(result.values, 'mytool.enabled'),
|
||||
'orphaned user key must not appear in values',
|
||||
);
|
||||
// The entire values map must be empty (no phantom keys)
|
||||
assert.strictEqual(
|
||||
Object.keys(result.values).length,
|
||||
0,
|
||||
'values must be empty when registry has no keys — got: ' + JSON.stringify(Object.keys(result.values)),
|
||||
);
|
||||
assert.deepEqual(result.validKeys, [], 'validKeys must be empty when registry has no keys');
|
||||
});
|
||||
|
||||
test('user config sets removed key — top-level orphan also not surfaced', () => {
|
||||
// Top-level orphan: user set 'orphan_flag' but the cap is gone
|
||||
const result = mergeFederatedConfig({
|
||||
configSchema: {},
|
||||
isCentralKey: neverCentral,
|
||||
userConfig: { orphan_flag: true },
|
||||
});
|
||||
|
||||
assert.ok(
|
||||
!Object.prototype.hasOwnProperty.call(result.values, 'orphan_flag'),
|
||||
'top-level orphaned key must not appear in values',
|
||||
);
|
||||
assert.deepEqual(result.validKeys, []);
|
||||
assert.strictEqual(Object.keys(result.values).length, 0);
|
||||
});
|
||||
|
||||
test('loadConfig: orphaned user value in config.json not surfaced after removal', () => {
|
||||
const tmpDir = mkTempProject();
|
||||
// User config contains a value for a key whose capability will be removed
|
||||
writeConfig(tmpDir, { orphancap: { flag: true } });
|
||||
|
||||
// Capability removed: inject empty registry
|
||||
_setFederatedRegistryForTests({ configSchema: {} });
|
||||
|
||||
const result = loadConfig(tmpDir);
|
||||
|
||||
// The orphaned capability key must NOT be surfaced in the resolved config object.
|
||||
// loadConfig extracts only known/central/federated keys into _baseConfig — any key
|
||||
// whose capability has been uninstalled (configSchema: {}) must not appear in the result.
|
||||
assert.ok(
|
||||
!Object.prototype.hasOwnProperty.call(result, 'orphancap'),
|
||||
'orphaned top-level key must not appear in resolved config when capability is removed',
|
||||
);
|
||||
// Central keys must remain unaffected by capability removal.
|
||||
assert.ok(
|
||||
Object.prototype.hasOwnProperty.call(result, 'model_profile'),
|
||||
'model_profile must still be present (central key unaffected by federated removal)',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 4. [negative] removing 'x' does NOT drop 'xy.enabled' ──────────────────
|
||||
|
||||
describe('[negative] prefix-adjacent key not dropped when shorter-prefix cap removed', () => {
|
||||
test("removing cap 'x' (key x.enabled) does NOT remove cap 'xy' (key xy.enabled)", () => {
|
||||
// Registry after 'x' is uninstalled but 'xy' remains
|
||||
const registryAfterXRemoved = {
|
||||
'xy.enabled': boolSlice('cap-xy', false),
|
||||
};
|
||||
|
||||
const result = mergeFederatedConfig({
|
||||
configSchema: registryAfterXRemoved,
|
||||
isCentralKey: neverCentral,
|
||||
userConfig: {},
|
||||
});
|
||||
|
||||
// x.enabled must not appear (was removed)
|
||||
assert.ok(
|
||||
!result.validKeys.includes('x.enabled'),
|
||||
'x.enabled must not appear (cap-x was uninstalled)',
|
||||
);
|
||||
assert.ok(
|
||||
!Object.prototype.hasOwnProperty.call(result.values, 'x.enabled'),
|
||||
'x.enabled must not be in values',
|
||||
);
|
||||
|
||||
// xy.enabled MUST still appear (different capability)
|
||||
assert.ok(
|
||||
result.validKeys.includes('xy.enabled'),
|
||||
'xy.enabled must still be present after cap-x removal',
|
||||
);
|
||||
assert.strictEqual(
|
||||
result.values['xy.enabled'],
|
||||
false,
|
||||
'xy.enabled value must be false (cap-xy default), not contaminated by cap-x removal',
|
||||
);
|
||||
});
|
||||
|
||||
test("removing 'x' does not drop 'x2.enabled' (numeric suffix, distinct cap)", () => {
|
||||
const registryAfterXRemoved = {
|
||||
'x2.enabled': boolSlice('cap-x2', true),
|
||||
};
|
||||
|
||||
const result = mergeFederatedConfig({
|
||||
configSchema: registryAfterXRemoved,
|
||||
isCentralKey: neverCentral,
|
||||
userConfig: {},
|
||||
});
|
||||
|
||||
assert.ok(
|
||||
!result.validKeys.includes('x.enabled'),
|
||||
'x.enabled must not appear (not in registry)',
|
||||
);
|
||||
assert.ok(
|
||||
result.validKeys.includes('x2.enabled'),
|
||||
'x2.enabled must survive — it belongs to cap-x2, not cap-x',
|
||||
);
|
||||
assert.strictEqual(
|
||||
result.values['x2.enabled'],
|
||||
true,
|
||||
'x2.enabled must have its own default (true)',
|
||||
);
|
||||
});
|
||||
|
||||
test("removing 'alpha' cap does not affect 'alphabeta.flag' cap", () => {
|
||||
const registryAfterAlphaRemoved = {
|
||||
'alphabeta.flag': boolSlice('cap-alphabeta', false),
|
||||
'gamma.flag': boolSlice('cap-gamma', true),
|
||||
};
|
||||
|
||||
const result = mergeFederatedConfig({
|
||||
configSchema: registryAfterAlphaRemoved,
|
||||
isCentralKey: neverCentral,
|
||||
userConfig: {},
|
||||
});
|
||||
|
||||
// alpha.flag not present (removed)
|
||||
assert.ok(
|
||||
!result.validKeys.includes('alpha.flag'),
|
||||
'alpha.flag must not appear after cap-alpha removal',
|
||||
);
|
||||
|
||||
// alphabeta.flag MUST be present (distinct capability)
|
||||
assert.ok(
|
||||
result.validKeys.includes('alphabeta.flag'),
|
||||
'alphabeta.flag must survive removal of alpha capability',
|
||||
);
|
||||
assert.strictEqual(
|
||||
result.values['alphabeta.flag'],
|
||||
false,
|
||||
'alphabeta.flag value must be false (its own default)',
|
||||
);
|
||||
|
||||
// gamma.flag also unaffected
|
||||
assert.ok(
|
||||
result.validKeys.includes('gamma.flag'),
|
||||
'gamma.flag must be unaffected by alpha removal',
|
||||
);
|
||||
assert.strictEqual(
|
||||
result.values['gamma.flag'],
|
||||
true,
|
||||
'gamma.flag value must be true (its own default)',
|
||||
);
|
||||
});
|
||||
});
|
||||
750
tests/loop-hooks-empty-points-e2e.test.cjs
Normal file
750
tests/loop-hooks-empty-points-e2e.test.cjs
Normal file
@@ -0,0 +1,750 @@
|
||||
'use strict';
|
||||
/**
|
||||
* E2E content tests for the GSD capability engine — ADR-857 phase 6
|
||||
*
|
||||
* Hook points tested: discuss:pre, discuss:post, execute:pre, execute:wave:pre,
|
||||
* verify:pre, ship:post
|
||||
*
|
||||
* All 6 points have zero hooks in the real registry by design.
|
||||
* Tests pin: exact envelope shape, placeholder string contract (Hyrum's Law),
|
||||
* resolver-filter mechanics (schema default / config-override / capabilityStatesById),
|
||||
* CLI contract (missing-arg, invalid-point), and Postel-leniency (malformed config).
|
||||
*
|
||||
* Rules:
|
||||
* - Every test drives a real command (CLI subprocess or real resolver + real registry).
|
||||
* - No readFileSync(...).includes() source-grep.
|
||||
* - Negative/BVA cases assert the SPECIFIC differing value so regression is caught.
|
||||
* - Each test is independently isolated with its own temp dir.
|
||||
*/
|
||||
|
||||
const { describe, it, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const GSD_TOOLS = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
|
||||
const realRegistry = require('../gsd-core/bin/lib/capability-registry.cjs');
|
||||
const { resolveLoopHooks, renderLoopHooks } = require('../gsd-core/bin/lib/loop-resolver.cjs');
|
||||
|
||||
// ─── Fixture helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Create a bare temp dir with .planning/ layout (no config.json) */
|
||||
function makeTempProject() {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-loop-e2e-'));
|
||||
fs.mkdirSync(path.join(dir, '.planning', 'phases'), { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
/** Create a temp dir with .planning/config.json set to the given object */
|
||||
function makeTempProjectWithConfig(configObj) {
|
||||
const dir = makeTempProject();
|
||||
fs.writeFileSync(path.join(dir, '.planning', 'config.json'), JSON.stringify(configObj));
|
||||
return dir;
|
||||
}
|
||||
|
||||
/** Create a bare temp dir with no .planning directory at all */
|
||||
function makeBareDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-loop-bare-'));
|
||||
}
|
||||
|
||||
/** Spawn gsd-tools via raw spawnSync; returns { status, stdout, stderr } */
|
||||
function spawnGsd(args, cwd) {
|
||||
return spawnSync(process.execPath, [GSD_TOOLS, ...args], {
|
||||
cwd: cwd || os.tmpdir(),
|
||||
encoding: 'utf8',
|
||||
timeout: 60000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a synthetic registry that has ALL 12 canonical byLoopPoint keys
|
||||
* (required so resolveLoopHooks does not reject valid canonical points),
|
||||
* with a single step at `targetPoint` that activates on `when` config key.
|
||||
*/
|
||||
function buildSyntheticRegistry({ targetPoint, when, schemaDefault }) {
|
||||
const allPoints = [
|
||||
'discuss:pre', 'discuss:post', 'plan:pre', 'plan:post',
|
||||
'execute:pre', 'execute:wave:pre', 'execute:wave:post', 'execute:post',
|
||||
'verify:pre', 'verify:post', 'ship:pre', 'ship:post',
|
||||
];
|
||||
const byLoopPoint = {};
|
||||
for (const p of allPoints) {
|
||||
byLoopPoint[p] = { steps: [], contributions: [], gates: [] };
|
||||
}
|
||||
// Add the step (or gate) at the target point
|
||||
byLoopPoint[targetPoint] = {
|
||||
steps: [{
|
||||
capId: 'future-cap',
|
||||
when,
|
||||
ref: { skill: 'future-skill' },
|
||||
}],
|
||||
contributions: [],
|
||||
gates: [],
|
||||
};
|
||||
const configSchema = {};
|
||||
if (when !== undefined && schemaDefault !== undefined) {
|
||||
configSchema[when] = { default: schemaDefault };
|
||||
}
|
||||
return { byLoopPoint, configSchema };
|
||||
}
|
||||
|
||||
// ─── Shared all-caps-on config (used by multiple tests) ──────────────────────
|
||||
const ALL_CAPS_ON_CONFIG = {
|
||||
workflow: {
|
||||
ui_phase: true,
|
||||
ui_review: true,
|
||||
ui_safety_gate: true,
|
||||
security_enforcement: true,
|
||||
tdd_mode: true,
|
||||
code_review: true,
|
||||
nyquist_validation: true,
|
||||
schema_drift_gate: true,
|
||||
post_planning_gaps: true,
|
||||
intel: { enabled: true },
|
||||
},
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// SECTION 1: discuss:pre
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('discuss:pre — real registry empty-resolution', () => {
|
||||
let tmpDir;
|
||||
before(() => { tmpDir = makeTempProject(); });
|
||||
after(() => { cleanup(tmpDir); });
|
||||
|
||||
it('[happy] discuss:pre with real registry returns exact 3-key envelope with empty activeHooks (Gall\'s Law E2E pin)', () => {
|
||||
const result = spawnGsd(['loop', 'render-hooks', 'discuss:pre', '--cwd', tmpDir, '--raw'], tmpDir);
|
||||
assert.strictEqual(result.status, 0, `expected exit 0, got ${result.status}. stderr: ${result.stderr}`);
|
||||
const envelope = JSON.parse(result.stdout.trim());
|
||||
assert.strictEqual(envelope.point, 'discuss:pre');
|
||||
assert.deepEqual(envelope.activeHooks, []);
|
||||
assert.strictEqual(envelope.rendered, '_No active hooks at discuss:pre._');
|
||||
assert.deepEqual(Object.keys(envelope).sort(), ['activeHooks', 'point', 'rendered'], 'envelope must have exactly 3 keys');
|
||||
});
|
||||
|
||||
it('[bva] discuss:pre with all capability config keys enabled still returns activeHooks:[] — config does not activate phantom hooks', () => {
|
||||
const configDir = makeTempProjectWithConfig(ALL_CAPS_ON_CONFIG);
|
||||
try {
|
||||
const result = spawnGsd(['loop', 'render-hooks', 'discuss:pre', '--cwd', configDir, '--raw'], configDir);
|
||||
assert.strictEqual(result.status, 0);
|
||||
const envelope = JSON.parse(result.stdout.trim());
|
||||
assert.deepEqual(envelope.activeHooks, [], 'No capability config should activate hooks at discuss:pre');
|
||||
assert.strictEqual(envelope.rendered, '_No active hooks at discuss:pre._');
|
||||
} finally {
|
||||
cleanup(configDir);
|
||||
}
|
||||
});
|
||||
|
||||
it('[happy] discuss:pre with real registry pure-function resolveLoopHooks returns empty activeHooks', () => {
|
||||
const resolved = resolveLoopHooks({ point: 'discuss:pre', registry: realRegistry, config: {} });
|
||||
assert.strictEqual(resolved.point, 'discuss:pre');
|
||||
assert.deepEqual(resolved.activeHooks, []);
|
||||
});
|
||||
|
||||
it('[happy] renderLoopHooks for discuss:pre empty state pins the exact Hyrum\'s-Law contract string', () => {
|
||||
const rendered = renderLoopHooks({ point: 'discuss:pre', activeHooks: [] });
|
||||
assert.strictEqual(rendered, '_No active hooks at discuss:pre._');
|
||||
});
|
||||
|
||||
it('[negative] discuss:pre missing-point argument to CLI exits non-zero with clear message', () => {
|
||||
const result = spawnGsd(['loop', 'render-hooks', '--raw'], tmpDir);
|
||||
assert.notStrictEqual(result.status, 0, 'must exit non-zero when point arg is missing');
|
||||
const combined = (result.stdout + result.stderr);
|
||||
assert.match(combined, /render-hooks requires a .point. argument/i);
|
||||
});
|
||||
|
||||
it('[bva] discuss:pre close-typo "discuss:pre " (trailing space) exits non-zero — boundary for point name validation', () => {
|
||||
const result = spawnGsd(['loop', 'render-hooks', 'discuss:pre ', '--raw'], tmpDir);
|
||||
assert.notStrictEqual(result.status, 0, 'must exit non-zero for invalid point');
|
||||
const combined = (result.stdout + result.stderr);
|
||||
assert.match(combined, /Invalid loop point/i);
|
||||
// Must list valid points so callers know what to use
|
||||
assert.match(combined, /discuss:pre[,\s]/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// SECTION 2: discuss:post — resolution + synthetic mechanics
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('discuss:post — E2E empty envelope + synthetic resolver mechanics', () => {
|
||||
let tmpDir;
|
||||
before(() => { tmpDir = makeTempProjectWithConfig({}); });
|
||||
after(() => { cleanup(tmpDir); });
|
||||
|
||||
it('[empty-resolution] discuss:post E2E subprocess returns exact empty envelope — activeHooks:[], 3-key shape, placeholder string pinned', () => {
|
||||
const result = spawnGsd(['loop', 'render-hooks', 'discuss:post', '--raw', '--cwd', tmpDir], tmpDir);
|
||||
assert.strictEqual(result.status, 0, `expected exit 0. stderr: ${result.stderr}`);
|
||||
const envelope = JSON.parse(result.stdout.trim());
|
||||
assert.strictEqual(envelope.point, 'discuss:post');
|
||||
assert.deepEqual(envelope.activeHooks, []);
|
||||
assert.strictEqual(envelope.rendered, '_No active hooks at discuss:post._');
|
||||
assert.deepEqual(Object.keys(envelope).sort(), ['activeHooks', 'point', 'rendered']);
|
||||
assert.ok(!Object.prototype.hasOwnProperty.call(envelope, 'warnings'), 'must not have spurious warnings field');
|
||||
});
|
||||
|
||||
it('[happy] discuss:post E2E with no .planning directory returns empty hooks (Postel leniency path)', () => {
|
||||
const bareDir = makeBareDir();
|
||||
try {
|
||||
const result = spawnGsd(['loop', 'render-hooks', 'discuss:post', '--raw', '--cwd', bareDir], bareDir);
|
||||
assert.strictEqual(result.status, 0, `expected exit 0 even with no .planning dir. stderr: ${result.stderr}`);
|
||||
const envelope = JSON.parse(result.stdout.trim());
|
||||
assert.strictEqual(envelope.activeHooks.length, 0);
|
||||
assert.strictEqual(envelope.rendered, '_No active hooks at discuss:post._');
|
||||
} finally {
|
||||
cleanup(bareDir);
|
||||
}
|
||||
});
|
||||
|
||||
it('[happy] discuss:post with real registry resolveLoopHooks returns activeHooks:[] (real registry, not synthetic)', () => {
|
||||
const resolved = resolveLoopHooks({ point: 'discuss:post', registry: realRegistry, config: {} });
|
||||
assert.strictEqual(resolved.point, 'discuss:post');
|
||||
assert.strictEqual(resolved.activeHooks.length, 0, 'Real registry must have 0 hooks at discuss:post');
|
||||
});
|
||||
|
||||
it('[bva] discuss:post with synthetic capability, schema default=false + config absent → hook absent (schema default=false suppresses hook)', () => {
|
||||
const reg = buildSyntheticRegistry({
|
||||
targetPoint: 'discuss:post',
|
||||
when: 'workflow.testcap_on',
|
||||
schemaDefault: false,
|
||||
});
|
||||
const resolved = resolveLoopHooks({ point: 'discuss:post', registry: reg, config: {} });
|
||||
assert.strictEqual(resolved.activeHooks.length, 0, 'schema default=false must suppress hook when config absent');
|
||||
});
|
||||
|
||||
it('[bva] discuss:post with synthetic capability, schema default=true + config absent → hook active; explicit config=false overrides → hook absent (BVA at config-wins threshold)', () => {
|
||||
const reg = buildSyntheticRegistry({
|
||||
targetPoint: 'discuss:post',
|
||||
when: 'workflow.testcap_on',
|
||||
schemaDefault: true,
|
||||
});
|
||||
|
||||
// Sub-case a: schema default=true, no config → active
|
||||
const resolvedA = resolveLoopHooks({ point: 'discuss:post', registry: reg, config: {} });
|
||||
assert.strictEqual(resolvedA.activeHooks.length, 1, 'schema default=true must activate hook when config absent');
|
||||
|
||||
// Sub-case b: explicit config=false → overrides schema default → inactive
|
||||
const resolvedB = resolveLoopHooks({
|
||||
point: 'discuss:post',
|
||||
registry: reg,
|
||||
config: { workflow: { testcap_on: false } },
|
||||
});
|
||||
assert.strictEqual(resolvedB.activeHooks.length, 0, 'explicit config=false must override schema default=true');
|
||||
});
|
||||
|
||||
it('[bva] discuss:post with synthetic capability, capabilityStatesById enabled=false → hook absent even when config=true', () => {
|
||||
const reg = buildSyntheticRegistry({
|
||||
targetPoint: 'discuss:post',
|
||||
when: 'workflow.testcap_on',
|
||||
schemaDefault: true,
|
||||
});
|
||||
const resolved = resolveLoopHooks({
|
||||
point: 'discuss:post',
|
||||
registry: reg,
|
||||
config: { workflow: { testcap_on: true } },
|
||||
capabilityStatesById: new Map([['future-cap', { enabled: false }]]),
|
||||
});
|
||||
assert.strictEqual(resolved.activeHooks.length, 0, 'capabilityStatesById enabled=false must suppress hook even when config=true');
|
||||
});
|
||||
|
||||
it('[negative] discuss:post with invalid point name "discuss:past" exits non-zero and lists valid points', () => {
|
||||
const result = spawnGsd(['loop', 'render-hooks', 'discuss:past', '--raw', '--cwd', tmpDir], tmpDir);
|
||||
assert.notStrictEqual(result.status, 0, 'must exit non-zero for typo point name');
|
||||
const combined = (result.stdout + result.stderr);
|
||||
assert.match(combined, /discuss:past|Invalid loop point/i);
|
||||
assert.match(combined, /discuss:pre/);
|
||||
});
|
||||
|
||||
it('[negative] discuss:post E2E with malformed config.json → still exits 0 with empty hooks (Postel)', () => {
|
||||
const malformedDir = makeTempProject();
|
||||
fs.writeFileSync(path.join(malformedDir, '.planning', 'config.json'), '{invalid json');
|
||||
try {
|
||||
const result = spawnGsd(['loop', 'render-hooks', 'discuss:post', '--raw', '--cwd', malformedDir], malformedDir);
|
||||
assert.strictEqual(result.status, 0, `must not crash on malformed config. stderr: ${result.stderr}`);
|
||||
const envelope = JSON.parse(result.stdout.trim());
|
||||
assert.strictEqual(envelope.activeHooks.length, 0);
|
||||
assert.strictEqual(envelope.rendered, '_No active hooks at discuss:post._');
|
||||
} finally {
|
||||
cleanup(malformedDir);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// SECTION 3: execute:pre
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('execute:pre — real registry empty-resolution + synthetic resolver mechanics', () => {
|
||||
it('[happy] execute:pre with real registry + all capability flags enabled returns 0 active hooks and exact placeholder', () => {
|
||||
const allOnDir = makeTempProjectWithConfig(ALL_CAPS_ON_CONFIG);
|
||||
try {
|
||||
const result = spawnGsd(['loop', 'render-hooks', 'execute:pre', '--raw', '--cwd', allOnDir], allOnDir);
|
||||
assert.strictEqual(result.status, 0, `expected exit 0. stderr: ${result.stderr}`);
|
||||
const parsed = JSON.parse(result.stdout.trim());
|
||||
assert.strictEqual(parsed.point, 'execute:pre');
|
||||
assert.strictEqual(parsed.activeHooks.length, 0);
|
||||
assert.strictEqual(parsed.rendered, '_No active hooks at execute:pre._');
|
||||
} finally {
|
||||
cleanup(allOnDir);
|
||||
}
|
||||
});
|
||||
|
||||
it('[empty-resolution] execute:pre with no config.json returns 0 active hooks (empty-project negative space)', () => {
|
||||
const emptyDir = makeTempProject();
|
||||
try {
|
||||
const result = spawnGsd(['loop', 'render-hooks', 'execute:pre', '--raw', '--cwd', emptyDir], emptyDir);
|
||||
assert.strictEqual(result.status, 0);
|
||||
const parsed = JSON.parse(result.stdout.trim());
|
||||
assert.strictEqual(parsed.activeHooks.length, 0);
|
||||
assert.strictEqual(parsed.rendered, '_No active hooks at execute:pre._');
|
||||
} finally {
|
||||
cleanup(emptyDir);
|
||||
}
|
||||
});
|
||||
|
||||
it('[happy] execute:pre with synthetic step+contribution+gate registered and when-flag=true → all 3 hooks activated', () => {
|
||||
const allPoints = [
|
||||
'discuss:pre', 'discuss:post', 'plan:pre', 'plan:post',
|
||||
'execute:pre', 'execute:wave:pre', 'execute:wave:post', 'execute:post',
|
||||
'verify:pre', 'verify:post', 'ship:pre', 'ship:post',
|
||||
];
|
||||
const byLoopPoint = {};
|
||||
for (const p of allPoints) {
|
||||
byLoopPoint[p] = { steps: [], contributions: [], gates: [] };
|
||||
}
|
||||
byLoopPoint['execute:pre'] = {
|
||||
steps: [{ capId: 'future-cap', when: 'workflow.future_enabled', ref: { skill: 'step-skill' } }],
|
||||
contributions: [{ capId: 'future-cap', when: 'workflow.future_enabled', into: 'context' }],
|
||||
gates: [{ capId: 'future-cap', when: 'workflow.future_enabled', check: { query: 'future.gate' }, blocking: true, onError: 'halt' }],
|
||||
};
|
||||
const syntheticReg = {
|
||||
byLoopPoint,
|
||||
configSchema: { 'workflow.future_enabled': { default: false } },
|
||||
};
|
||||
|
||||
const resolved = resolveLoopHooks({
|
||||
point: 'execute:pre',
|
||||
registry: syntheticReg,
|
||||
config: { workflow: { future_enabled: true } },
|
||||
});
|
||||
assert.strictEqual(resolved.activeHooks.length, 3, 'All 3 hooks (step, contribution, gate) must be active when when-flag=true');
|
||||
assert.strictEqual(resolved.activeHooks[0].kind, 'step');
|
||||
assert.strictEqual(resolved.activeHooks[1].kind, 'contribution');
|
||||
assert.strictEqual(resolved.activeHooks[2].kind, 'gate');
|
||||
});
|
||||
|
||||
it('[negative] execute:pre with synthetic hook registered but when-flag=false → hook filtered, activeHooks=[], rendered is placeholder', () => {
|
||||
const reg = buildSyntheticRegistry({ targetPoint: 'execute:pre', when: 'workflow.future_enabled', schemaDefault: false });
|
||||
const resolved = resolveLoopHooks({
|
||||
point: 'execute:pre',
|
||||
registry: reg,
|
||||
config: { workflow: { future_enabled: false } },
|
||||
});
|
||||
assert.strictEqual(resolved.activeHooks.length, 0, 'when-flag=false must filter hook');
|
||||
const rendered = renderLoopHooks(resolved);
|
||||
assert.strictEqual(rendered, '_No active hooks at execute:pre._');
|
||||
});
|
||||
|
||||
it('[negative] execute:pre with synthetic unconditional hook but capability disabled via capabilityStatesById → hook filtered', () => {
|
||||
const allPoints = [
|
||||
'discuss:pre', 'discuss:post', 'plan:pre', 'plan:post',
|
||||
'execute:pre', 'execute:wave:pre', 'execute:wave:post', 'execute:post',
|
||||
'verify:pre', 'verify:post', 'ship:pre', 'ship:post',
|
||||
];
|
||||
const byLoopPoint = {};
|
||||
for (const p of allPoints) {
|
||||
byLoopPoint[p] = { steps: [], contributions: [], gates: [] };
|
||||
}
|
||||
// No `when` = unconditional hook
|
||||
byLoopPoint['execute:pre'] = {
|
||||
steps: [{ capId: 'future-cap', ref: { skill: 'future-skill' } }],
|
||||
contributions: [],
|
||||
gates: [],
|
||||
};
|
||||
const syntheticReg = { byLoopPoint, configSchema: {} };
|
||||
|
||||
const resolved = resolveLoopHooks({
|
||||
point: 'execute:pre',
|
||||
registry: syntheticReg,
|
||||
config: {},
|
||||
capabilityStatesById: new Map([['future-cap', { enabled: false }]]),
|
||||
});
|
||||
assert.strictEqual(resolved.activeHooks.length, 0, 'capabilityStatesById disabled must filter unconditional hook');
|
||||
});
|
||||
|
||||
it('[bva] execute:pre with schema default=true synthetic hook and NO config → hook activates (schema default boundary)', () => {
|
||||
const reg = buildSyntheticRegistry({
|
||||
targetPoint: 'execute:pre',
|
||||
when: 'workflow.future_enabled',
|
||||
schemaDefault: true,
|
||||
});
|
||||
const resolved = resolveLoopHooks({ point: 'execute:pre', registry: reg, config: {} });
|
||||
assert.strictEqual(resolved.activeHooks.length, 1, 'schema default=true must activate hook even with absent config');
|
||||
});
|
||||
|
||||
it('[negative] real registry has exactly 0 hooks at execute:pre — guard against accidental registration', () => {
|
||||
const entry = realRegistry.byLoopPoint['execute:pre'];
|
||||
assert.ok(entry, 'execute:pre must be present in real registry byLoopPoint');
|
||||
assert.strictEqual(entry.steps.length, 0, 'execute:pre must have 0 steps in real registry');
|
||||
assert.strictEqual(entry.contributions.length, 0, 'execute:pre must have 0 contributions in real registry');
|
||||
assert.strictEqual(entry.gates.length, 0, 'execute:pre must have 0 gates in real registry');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// SECTION 4: execute:wave:pre
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('execute:wave:pre — real registry empty-resolution + synthetic mechanics', () => {
|
||||
it('[empty-resolution] execute:wave:pre with real registry returns empty activeHooks and exact placeholder text', () => {
|
||||
const result = spawnGsd(['loop', 'render-hooks', 'execute:wave:pre', '--raw'], os.tmpdir());
|
||||
assert.strictEqual(result.status, 0, `expected exit 0. stderr: ${result.stderr}`);
|
||||
const envelope = JSON.parse(result.stdout.trim());
|
||||
assert.strictEqual(envelope.point, 'execute:wave:pre');
|
||||
assert.ok(Array.isArray(envelope.activeHooks), 'activeHooks must be an array');
|
||||
assert.strictEqual(envelope.activeHooks.length, 0);
|
||||
assert.strictEqual(envelope.rendered, '_No active hooks at execute:wave:pre._');
|
||||
});
|
||||
|
||||
it('[happy] execute:wave:pre with synthetic registry containing a gate hook resolves it correctly', () => {
|
||||
const allPoints = [
|
||||
'discuss:pre', 'discuss:post', 'plan:pre', 'plan:post',
|
||||
'execute:pre', 'execute:wave:pre', 'execute:wave:post', 'execute:post',
|
||||
'verify:pre', 'verify:post', 'ship:pre', 'ship:post',
|
||||
];
|
||||
const byLoopPoint = {};
|
||||
for (const p of allPoints) {
|
||||
byLoopPoint[p] = { steps: [], contributions: [], gates: [] };
|
||||
}
|
||||
byLoopPoint['execute:wave:pre'] = {
|
||||
steps: [],
|
||||
contributions: [],
|
||||
gates: [{
|
||||
capId: 'future-cap',
|
||||
// no `when` = unconditional
|
||||
check: { query: 'future.gate' },
|
||||
blocking: true,
|
||||
onError: 'halt',
|
||||
}],
|
||||
};
|
||||
const syntheticReg = { byLoopPoint, configSchema: {} };
|
||||
|
||||
const resolved = resolveLoopHooks({ point: 'execute:wave:pre', registry: syntheticReg, config: {} });
|
||||
assert.strictEqual(resolved.activeHooks.length, 1);
|
||||
const gate = resolved.activeHooks[0];
|
||||
assert.strictEqual(gate.kind, 'gate');
|
||||
assert.strictEqual(gate.capId, 'future-cap');
|
||||
assert.deepEqual(gate.check, { query: 'future.gate' });
|
||||
assert.strictEqual(gate.blocking, true);
|
||||
assert.strictEqual(gate.onError, 'halt');
|
||||
});
|
||||
|
||||
it('[negative] execute:wave:pre with synthetic step hook and when=false config → hook filtered → empty', () => {
|
||||
const reg = buildSyntheticRegistry({
|
||||
targetPoint: 'execute:wave:pre',
|
||||
when: 'workflow.wave_pre_enabled',
|
||||
schemaDefault: true,
|
||||
});
|
||||
|
||||
// BVA: schema default=true without override → active
|
||||
const resolvedDefault = resolveLoopHooks({ point: 'execute:wave:pre', registry: reg, config: {} });
|
||||
assert.strictEqual(resolvedDefault.activeHooks.length, 1, 'schema default=true with no config override must activate');
|
||||
|
||||
// BVA: config override false → inactive
|
||||
const resolvedOff = resolveLoopHooks({
|
||||
point: 'execute:wave:pre',
|
||||
registry: reg,
|
||||
config: { workflow: { wave_pre_enabled: false } },
|
||||
});
|
||||
assert.strictEqual(resolvedOff.activeHooks.length, 0, 'explicit config=false must override schema default=true');
|
||||
});
|
||||
|
||||
it('[bva] execute:wave:pre BVA: schema default=true → active (threshold=on), schema default=false → inactive (threshold=off)', () => {
|
||||
const regA = buildSyntheticRegistry({ targetPoint: 'execute:wave:pre', when: 'workflow.flag', schemaDefault: true });
|
||||
const regB = buildSyntheticRegistry({ targetPoint: 'execute:wave:pre', when: 'workflow.flag', schemaDefault: false });
|
||||
|
||||
const resolvedA = resolveLoopHooks({ point: 'execute:wave:pre', registry: regA, config: {} });
|
||||
assert.strictEqual(resolvedA.activeHooks.length, 1, 'registry A (default=true) must activate hook');
|
||||
|
||||
const resolvedB = resolveLoopHooks({ point: 'execute:wave:pre', registry: regB, config: {} });
|
||||
assert.strictEqual(resolvedB.activeHooks.length, 0, 'registry B (default=false) must NOT activate hook');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// SECTION 5: verify:pre
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('verify:pre — real registry empty-resolution + synthetic extension-point readiness', () => {
|
||||
let tmpEmptyProjectDir;
|
||||
let tmpProjectDirAllOn;
|
||||
before(() => {
|
||||
tmpEmptyProjectDir = makeTempProject();
|
||||
tmpProjectDirAllOn = makeTempProjectWithConfig(ALL_CAPS_ON_CONFIG);
|
||||
});
|
||||
after(() => {
|
||||
cleanup(tmpEmptyProjectDir);
|
||||
cleanup(tmpProjectDirAllOn);
|
||||
});
|
||||
|
||||
it('[empty-resolution] verify:pre with real registry and no config yields empty activeHooks and exact placeholder (Gall\'s Law)', () => {
|
||||
const resolved = resolveLoopHooks({ point: 'verify:pre', registry: realRegistry, config: {} });
|
||||
assert.strictEqual(resolved.activeHooks.length, 0);
|
||||
assert.strictEqual(renderLoopHooks(resolved), '_No active hooks at verify:pre._');
|
||||
});
|
||||
|
||||
it('[happy] verify:pre E2E subprocess returns well-formed 3-key JSON envelope with empty activeHooks (Hyrum\'s Law contract pin)', () => {
|
||||
const result = spawnGsd(['loop', 'render-hooks', 'verify:pre', '--cwd', tmpEmptyProjectDir, '--raw'], tmpEmptyProjectDir);
|
||||
assert.strictEqual(result.status, 0, `expected exit 0. stderr: ${result.stderr}`);
|
||||
const envelope = JSON.parse(result.stdout.trim());
|
||||
assert.strictEqual(envelope.point, 'verify:pre');
|
||||
assert.deepEqual(envelope.activeHooks, []);
|
||||
assert.strictEqual(envelope.rendered, '_No active hooks at verify:pre._');
|
||||
assert.deepEqual(Object.keys(envelope).sort(), ['activeHooks', 'point', 'rendered']);
|
||||
});
|
||||
|
||||
it('[negative] verify:pre with all capability config keys set to true still yields empty activeHooks — no leakage from other points', () => {
|
||||
const resolved = resolveLoopHooks({
|
||||
point: 'verify:pre',
|
||||
registry: realRegistry,
|
||||
config: { workflow: { ui_phase: true, ui_review: true, ui_safety_gate: true } },
|
||||
});
|
||||
assert.strictEqual(resolved.activeHooks.length, 0, 'UI and other capabilities must not bleed through to verify:pre');
|
||||
});
|
||||
|
||||
it('[bva] Synthetic step at verify:pre with configSchema default=true fires correctly — extension point readiness', () => {
|
||||
const reg = buildSyntheticRegistry({ targetPoint: 'verify:pre', when: 'workflow.future_enabled', schemaDefault: true });
|
||||
const resolved = resolveLoopHooks({ point: 'verify:pre', registry: reg, config: {} });
|
||||
assert.strictEqual(resolved.activeHooks.length, 1, 'synthetic step at verify:pre with default=true must activate');
|
||||
assert.strictEqual(resolved.activeHooks[0].capId, 'future-cap');
|
||||
assert.strictEqual(resolved.activeHooks[0].kind, 'step');
|
||||
const rendered = renderLoopHooks(resolved);
|
||||
assert.match(rendered, /future-skill/);
|
||||
assert.match(rendered, /future-cap/);
|
||||
});
|
||||
|
||||
it('[bva] Synthetic step at verify:pre with when=false (config override) filters correctly — activation logic applies at this point', () => {
|
||||
const reg = buildSyntheticRegistry({ targetPoint: 'verify:pre', when: 'workflow.future_enabled', schemaDefault: true });
|
||||
const resolved = resolveLoopHooks({
|
||||
point: 'verify:pre',
|
||||
registry: reg,
|
||||
config: { workflow: { future_enabled: false } },
|
||||
});
|
||||
assert.strictEqual(resolved.activeHooks.length, 0, 'config explicit false must beat schema default=true');
|
||||
});
|
||||
|
||||
it('[negative] verify:pre with malformed config.json in .planning/ degrades leniently (Postel\'s Law at this point)', () => {
|
||||
const malformedDir = makeTempProject();
|
||||
fs.writeFileSync(path.join(malformedDir, '.planning', 'config.json'), '{invalid json');
|
||||
try {
|
||||
const result = spawnGsd(['loop', 'render-hooks', 'verify:pre', '--cwd', malformedDir, '--raw'], malformedDir);
|
||||
assert.strictEqual(result.status, 0, `must not crash on malformed config. stderr: ${result.stderr}`);
|
||||
const envelope = JSON.parse(result.stdout.trim());
|
||||
assert.deepEqual(envelope.activeHooks, []);
|
||||
} finally {
|
||||
cleanup(malformedDir);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// SECTION 6: ship:post
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('ship:post — real registry empty-resolution + resilience to registry edge-cases', () => {
|
||||
it('[happy] ship:post E2E subprocess returns typed envelope: point=\'ship:post\', activeHooks=[], rendered=placeholder, no extra keys', () => {
|
||||
const tmpDir = makeTempProject();
|
||||
try {
|
||||
const result = spawnGsd(['loop', 'render-hooks', 'ship:post', '--raw', '--cwd', tmpDir], tmpDir);
|
||||
assert.strictEqual(result.status, 0, `expected exit 0. stderr: ${result.stderr}`);
|
||||
const envelope = JSON.parse(result.stdout.trim());
|
||||
assert.strictEqual(envelope.point, 'ship:post');
|
||||
assert.deepEqual(envelope.activeHooks, []);
|
||||
assert.strictEqual(envelope.rendered, '_No active hooks at ship:post._');
|
||||
assert.deepEqual(Object.keys(envelope).sort(), ['activeHooks', 'point', 'rendered']);
|
||||
assert.ok(!Object.prototype.hasOwnProperty.call(envelope, 'warnings'), 'must not have warnings key');
|
||||
} finally {
|
||||
cleanup(tmpDir);
|
||||
}
|
||||
});
|
||||
|
||||
it('[bva] ship:post returns empty activeHooks regardless of any capability config being enabled — no config leaks hooks into this point', () => {
|
||||
const allOnDir = makeTempProjectWithConfig(ALL_CAPS_ON_CONFIG);
|
||||
try {
|
||||
const result = spawnGsd(['loop', 'render-hooks', 'ship:post', '--raw', '--cwd', allOnDir], allOnDir);
|
||||
assert.strictEqual(result.status, 0);
|
||||
const envelope = JSON.parse(result.stdout.trim());
|
||||
assert.deepEqual(envelope.activeHooks, [], 'Maximum capability activation must still produce zero hooks at ship:post');
|
||||
} finally {
|
||||
cleanup(allOnDir);
|
||||
}
|
||||
});
|
||||
|
||||
it('[happy] resolveLoopHooks with real capability-registry at ship:post returns empty activeHooks and well-formed byLoopPoint entry', () => {
|
||||
const resolved = resolveLoopHooks({ point: 'ship:post', registry: realRegistry, config: {} });
|
||||
assert.strictEqual(resolved.point, 'ship:post');
|
||||
assert.ok(Array.isArray(resolved.activeHooks));
|
||||
assert.strictEqual(resolved.activeHooks.length, 0);
|
||||
|
||||
const entry = realRegistry.byLoopPoint['ship:post'];
|
||||
assert.ok(entry, 'ship:post must be present in real registry byLoopPoint');
|
||||
assert.strictEqual(entry.steps.length, 0, 'ship:post must have 0 steps');
|
||||
assert.strictEqual(entry.contributions.length, 0, 'ship:post must have 0 contributions');
|
||||
assert.strictEqual(entry.gates.length, 0, 'ship:post must have 0 gates');
|
||||
});
|
||||
|
||||
it('[negative] ship:post E2E exits 0 and returns empty envelope when project has no .planning directory at all', () => {
|
||||
const bareDir = makeBareDir();
|
||||
try {
|
||||
const result = spawnGsd(['loop', 'render-hooks', 'ship:post', '--raw', '--cwd', bareDir], bareDir);
|
||||
assert.strictEqual(result.status, 0, `expected exit 0 even with no .planning dir. stderr: ${result.stderr}`);
|
||||
const envelope = JSON.parse(result.stdout.trim());
|
||||
assert.strictEqual(envelope.activeHooks.length, 0);
|
||||
assert.strictEqual(envelope.rendered, '_No active hooks at ship:post._');
|
||||
} finally {
|
||||
cleanup(bareDir);
|
||||
}
|
||||
});
|
||||
|
||||
it('[negative] resolveLoopHooks does not throw and returns empty hooks when byLoopPoint[\'ship:post\'] is null', () => {
|
||||
const allPoints = [
|
||||
'discuss:pre', 'discuss:post', 'plan:pre', 'plan:post',
|
||||
'execute:pre', 'execute:wave:pre', 'execute:wave:post', 'execute:post',
|
||||
'verify:pre', 'verify:post', 'ship:pre', 'ship:post',
|
||||
];
|
||||
const byLoopPoint = {};
|
||||
for (const p of allPoints) {
|
||||
byLoopPoint[p] = { steps: [], contributions: [], gates: [] };
|
||||
}
|
||||
byLoopPoint['ship:post'] = null;
|
||||
const syntheticReg = { byLoopPoint, configSchema: {} };
|
||||
|
||||
const resolved = resolveLoopHooks({ point: 'ship:post', registry: syntheticReg, config: {} });
|
||||
assert.strictEqual(resolved.activeHooks.length, 0, 'null byLoopPoint entry must not throw and must return 0 hooks');
|
||||
});
|
||||
|
||||
it('[negative] resolveLoopHooks does not throw when byLoopPoint[\'ship:post\'] exists but has no steps/contributions/gates keys', () => {
|
||||
const allPoints = [
|
||||
'discuss:pre', 'discuss:post', 'plan:pre', 'plan:post',
|
||||
'execute:pre', 'execute:wave:pre', 'execute:wave:post', 'execute:post',
|
||||
'verify:pre', 'verify:post', 'ship:pre', 'ship:post',
|
||||
];
|
||||
const byLoopPoint = {};
|
||||
for (const p of allPoints) {
|
||||
byLoopPoint[p] = { steps: [], contributions: [], gates: [] };
|
||||
}
|
||||
byLoopPoint['ship:post'] = {}; // No arrays at all
|
||||
const syntheticReg = { byLoopPoint, configSchema: {} };
|
||||
|
||||
const resolved = resolveLoopHooks({ point: 'ship:post', registry: syntheticReg, config: {} });
|
||||
assert.strictEqual(resolved.activeHooks.length, 0, 'missing arrays in byLoopPoint entry must not throw');
|
||||
});
|
||||
|
||||
it('[bva] ship:post returns empty activeHooks even when security_enforcement=true — security gate lives at ship:pre not ship:post', () => {
|
||||
const securityOnDir = makeTempProjectWithConfig({ workflow: { security_enforcement: true } });
|
||||
try {
|
||||
// ship:post must be empty
|
||||
const postResult = spawnGsd(['loop', 'render-hooks', 'ship:post', '--raw', '--cwd', securityOnDir], securityOnDir);
|
||||
assert.strictEqual(postResult.status, 0);
|
||||
const postEnvelope = JSON.parse(postResult.stdout.trim());
|
||||
assert.deepEqual(postEnvelope.activeHooks, [], 'ship:post must be empty even with security_enforcement=true');
|
||||
|
||||
// ship:pre must have the security gate (proves the config actually works and the difference is real)
|
||||
const preResult = spawnGsd(['loop', 'render-hooks', 'ship:pre', '--raw', '--cwd', securityOnDir], securityOnDir);
|
||||
assert.strictEqual(preResult.status, 0);
|
||||
const preEnvelope = JSON.parse(preResult.stdout.trim());
|
||||
const secGate = preEnvelope.activeHooks.find(h => h.capId === 'security');
|
||||
assert.ok(secGate, 'ship:pre must have a security gate when security_enforcement=true');
|
||||
} finally {
|
||||
cleanup(securityOnDir);
|
||||
}
|
||||
});
|
||||
|
||||
it('[empty-resolution] ship:post byLoopPoint entry exists in the real registry with all three arrays empty — no capability has silently self-registered here', () => {
|
||||
const entry = realRegistry.byLoopPoint['ship:post'];
|
||||
assert.ok(entry, 'ship:post must be present in real registry byLoopPoint');
|
||||
assert.strictEqual(entry.steps.length, 0, 'ship:post must have 0 steps — accidental registration guard');
|
||||
assert.strictEqual(entry.contributions.length, 0, 'ship:post must have 0 contributions — accidental registration guard');
|
||||
assert.strictEqual(entry.gates.length, 0, 'ship:post must have 0 gates — accidental registration guard');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// SECTION 7: CLI contract (shared across all 6 points)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('CLI contract — missing/invalid point argument (shared across all 6 empty points)', () => {
|
||||
it('[negative] missing point argument exits non-zero and includes render-hooks syntax in error message', () => {
|
||||
const result = spawnGsd(['loop', 'render-hooks', '--raw'], os.tmpdir());
|
||||
assert.notStrictEqual(result.status, 0, 'must exit non-zero when point arg is missing');
|
||||
const combined = (result.stdout + result.stderr);
|
||||
assert.match(combined, /render-hooks requires a .point. argument/i);
|
||||
});
|
||||
|
||||
it('[bva] "discuss:post " with trailing space exits non-zero — boundary: trailing whitespace makes point invalid', () => {
|
||||
const result = spawnGsd(['loop', 'render-hooks', 'discuss:post ', '--raw'], os.tmpdir());
|
||||
assert.notStrictEqual(result.status, 0, 'must reject point with trailing space');
|
||||
const combined = (result.stdout + result.stderr);
|
||||
assert.match(combined, /Invalid loop point/i);
|
||||
});
|
||||
|
||||
it('[bva] "execute:pre." with trailing period exits non-zero — boundary: period suffix makes point invalid', () => {
|
||||
const result = spawnGsd(['loop', 'render-hooks', 'execute:pre.', '--raw'], os.tmpdir());
|
||||
assert.notStrictEqual(result.status, 0, 'must reject point with trailing period');
|
||||
const combined = (result.stdout + result.stderr);
|
||||
assert.match(combined, /Invalid loop point/i);
|
||||
});
|
||||
|
||||
it('[negative] error message for invalid point includes list of valid points so callers can self-correct', () => {
|
||||
const result = spawnGsd(['loop', 'render-hooks', 'verify:future', '--raw'], os.tmpdir());
|
||||
assert.notStrictEqual(result.status, 0);
|
||||
const combined = (result.stdout + result.stderr);
|
||||
// Must include at least several valid points in the error message
|
||||
assert.match(combined, /discuss:pre/);
|
||||
assert.match(combined, /ship:post/);
|
||||
assert.match(combined, /verify:pre/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// SECTION 8: Parametric empty-point sweep across all 6 points (E2E regression guard)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Parametric E2E sweep — all 6 empty points return correct envelope shape via real registry', () => {
|
||||
const EMPTY_POINTS = [
|
||||
'discuss:pre',
|
||||
'discuss:post',
|
||||
'execute:pre',
|
||||
'execute:wave:pre',
|
||||
'verify:pre',
|
||||
'ship:post',
|
||||
];
|
||||
|
||||
for (const point of EMPTY_POINTS) {
|
||||
it(`[parametric] ${point} — E2E subprocess exits 0 with {point, activeHooks:[], rendered:placeholder}`, () => {
|
||||
const tmpDir = makeTempProject();
|
||||
try {
|
||||
const result = spawnGsd(['loop', 'render-hooks', point, '--raw', '--cwd', tmpDir], tmpDir);
|
||||
assert.strictEqual(result.status, 0, `${point}: expected exit 0. stderr: ${result.stderr}`);
|
||||
const envelope = JSON.parse(result.stdout.trim());
|
||||
assert.strictEqual(envelope.point, point, `${point}: envelope.point mismatch`);
|
||||
assert.ok(Array.isArray(envelope.activeHooks), `${point}: activeHooks must be an array`);
|
||||
assert.strictEqual(envelope.activeHooks.length, 0, `${point}: activeHooks must be empty`);
|
||||
assert.strictEqual(envelope.rendered, `_No active hooks at ${point}._`, `${point}: rendered placeholder mismatch`);
|
||||
} finally {
|
||||
cleanup(tmpDir);
|
||||
}
|
||||
});
|
||||
|
||||
it(`[parametric] ${point} — pure-function resolveLoopHooks with real registry returns 0 activeHooks`, () => {
|
||||
const resolved = resolveLoopHooks({ point, registry: realRegistry, config: {} });
|
||||
assert.strictEqual(resolved.point, point);
|
||||
assert.strictEqual(resolved.activeHooks.length, 0, `${point}: real registry must have 0 hooks at this point`);
|
||||
});
|
||||
}
|
||||
});
|
||||
309
tests/loop-hooks-ship-pre-e2e.test.cjs
Normal file
309
tests/loop-hooks-ship-pre-e2e.test.cjs
Normal file
@@ -0,0 +1,309 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* loop-hooks-ship-pre-e2e.test.cjs — E2E content tests for the ship:pre hook point.
|
||||
*
|
||||
* ADR-857 phase 6 gap coverage. Tests cover:
|
||||
* - loop render-hooks ship:pre CLI subprocess (envelope shape, predicate typing)
|
||||
* - frontmatter get CLI subprocess (threats_open field contract)
|
||||
* - resolveLoopHooks pure-function with realRegistry (predicate.equals integer contract)
|
||||
*
|
||||
* NOTE: ship:pre has NO runnable predicate evaluator — enforcement is ship.md prose only.
|
||||
* The check.predicate shape is asserted here to pin the Hyrum's-law contract for downstream
|
||||
* consumers (workflow prose, manual ship gate). This is a known robustness gap (kerckhoffs).
|
||||
*
|
||||
* Follows RULESET.TESTS (no source-grep, BVA at thresholds, genuine assertions).
|
||||
*/
|
||||
|
||||
const { describe, test, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const GSD_TOOLS = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
|
||||
|
||||
const {
|
||||
resolveLoopHooks,
|
||||
} = require('../gsd-core/bin/lib/loop-resolver.cjs');
|
||||
|
||||
const realRegistry = require('../gsd-core/bin/lib/capability-registry.cjs');
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Run gsd-tools synchronously via spawnSync. Returns { status, stdout, stderr }.
|
||||
* Does NOT throw on non-zero exit — callers must assert status themselves.
|
||||
*/
|
||||
function runTools(args, opts = {}) {
|
||||
const result = spawnSync(process.execPath, [GSD_TOOLS, ...args], {
|
||||
encoding: 'utf8',
|
||||
timeout: 60000,
|
||||
cwd: opts.cwd || process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
// Clear ambient session vars that can redirect config paths
|
||||
GSD_SESSION_KEY: '',
|
||||
CODEX_THREAD_ID: '',
|
||||
CLAUDE_SESSION_ID: '',
|
||||
...opts.env,
|
||||
},
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a minimal temp project directory with a .planning/ dir.
|
||||
* Optionally write config.json if configObj is provided.
|
||||
*/
|
||||
function makeTmpProject(prefix, configObj) {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
const planningDir = path.join(tmpDir, '.planning');
|
||||
fs.mkdirSync(planningDir, { recursive: true });
|
||||
if (configObj !== undefined) {
|
||||
fs.writeFileSync(path.join(planningDir, 'config.json'), JSON.stringify(configObj), 'utf8');
|
||||
}
|
||||
return tmpDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a SECURITY.md file with the given frontmatter content to the given dir.
|
||||
*/
|
||||
function writeSecurityMd(dir, frontmatter) {
|
||||
const content = `---\n${frontmatter}\n---\n# Security Review\n`;
|
||||
const filePath = path.join(dir, 'SECURITY.md');
|
||||
fs.writeFileSync(filePath, content, 'utf8');
|
||||
return filePath;
|
||||
}
|
||||
|
||||
// ─── Fixture state ─────────────────────────────────────────────────────────────
|
||||
|
||||
let tmpEnforcementOn; // .planning/config.json with security_enforcement:true
|
||||
let tmpEnforcementOff; // .planning/config.json with security_enforcement:false
|
||||
let tmpNoConfig; // .planning/ dir with NO config.json (schema default applies)
|
||||
let tmpWithSecurityMd; // project + SECURITY.md variants in sub-temp dir
|
||||
|
||||
before(() => {
|
||||
tmpEnforcementOn = makeTmpProject('ship-pre-on-', { workflow: { security_enforcement: true } });
|
||||
tmpEnforcementOff = makeTmpProject('ship-pre-off-', { workflow: { security_enforcement: false } });
|
||||
tmpNoConfig = makeTmpProject('ship-pre-noconf-'); // no config.json
|
||||
tmpWithSecurityMd = fs.mkdtempSync(path.join(os.tmpdir(), 'ship-pre-secmd-'));
|
||||
});
|
||||
|
||||
after(() => {
|
||||
if (tmpEnforcementOn) cleanup(tmpEnforcementOn);
|
||||
if (tmpEnforcementOff) cleanup(tmpEnforcementOff);
|
||||
if (tmpNoConfig) cleanup(tmpNoConfig);
|
||||
if (tmpWithSecurityMd) cleanup(tmpWithSecurityMd);
|
||||
});
|
||||
|
||||
// ─── 1. render-hooks ship:pre envelope tests ──────────────────────────────────
|
||||
|
||||
describe('loop render-hooks ship:pre — envelope resolution', () => {
|
||||
|
||||
test('[happy] security_enforcement=true returns gate hook with correct predicate shape', () => {
|
||||
const result = runTools(['loop', 'render-hooks', 'ship:pre', '--raw'], { cwd: tmpEnforcementOn });
|
||||
|
||||
assert.strictEqual(result.status, 0, `expected exit 0, got ${result.status}; stderr: ${result.stderr}`);
|
||||
|
||||
const envelope = JSON.parse(result.stdout.trim());
|
||||
|
||||
assert.strictEqual(envelope.point, 'ship:pre');
|
||||
assert.strictEqual(envelope.activeHooks.length, 1, 'expected exactly 1 active hook');
|
||||
|
||||
const gate = envelope.activeHooks[0];
|
||||
assert.strictEqual(gate.capId, 'security');
|
||||
assert.strictEqual(gate.kind, 'gate');
|
||||
assert.strictEqual(gate.blocking, true);
|
||||
assert.strictEqual(gate.onError, 'halt');
|
||||
assert.strictEqual(gate.when, 'workflow.security_enforcement');
|
||||
|
||||
// Predicate shape — the critical contract for downstream workflow prose
|
||||
const pred = gate.check.predicate;
|
||||
assert.strictEqual(pred.kind, 'artifact-frontmatter-equals');
|
||||
assert.strictEqual(pred.artifact, 'SECURITY.md');
|
||||
assert.strictEqual(pred.field, 'threats_open');
|
||||
assert.strictEqual(pred.equals, 0);
|
||||
// TYPE contract: equals must be integer (not string '0')
|
||||
assert.strictEqual(typeof pred.equals, 'number', 'predicate.equals must be a number, not a string');
|
||||
});
|
||||
|
||||
test('[negative] security_enforcement=false returns empty activeHooks (gate suppressed)', () => {
|
||||
const result = runTools(['loop', 'render-hooks', 'ship:pre', '--raw'], { cwd: tmpEnforcementOff });
|
||||
|
||||
assert.strictEqual(result.status, 0);
|
||||
|
||||
const envelope = JSON.parse(result.stdout.trim());
|
||||
|
||||
assert.strictEqual(envelope.point, 'ship:pre');
|
||||
assert.deepEqual(envelope.activeHooks, [], 'expected empty activeHooks when enforcement disabled');
|
||||
assert.strictEqual(envelope.rendered, '_No active hooks at ship:pre._');
|
||||
|
||||
// Confirm no security hook leaked through
|
||||
const secHook = envelope.activeHooks.find(h => h.capId === 'security');
|
||||
assert.strictEqual(secHook, undefined, 'security gate must be absent when enforcement=false');
|
||||
});
|
||||
|
||||
test('[happy] no config.json uses schema default (security_enforcement=true) and activates gate', () => {
|
||||
const result = runTools(['loop', 'render-hooks', 'ship:pre', '--raw'], { cwd: tmpNoConfig });
|
||||
|
||||
assert.strictEqual(result.status, 0);
|
||||
|
||||
const envelope = JSON.parse(result.stdout.trim());
|
||||
|
||||
// Schema default for security_enforcement is true — gate must fire
|
||||
assert.strictEqual(envelope.activeHooks.length, 1, 'schema default must activate the security gate');
|
||||
assert.strictEqual(envelope.activeHooks[0].capId, 'security');
|
||||
assert.strictEqual(envelope.activeHooks[0].blocking, true);
|
||||
});
|
||||
|
||||
test('[empty-resolution] gate active when no SECURITY.md exists: envelope confirms gate live (fail-closed)', () => {
|
||||
// The phase dir has NO SECURITY.md — the gate is still ACTIVE in the envelope
|
||||
// (activation is config-driven; file absence is a predicate evaluation concern
|
||||
// handled by ship.md prose, not the CLI resolver).
|
||||
const tmpPhaseNoSec = makeTmpProject('ship-pre-nosec-', { workflow: { security_enforcement: true } });
|
||||
try {
|
||||
const phaseDir = path.join(tmpPhaseNoSec, '.planning', 'phases', '01-feature');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
// No SECURITY.md written anywhere
|
||||
|
||||
const result = runTools(['loop', 'render-hooks', 'ship:pre', '--raw'], { cwd: tmpPhaseNoSec });
|
||||
assert.strictEqual(result.status, 0);
|
||||
|
||||
const envelope = JSON.parse(result.stdout.trim());
|
||||
|
||||
// Gate must still be active — no-file does not suppress the gate
|
||||
assert.strictEqual(envelope.activeHooks.length, 1, 'gate must remain active even without SECURITY.md on disk');
|
||||
assert.strictEqual(envelope.activeHooks[0].capId, 'security');
|
||||
assert.strictEqual(envelope.activeHooks[0].blocking, true);
|
||||
// Confirm no SECURITY.md in the phase dir (this is the "no-file" scenario)
|
||||
const hasSec = fs.readdirSync(phaseDir).some(f => f.endsWith('-SECURITY.md') || f === 'SECURITY.md');
|
||||
assert.strictEqual(hasSec, false, 'fixture must have no SECURITY.md for this test to be meaningful');
|
||||
} finally {
|
||||
cleanup(tmpPhaseNoSec);
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// ─── 2. predicate.equals integer contract via resolveLoopHooks (pure function) ─
|
||||
|
||||
describe('resolveLoopHooks ship:pre — predicate.equals integer type contract', () => {
|
||||
|
||||
test('[bva] predicate.equals is integer 0 in resolved output (Hyrum\'s-law type pin)', () => {
|
||||
const resolved = resolveLoopHooks({
|
||||
point: 'ship:pre',
|
||||
registry: realRegistry,
|
||||
config: { workflow: { security_enforcement: true } },
|
||||
});
|
||||
|
||||
assert.strictEqual(resolved.activeHooks.length, 1);
|
||||
const gate = resolved.activeHooks[0];
|
||||
assert.strictEqual(gate.capId, 'security');
|
||||
|
||||
const equals = gate.check.predicate.equals;
|
||||
assert.strictEqual(equals, 0, 'predicate.equals must be integer 0');
|
||||
assert.strictEqual(typeof equals, 'number', 'predicate.equals typeof must be number, not string');
|
||||
});
|
||||
|
||||
test('[negative] security_enforcement=false via resolveLoopHooks returns 0 active hooks', () => {
|
||||
const resolved = resolveLoopHooks({
|
||||
point: 'ship:pre',
|
||||
registry: realRegistry,
|
||||
config: { workflow: { security_enforcement: false } },
|
||||
});
|
||||
|
||||
assert.strictEqual(resolved.activeHooks.length, 0, 'enforcement=false must yield 0 hooks');
|
||||
assert.strictEqual(resolved.point, 'ship:pre');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// ─── 3. frontmatter get contract for threats_open field ───────────────────────
|
||||
|
||||
describe('frontmatter get SECURITY.md threats_open — type contract', () => {
|
||||
|
||||
test('[happy] threats_open:0 returns string "0" (type contract: YAML→string via frontmatter CLI)', () => {
|
||||
const secFile = writeSecurityMd(tmpWithSecurityMd, 'threats_open: 0\nasvs_level: 1');
|
||||
|
||||
const result = runTools(['frontmatter', 'get', secFile, '--field', 'threats_open', '--raw']);
|
||||
|
||||
assert.strictEqual(result.status, 0);
|
||||
// Raw output is JSON-encoded string "0", not integer 0
|
||||
const parsed = JSON.parse(result.stdout.trim());
|
||||
assert.strictEqual(parsed, '0', 'frontmatter returns string "0", not integer 0');
|
||||
assert.strictEqual(typeof parsed, 'string', 'frontmatter CLI must return a string for YAML integer fields');
|
||||
});
|
||||
|
||||
test('[bva] threats_open:1 returns string "1" — above threshold, predicate(equals:0) fails', () => {
|
||||
// BVA: equals:0 passes, equals:1 blocks — this is the just-above threshold value
|
||||
const secFile = path.join(tmpWithSecurityMd, 'SECURITY-1.md');
|
||||
fs.writeFileSync(secFile, '---\nthreats_open: 1\nasvs_level: 1\n---\n# Security\n', 'utf8');
|
||||
|
||||
const result = runTools(['frontmatter', 'get', secFile, '--field', 'threats_open', '--raw']);
|
||||
|
||||
assert.strictEqual(result.status, 0);
|
||||
const parsed = JSON.parse(result.stdout.trim());
|
||||
assert.strictEqual(parsed, '1', 'threats_open:1 must return string "1"');
|
||||
// Verify this differs from the passing case (string "0" !== string "1")
|
||||
assert.notStrictEqual(parsed, '0', 'string "1" must not equal passing value "0"');
|
||||
});
|
||||
|
||||
test('[negative] missing threats_open field returns Field-not-found error (fail-closed path)', () => {
|
||||
const secFile = path.join(tmpWithSecurityMd, 'SECURITY-missing-field.md');
|
||||
// No threats_open key in frontmatter — only unrelated fields
|
||||
fs.writeFileSync(secFile, '---\nphase: 01\nstatus: active\n---\n# Security\n', 'utf8');
|
||||
|
||||
const result = runTools(['frontmatter', 'get', secFile, '--field', 'threats_open', '--raw']);
|
||||
|
||||
// Exit 0 — the CLI returns a JSON error object, not a crash
|
||||
assert.strictEqual(result.status, 0);
|
||||
const parsed = JSON.parse(result.stdout.trim());
|
||||
|
||||
// Must return an error object, not a string value
|
||||
assert.strictEqual(typeof parsed, 'object', 'missing field must return an object, not a string');
|
||||
assert.strictEqual(parsed.error, 'Field not found');
|
||||
assert.strictEqual(parsed.field, 'threats_open');
|
||||
});
|
||||
|
||||
test('[negative] threats_open: unknown returns string "unknown" (ambiguous value must fail closed)', () => {
|
||||
// Non-numeric string value — predicate (equals:0 integer) cannot match
|
||||
const secFile = path.join(tmpWithSecurityMd, 'SECURITY-unknown.md');
|
||||
fs.writeFileSync(secFile, '---\nthreats_open: unknown\nasvs_level: 1\n---\n# Security\n', 'utf8');
|
||||
|
||||
const result = runTools(['frontmatter', 'get', secFile, '--field', 'threats_open', '--raw']);
|
||||
|
||||
assert.strictEqual(result.status, 0);
|
||||
const parsed = JSON.parse(result.stdout.trim());
|
||||
|
||||
assert.strictEqual(parsed, 'unknown', 'non-numeric value must be returned as-is');
|
||||
// Confirm this is NOT a match for predicate.equals===0 (integer)
|
||||
assert.notStrictEqual(parsed, 0, 'string "unknown" must not match integer 0');
|
||||
assert.strictEqual(typeof parsed, 'string');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// ─── 4. Real registry structure sanity ────────────────────────────────────────
|
||||
|
||||
describe('real registry ship:pre — structural guards', () => {
|
||||
|
||||
test('ship:pre byLoopPoint entry has exactly 1 gate and 0 steps/contributions', () => {
|
||||
const entry = realRegistry.byLoopPoint['ship:pre'];
|
||||
assert.ok(entry, 'ship:pre must exist in byLoopPoint');
|
||||
assert.strictEqual(entry.steps.length, 0, 'ship:pre must have 0 steps');
|
||||
assert.strictEqual(entry.contributions.length, 0, 'ship:pre must have 0 contributions');
|
||||
assert.strictEqual(entry.gates.length, 1, 'ship:pre must have exactly 1 gate (security)');
|
||||
});
|
||||
|
||||
test('ship:pre gate capId is "security" and check has predicate not query', () => {
|
||||
const gate = realRegistry.byLoopPoint['ship:pre'].gates[0];
|
||||
assert.strictEqual(gate.capId, 'security');
|
||||
assert.ok(gate.check.predicate, 'ship:pre gate must use predicate, not query');
|
||||
assert.strictEqual(gate.check.query, undefined, 'ship:pre must NOT have a check.query (predicate-only gate)');
|
||||
});
|
||||
|
||||
});
|
||||
543
tests/loop-hooks-verify-post-e2e.test.cjs
Normal file
543
tests/loop-hooks-verify-post-e2e.test.cjs
Normal file
@@ -0,0 +1,543 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* loop-hooks-verify-post-e2e.test.cjs
|
||||
*
|
||||
* E2E content tests for the verify:post hook point — ADR-857 phase 6.
|
||||
*
|
||||
* Coverage focus (backlog: hook-e2e-gaps.md § verify:post):
|
||||
* - All-on: 3 hooks in registry order (nyquist → security → ui) with
|
||||
* correct kind/ref.skill/onError (halt for nyquist+security, skip for ui)
|
||||
* - No-config: schema defaults activate all 3
|
||||
* - Per-key false: each of the 3 BVA cases excludes only that one step
|
||||
* - All-false: empty activeHooks + valid envelope shape
|
||||
* - Surface-disable (via capabilityStatesById on pure resolver): ui/security
|
||||
* cluster excluded; remaining steps correct
|
||||
* - Malformed config.json: falls back to schema defaults (3 active)
|
||||
* - Deterministic ordering: two calls produce identical activeHooks arrays
|
||||
*
|
||||
* Hard rules enforced here:
|
||||
* - Every test drives real resolver or CLI subprocess — no readFileSync source-grep
|
||||
* - Genuine assertions: negative/BVA cases assert the SPECIFIC differing value
|
||||
* - Each test owns its own fixture (isolated tmpDir); cleanup in afterEach
|
||||
*/
|
||||
|
||||
const { describe, test, before, after, afterEach } = require('node:test');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
// ── Real modules under test ────────────────────────────────────────────────────
|
||||
const {
|
||||
resolveLoopHooks,
|
||||
renderLoopHooks,
|
||||
} = require('../gsd-core/bin/lib/loop-resolver.cjs');
|
||||
const realRegistry = require('../gsd-core/bin/lib/capability-registry.cjs');
|
||||
|
||||
// ── CLI path ───────────────────────────────────────────────────────────────────
|
||||
const GSD_TOOLS = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
|
||||
|
||||
// ── Env hermeticity (strip ambient GSD_ vars that skew planning dir lookups) ──
|
||||
const CLEAN_ENV = Object.fromEntries(
|
||||
Object.entries(process.env).filter(([k]) => !k.startsWith('GSD_')),
|
||||
);
|
||||
|
||||
/**
|
||||
* Invoke gsd-tools CLI with spawnSync and return the parsed result.
|
||||
* Always use CLEAN_ENV to avoid ambient GSD_ env vars redirecting planning paths.
|
||||
*/
|
||||
function runCli(args, cwd) {
|
||||
const result = spawnSync(process.execPath, [GSD_TOOLS, ...args], {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
env: CLEAN_ENV,
|
||||
timeout: 60000,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Create a temp dir with a .planning/ subdirectory (no config.json). */
|
||||
function makeTmpProject() {
|
||||
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'vpost-e2e-'));
|
||||
fs.mkdirSync(path.join(d, '.planning'), { recursive: true });
|
||||
return d;
|
||||
}
|
||||
|
||||
/** Write .planning/config.json with the given object. */
|
||||
function writeConfig(tmpDir, cfg) {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify(cfg),
|
||||
'utf8',
|
||||
);
|
||||
}
|
||||
|
||||
// ── Fixtures shared across all-on and ordering tests ─────────────────────────
|
||||
let allOnDir; // .planning/config.json with all three verify:post flags = true
|
||||
let noConfigDir; // .planning/ but NO config.json
|
||||
let allOffDir; // all three flags explicitly false
|
||||
|
||||
before(() => {
|
||||
allOnDir = makeTmpProject();
|
||||
writeConfig(allOnDir, {
|
||||
workflow: { nyquist_validation: true, security_enforcement: true, ui_review: true },
|
||||
});
|
||||
|
||||
noConfigDir = makeTmpProject();
|
||||
// No config.json — schema defaults (all true) should activate all three
|
||||
|
||||
allOffDir = makeTmpProject();
|
||||
writeConfig(allOffDir, {
|
||||
workflow: { nyquist_validation: false, security_enforcement: false, ui_review: false },
|
||||
});
|
||||
});
|
||||
|
||||
after(() => {
|
||||
for (const d of [allOnDir, noConfigDir, allOffDir]) {
|
||||
if (d) cleanup(d);
|
||||
}
|
||||
});
|
||||
|
||||
// Per-test isolation: each test creates its own dir; afterEach cleans it up.
|
||||
let perTestDir = null;
|
||||
afterEach(() => {
|
||||
if (perTestDir) {
|
||||
cleanup(perTestDir);
|
||||
perTestDir = null;
|
||||
}
|
||||
});
|
||||
|
||||
// ─── 1. All-on: three hooks in correct order with full typed shape ─────────────
|
||||
|
||||
describe('verify:post — all-on config activates all three steps in registry order', () => {
|
||||
test('[happy] CLI returns 3 active hooks: nyquist→security→ui with correct capId, kind, ref.skill', () => {
|
||||
const result = runCli(
|
||||
['loop', 'render-hooks', 'verify:post', '--raw', '--cwd', allOnDir],
|
||||
allOnDir,
|
||||
);
|
||||
assert.strictEqual(result.status, 0, `CLI exited non-zero: ${result.stderr}`);
|
||||
const envelope = JSON.parse(result.stdout.trim());
|
||||
|
||||
assert.strictEqual(envelope.point, 'verify:post');
|
||||
assert.strictEqual(envelope.activeHooks.length, 3,
|
||||
`Expected 3 active hooks, got ${envelope.activeHooks.length}: ${JSON.stringify(envelope.activeHooks.map(h => h.capId))}`);
|
||||
|
||||
// Step 1: nyquist
|
||||
const [nyquist, security, ui] = envelope.activeHooks;
|
||||
assert.strictEqual(nyquist.capId, 'nyquist');
|
||||
assert.strictEqual(nyquist.kind, 'step');
|
||||
assert.strictEqual(nyquist.ref.skill, 'validate-phase');
|
||||
assert.strictEqual(nyquist.onError, 'halt');
|
||||
|
||||
// Step 2: security
|
||||
assert.strictEqual(security.capId, 'security');
|
||||
assert.strictEqual(security.kind, 'step');
|
||||
assert.strictEqual(security.ref.skill, 'secure-phase');
|
||||
assert.strictEqual(security.onError, 'halt');
|
||||
|
||||
// Step 3: ui
|
||||
assert.strictEqual(ui.capId, 'ui');
|
||||
assert.strictEqual(ui.kind, 'step');
|
||||
assert.strictEqual(ui.ref.skill, 'ui-review');
|
||||
assert.strictEqual(ui.onError, 'skip');
|
||||
});
|
||||
|
||||
test('[happy] CLI returns rendered markdown with Step 1/2/3 in correct order', () => {
|
||||
const result = runCli(
|
||||
['loop', 'render-hooks', 'verify:post', '--raw', '--cwd', allOnDir],
|
||||
allOnDir,
|
||||
);
|
||||
assert.strictEqual(result.status, 0);
|
||||
const envelope = JSON.parse(result.stdout.trim());
|
||||
|
||||
// Rendered text must contain all three steps in correct order
|
||||
const { rendered } = envelope;
|
||||
assert.ok(typeof rendered === 'string' && rendered.length > 0, 'rendered must be non-empty string');
|
||||
|
||||
const step1Pos = rendered.indexOf('validate-phase');
|
||||
const step2Pos = rendered.indexOf('secure-phase');
|
||||
const step3Pos = rendered.indexOf('ui-review');
|
||||
assert.ok(step1Pos < step2Pos, `nyquist (pos ${step1Pos}) must come before security (pos ${step2Pos}) in rendered`);
|
||||
assert.ok(step2Pos < step3Pos, `security (pos ${step2Pos}) must come before ui (pos ${step3Pos}) in rendered`);
|
||||
|
||||
// Rendered must NOT be the placeholder (all hooks active)
|
||||
assert.ok(
|
||||
!rendered.includes('_No active hooks at verify:post._'),
|
||||
'rendered must not be the empty-hooks placeholder when all are active',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 2. No-config: schema defaults activate all 3 ─────────────────────────────
|
||||
|
||||
describe('verify:post — no config.json falls back to schema defaults (all three active)', () => {
|
||||
test('[happy] CLI with no config.json returns 3 active hooks via schema default=true', () => {
|
||||
const result = runCli(
|
||||
['loop', 'render-hooks', 'verify:post', '--raw', '--cwd', noConfigDir],
|
||||
noConfigDir,
|
||||
);
|
||||
assert.strictEqual(result.status, 0, `CLI exited non-zero: ${result.stderr}`);
|
||||
const envelope = JSON.parse(result.stdout.trim());
|
||||
|
||||
assert.strictEqual(envelope.point, 'verify:post');
|
||||
assert.strictEqual(envelope.activeHooks.length, 3,
|
||||
`Schema defaults should activate 3 hooks, got ${envelope.activeHooks.length}`);
|
||||
|
||||
// Verify capIds — schema default=true for all three
|
||||
const capIds = envelope.activeHooks.map(h => h.capId);
|
||||
assert.deepEqual(capIds, ['nyquist', 'security', 'ui'],
|
||||
`Expected ['nyquist','security','ui'], got ${JSON.stringify(capIds)}`);
|
||||
});
|
||||
|
||||
test('[happy] pure resolveLoopHooks with realRegistry and empty config activates all 3 (schema default path)', () => {
|
||||
const resolved = resolveLoopHooks({
|
||||
point: 'verify:post',
|
||||
registry: realRegistry,
|
||||
config: {},
|
||||
});
|
||||
assert.strictEqual(resolved.point, 'verify:post');
|
||||
assert.strictEqual(resolved.activeHooks.length, 3,
|
||||
`Expected 3 active hooks via schema default, got ${resolved.activeHooks.length}`);
|
||||
assert.deepEqual(
|
||||
resolved.activeHooks.map(h => h.capId),
|
||||
['nyquist', 'security', 'ui'],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 3. All-false: empty hooks + valid 3-key envelope ─────────────────────────
|
||||
|
||||
describe('verify:post — all three flags explicitly false returns empty activeHooks', () => {
|
||||
test('[negative] CLI with all-false config returns activeHooks:[] and placeholder rendered', () => {
|
||||
const result = runCli(
|
||||
['loop', 'render-hooks', 'verify:post', '--raw', '--cwd', allOffDir],
|
||||
allOffDir,
|
||||
);
|
||||
assert.strictEqual(result.status, 0, `CLI exited non-zero: ${result.stderr}`);
|
||||
const envelope = JSON.parse(result.stdout.trim());
|
||||
|
||||
// Genuine assertion: MUST be 0 (not 1 or 3) — verifies filtering actually works
|
||||
assert.strictEqual(envelope.activeHooks.length, 0,
|
||||
`Expected 0 hooks when all flags=false, got ${envelope.activeHooks.length}: ${JSON.stringify(envelope.activeHooks.map(h => h.capId))}`);
|
||||
assert.deepEqual(envelope.activeHooks, []);
|
||||
assert.strictEqual(envelope.rendered, '_No active hooks at verify:post._');
|
||||
assert.strictEqual(envelope.point, 'verify:post');
|
||||
});
|
||||
|
||||
test('[negative] pure resolveLoopHooks with all-false config returns empty activeHooks', () => {
|
||||
const resolved = resolveLoopHooks({
|
||||
point: 'verify:post',
|
||||
registry: realRegistry,
|
||||
config: { workflow: { nyquist_validation: false, security_enforcement: false, ui_review: false } },
|
||||
});
|
||||
// Must be exactly 0, not 1 or 3
|
||||
assert.strictEqual(resolved.activeHooks.length, 0);
|
||||
assert.strictEqual(renderLoopHooks(resolved), '_No active hooks at verify:post._');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 4. BVA: per-key false excludes only that one step ────────────────────────
|
||||
|
||||
describe('verify:post — per-key BVA: each false excludes only that single step', () => {
|
||||
test('[bva] nyquist_validation=false excludes ONLY nyquist; security+ui remain (length=2)', () => {
|
||||
perTestDir = makeTmpProject();
|
||||
writeConfig(perTestDir, {
|
||||
workflow: { nyquist_validation: false, security_enforcement: true, ui_review: true },
|
||||
});
|
||||
|
||||
const result = runCli(
|
||||
['loop', 'render-hooks', 'verify:post', '--raw', '--cwd', perTestDir],
|
||||
perTestDir,
|
||||
);
|
||||
assert.strictEqual(result.status, 0);
|
||||
const envelope = JSON.parse(result.stdout.trim());
|
||||
|
||||
// Genuine BVA: must be exactly 2, not 3 or 0
|
||||
assert.strictEqual(envelope.activeHooks.length, 2,
|
||||
`Expected 2 hooks (security+ui), got ${envelope.activeHooks.length}: ${JSON.stringify(envelope.activeHooks.map(h => h.capId))}`);
|
||||
|
||||
const capIds = envelope.activeHooks.map(h => h.capId);
|
||||
assert.ok(!capIds.includes('nyquist'), `nyquist must be absent when nyquist_validation=false, got ${JSON.stringify(capIds)}`);
|
||||
assert.strictEqual(capIds[0], 'security', `First remaining hook must be security`);
|
||||
assert.strictEqual(capIds[1], 'ui', `Second remaining hook must be ui`);
|
||||
});
|
||||
|
||||
test('[bva] security_enforcement=false excludes ONLY security; nyquist+ui remain (length=2)', () => {
|
||||
perTestDir = makeTmpProject();
|
||||
writeConfig(perTestDir, {
|
||||
workflow: { nyquist_validation: true, security_enforcement: false, ui_review: true },
|
||||
});
|
||||
|
||||
const result = runCli(
|
||||
['loop', 'render-hooks', 'verify:post', '--raw', '--cwd', perTestDir],
|
||||
perTestDir,
|
||||
);
|
||||
assert.strictEqual(result.status, 0);
|
||||
const envelope = JSON.parse(result.stdout.trim());
|
||||
|
||||
// Genuine BVA: must be exactly 2, not 3 or 0
|
||||
assert.strictEqual(envelope.activeHooks.length, 2,
|
||||
`Expected 2 hooks (nyquist+ui), got ${envelope.activeHooks.length}: ${JSON.stringify(envelope.activeHooks.map(h => h.capId))}`);
|
||||
|
||||
const capIds = envelope.activeHooks.map(h => h.capId);
|
||||
assert.ok(!capIds.includes('security'), `security must be absent when security_enforcement=false, got ${JSON.stringify(capIds)}`);
|
||||
assert.strictEqual(capIds[0], 'nyquist', `First remaining hook must be nyquist`);
|
||||
assert.strictEqual(capIds[1], 'ui', `Second remaining hook must be ui`);
|
||||
});
|
||||
|
||||
test('[bva] ui_review=false excludes ONLY ui; nyquist+security remain (length=2)', () => {
|
||||
perTestDir = makeTmpProject();
|
||||
writeConfig(perTestDir, {
|
||||
workflow: { nyquist_validation: true, security_enforcement: true, ui_review: false },
|
||||
});
|
||||
|
||||
const result = runCli(
|
||||
['loop', 'render-hooks', 'verify:post', '--raw', '--cwd', perTestDir],
|
||||
perTestDir,
|
||||
);
|
||||
assert.strictEqual(result.status, 0);
|
||||
const envelope = JSON.parse(result.stdout.trim());
|
||||
|
||||
// Genuine BVA: must be exactly 2, not 3 or 0
|
||||
assert.strictEqual(envelope.activeHooks.length, 2,
|
||||
`Expected 2 hooks (nyquist+security), got ${envelope.activeHooks.length}: ${JSON.stringify(envelope.activeHooks.map(h => h.capId))}`);
|
||||
|
||||
const capIds = envelope.activeHooks.map(h => h.capId);
|
||||
assert.ok(!capIds.includes('ui'), `ui must be absent when ui_review=false, got ${JSON.stringify(capIds)}`);
|
||||
assert.strictEqual(capIds[0], 'nyquist', `First remaining hook must be nyquist`);
|
||||
assert.strictEqual(capIds[1], 'security', `Second remaining hook must be security`);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 5. Surface-disable via capabilityStatesById (pure resolver) ──────────────
|
||||
|
||||
describe('verify:post — surface-disable: capabilityStatesById filters hooks', () => {
|
||||
test('[negative] ui disabled via capabilityStatesById→enabled:false excludes ui step; nyquist+security remain', () => {
|
||||
const capabilityStatesById = new Map([
|
||||
['nyquist', { enabled: true }],
|
||||
['security', { enabled: true }],
|
||||
['ui', { enabled: false }],
|
||||
]);
|
||||
const resolved = resolveLoopHooks({
|
||||
point: 'verify:post',
|
||||
registry: realRegistry,
|
||||
config: { workflow: { nyquist_validation: true, security_enforcement: true, ui_review: true } },
|
||||
capabilityStatesById,
|
||||
});
|
||||
|
||||
// Genuine assertion: must be 2 (not 3) — proves surface filter excludes ui
|
||||
assert.strictEqual(resolved.activeHooks.length, 2,
|
||||
`Expected 2 hooks with ui disabled, got ${resolved.activeHooks.length}: ${JSON.stringify(resolved.activeHooks.map(h => h.capId))}`);
|
||||
|
||||
const capIds = resolved.activeHooks.map(h => h.capId);
|
||||
assert.ok(!capIds.includes('ui'), `ui must be filtered out when capability disabled`);
|
||||
assert.strictEqual(capIds[0], 'nyquist');
|
||||
assert.strictEqual(capIds[1], 'security');
|
||||
});
|
||||
|
||||
test('[negative] security disabled via capabilityStatesById excludes security step; nyquist+ui remain', () => {
|
||||
const capabilityStatesById = new Map([
|
||||
['nyquist', { enabled: true }],
|
||||
['security', { enabled: false }],
|
||||
['ui', { enabled: true }],
|
||||
]);
|
||||
const resolved = resolveLoopHooks({
|
||||
point: 'verify:post',
|
||||
registry: realRegistry,
|
||||
config: { workflow: { nyquist_validation: true, security_enforcement: true, ui_review: true } },
|
||||
capabilityStatesById,
|
||||
});
|
||||
|
||||
// Genuine: must be 2 (not 3) — proves security cluster exclusion
|
||||
assert.strictEqual(resolved.activeHooks.length, 2,
|
||||
`Expected 2 hooks with security disabled, got ${resolved.activeHooks.length}`);
|
||||
|
||||
const capIds = resolved.activeHooks.map(h => h.capId);
|
||||
assert.ok(!capIds.includes('security'), `security must be filtered out when capability disabled`);
|
||||
assert.strictEqual(capIds[0], 'nyquist');
|
||||
assert.strictEqual(capIds[1], 'ui');
|
||||
});
|
||||
|
||||
test('[empty-resolution] all three disabled via capabilityStatesById returns empty activeHooks with valid envelope', () => {
|
||||
const capabilityStatesById = new Map([
|
||||
['nyquist', { enabled: false }],
|
||||
['security', { enabled: false }],
|
||||
['ui', { enabled: false }],
|
||||
]);
|
||||
const resolved = resolveLoopHooks({
|
||||
point: 'verify:post',
|
||||
registry: realRegistry,
|
||||
config: { workflow: { nyquist_validation: true, security_enforcement: true, ui_review: true } },
|
||||
capabilityStatesById,
|
||||
});
|
||||
|
||||
assert.strictEqual(resolved.point, 'verify:post');
|
||||
assert.deepEqual(resolved.activeHooks, []);
|
||||
assert.strictEqual(renderLoopHooks(resolved), '_No active hooks at verify:post._');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 6. Malformed config.json: falls back to schema defaults ──────────────────
|
||||
|
||||
describe('verify:post — malformed config.json: schema defaults fire (3 active, no crash)', () => {
|
||||
test('[negative] CLI with malformed config.json exits 0 and returns all 3 hooks via schema defaults', () => {
|
||||
perTestDir = makeTmpProject();
|
||||
fs.writeFileSync(
|
||||
path.join(perTestDir, '.planning', 'config.json'),
|
||||
'{ broken json',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const result = runCli(
|
||||
['loop', 'render-hooks', 'verify:post', '--raw', '--cwd', perTestDir],
|
||||
perTestDir,
|
||||
);
|
||||
assert.strictEqual(result.status, 0, `CLI must not crash on malformed config: ${result.stderr}`);
|
||||
|
||||
const envelope = JSON.parse(result.stdout.trim());
|
||||
assert.strictEqual(envelope.point, 'verify:post');
|
||||
// Schema defaults (all true) must activate all 3 when config.json parse fails
|
||||
assert.strictEqual(envelope.activeHooks.length, 3,
|
||||
`Expected 3 hooks via schema defaults on malformed config, got ${envelope.activeHooks.length}`);
|
||||
|
||||
const capIds = envelope.activeHooks.map(h => h.capId);
|
||||
assert.deepEqual(capIds, ['nyquist', 'security', 'ui']);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 7. Deterministic ordering: two calls produce identical results ────────────
|
||||
|
||||
describe('verify:post — deterministic ordering: repeated calls produce identical activeHooks', () => {
|
||||
test('[happy] two resolveLoopHooks calls return identical activeHooks arrays (order stability)', () => {
|
||||
const config = {
|
||||
workflow: { nyquist_validation: true, security_enforcement: true, ui_review: true },
|
||||
};
|
||||
const first = resolveLoopHooks({ point: 'verify:post', registry: realRegistry, config });
|
||||
const second = resolveLoopHooks({ point: 'verify:post', registry: realRegistry, config });
|
||||
|
||||
// Genuine: both must have exactly the same structure
|
||||
assert.deepEqual(first.activeHooks, second.activeHooks,
|
||||
'Two resolver calls must produce identical activeHooks (determinism)');
|
||||
assert.deepEqual(
|
||||
first.activeHooks.map(h => h.capId),
|
||||
['nyquist', 'security', 'ui'],
|
||||
'Order must be nyquist→security→ui',
|
||||
);
|
||||
});
|
||||
|
||||
test('[happy] two CLI invocations return identical stdout (CLI-level determinism)', () => {
|
||||
const call1 = runCli(
|
||||
['loop', 'render-hooks', 'verify:post', '--raw', '--cwd', allOnDir],
|
||||
allOnDir,
|
||||
);
|
||||
const call2 = runCli(
|
||||
['loop', 'render-hooks', 'verify:post', '--raw', '--cwd', allOnDir],
|
||||
allOnDir,
|
||||
);
|
||||
|
||||
assert.strictEqual(call1.status, 0);
|
||||
assert.strictEqual(call2.status, 0);
|
||||
|
||||
const env1 = JSON.parse(call1.stdout.trim());
|
||||
const env2 = JSON.parse(call2.stdout.trim());
|
||||
|
||||
assert.deepEqual(env1.activeHooks, env2.activeHooks,
|
||||
'Two CLI calls must produce identical activeHooks');
|
||||
assert.strictEqual(env1.rendered, env2.rendered,
|
||||
'Two CLI calls must produce identical rendered output');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 8. onError fields per-hook (halt for nyquist+security, skip for ui) ──────
|
||||
|
||||
describe('verify:post — onError semantics: halt for nyquist+security, skip for ui', () => {
|
||||
test('[bva] onError is exactly "halt" for nyquist, "halt" for security, "skip" for ui — pure resolver', () => {
|
||||
const resolved = resolveLoopHooks({
|
||||
point: 'verify:post',
|
||||
registry: realRegistry,
|
||||
config: { workflow: { nyquist_validation: true, security_enforcement: true, ui_review: true } },
|
||||
});
|
||||
|
||||
assert.strictEqual(resolved.activeHooks.length, 3);
|
||||
// Genuine BVA: each onError must match the exact canonical value
|
||||
assert.strictEqual(resolved.activeHooks[0].onError, 'halt',
|
||||
`nyquist onError must be 'halt', got '${resolved.activeHooks[0].onError}'`);
|
||||
assert.strictEqual(resolved.activeHooks[1].onError, 'halt',
|
||||
`security onError must be 'halt', got '${resolved.activeHooks[1].onError}'`);
|
||||
assert.strictEqual(resolved.activeHooks[2].onError, 'skip',
|
||||
`ui onError must be 'skip', got '${resolved.activeHooks[2].onError}'`);
|
||||
});
|
||||
|
||||
test('[bva] CLI envelope preserves onError values in the correct field position', () => {
|
||||
const result = runCli(
|
||||
['loop', 'render-hooks', 'verify:post', '--raw', '--cwd', allOnDir],
|
||||
allOnDir,
|
||||
);
|
||||
assert.strictEqual(result.status, 0);
|
||||
const envelope = JSON.parse(result.stdout.trim());
|
||||
|
||||
// Genuine BVA: assert specific onError value at each position, not just presence
|
||||
assert.strictEqual(envelope.activeHooks[0].onError, 'halt');
|
||||
assert.strictEqual(envelope.activeHooks[1].onError, 'halt');
|
||||
assert.strictEqual(envelope.activeHooks[2].onError, 'skip');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 9. Envelope shape: exactly 3 keys, no spurious 'warnings' ────────────────
|
||||
|
||||
describe('verify:post — envelope shape pins Hyrum\'s Law contract', () => {
|
||||
test('[happy] all-on CLI response has exactly 3 envelope keys: point, activeHooks, rendered', () => {
|
||||
const result = runCli(
|
||||
['loop', 'render-hooks', 'verify:post', '--raw', '--cwd', allOnDir],
|
||||
allOnDir,
|
||||
);
|
||||
assert.strictEqual(result.status, 0);
|
||||
const envelope = JSON.parse(result.stdout.trim());
|
||||
|
||||
// When state.warnings is empty, the envelope must have exactly 3 keys
|
||||
const keys = Object.keys(envelope).sort();
|
||||
assert.deepEqual(keys, ['activeHooks', 'point', 'rendered'],
|
||||
`Envelope must have exactly 3 keys, got: ${JSON.stringify(keys)}`);
|
||||
});
|
||||
|
||||
test('[negative] all-off CLI response envelope still has exactly 3 keys (no extra warnings key)', () => {
|
||||
const result = runCli(
|
||||
['loop', 'render-hooks', 'verify:post', '--raw', '--cwd', allOffDir],
|
||||
allOffDir,
|
||||
);
|
||||
assert.strictEqual(result.status, 0);
|
||||
const envelope = JSON.parse(result.stdout.trim());
|
||||
|
||||
// All-false path: 3 keys, not more
|
||||
const keys = Object.keys(envelope).sort();
|
||||
assert.deepEqual(keys, ['activeHooks', 'point', 'rendered'],
|
||||
`Empty-hooks envelope must have exactly 3 keys, got: ${JSON.stringify(keys)}`);
|
||||
assert.strictEqual(envelope.point, 'verify:post');
|
||||
assert.deepEqual(envelope.activeHooks, []);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 10. Real registry byLoopPoint shape check (no drift guard) ───────────────
|
||||
|
||||
describe('verify:post — real registry has exactly 3 steps and 0 contributions+gates', () => {
|
||||
test('[happy] realRegistry.byLoopPoint[verify:post] has 3 steps, 0 contributions, 0 gates', () => {
|
||||
const entry = realRegistry.byLoopPoint['verify:post'];
|
||||
assert.ok(entry, 'verify:post must exist in registry');
|
||||
assert.strictEqual(entry.steps.length, 3,
|
||||
`Expected 3 steps at verify:post, got ${entry.steps.length}`);
|
||||
assert.strictEqual(entry.contributions.length, 0,
|
||||
`Expected 0 contributions at verify:post, got ${entry.contributions.length}`);
|
||||
assert.strictEqual(entry.gates.length, 0,
|
||||
`Expected 0 gates at verify:post, got ${entry.gates.length}`);
|
||||
});
|
||||
|
||||
test('[happy] registry steps at verify:post have correct capIds in order', () => {
|
||||
const entry = realRegistry.byLoopPoint['verify:post'];
|
||||
const capIds = entry.steps.map(s => s.capId);
|
||||
assert.deepEqual(capIds, ['nyquist', 'security', 'ui'],
|
||||
`Registry must have steps in nyquist→security→ui order, got ${JSON.stringify(capIds)}`);
|
||||
});
|
||||
});
|
||||
@@ -866,3 +866,100 @@ describe('cmdLoopRenderHooks end-to-end (via gsd-tools)', () => {
|
||||
assert.match(result.stderr, /plan:mid|Invalid loop point/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 10. --active-cap flag (scanner-safe boolean derivation) ──────────────────
|
||||
|
||||
describe('--active-cap flag (loop render-hooks)', () => {
|
||||
// Temp project with tdd_mode=true in config
|
||||
let tddOnDir;
|
||||
// Temp project with tdd_mode=false in config
|
||||
let tddOffDir;
|
||||
|
||||
before(() => {
|
||||
tddOnDir = fs.mkdtempSync(path.join(os.tmpdir(), 'loop-active-cap-tdd-on-'));
|
||||
const planOn = path.join(tddOnDir, '.planning');
|
||||
fs.mkdirSync(planOn, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(planOn, 'config.json'),
|
||||
JSON.stringify({ workflow: { tdd_mode: true } }),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
tddOffDir = fs.mkdtempSync(path.join(os.tmpdir(), 'loop-active-cap-tdd-off-'));
|
||||
const planOff = path.join(tddOffDir, '.planning');
|
||||
fs.mkdirSync(planOff, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(planOff, 'config.json'),
|
||||
JSON.stringify({ workflow: { tdd_mode: false } }),
|
||||
'utf8',
|
||||
);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
if (tddOnDir) cleanup(tddOnDir);
|
||||
if (tddOffDir) cleanup(tddOffDir);
|
||||
});
|
||||
|
||||
test('--active-cap tdd with tdd_mode=true → stdout trimmed === "true", exit 0', () => {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[GSD_TOOLS, 'loop', 'render-hooks', 'execute:post', '--active-cap', 'tdd', '--cwd', tddOnDir],
|
||||
{ cwd: ROOT, encoding: 'utf8' },
|
||||
);
|
||||
assert.strictEqual(result.status, 0, 'Expected exit 0. stderr: ' + (result.stderr || ''));
|
||||
assert.strictEqual(result.stdout.trim(), 'true', 'Expected stdout "true" when tdd_mode=true');
|
||||
});
|
||||
|
||||
test('--active-cap tdd with tdd_mode=false → stdout trimmed === "false", exit 0', () => {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[GSD_TOOLS, 'loop', 'render-hooks', 'execute:post', '--active-cap', 'tdd', '--cwd', tddOffDir],
|
||||
{ cwd: ROOT, encoding: 'utf8' },
|
||||
);
|
||||
assert.strictEqual(result.status, 0, 'Expected exit 0. stderr: ' + (result.stderr || ''));
|
||||
assert.strictEqual(result.stdout.trim(), 'false', 'Expected stdout "false" when tdd_mode=false');
|
||||
});
|
||||
|
||||
test('--active-cap <nonexistent-cap> → stdout trimmed === "false", exit 0', () => {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[GSD_TOOLS, 'loop', 'render-hooks', 'execute:post', '--active-cap', 'no-such-capability-xyz', '--cwd', tddOffDir],
|
||||
{ cwd: ROOT, encoding: 'utf8' },
|
||||
);
|
||||
assert.strictEqual(result.status, 0, 'Expected exit 0 for unknown capId. stderr: ' + (result.stderr || ''));
|
||||
assert.strictEqual(result.stdout.trim(), 'false', 'Expected stdout "false" for unknown capId');
|
||||
});
|
||||
|
||||
test('--active-cap with no value → non-zero exit and error message', () => {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[GSD_TOOLS, 'loop', 'render-hooks', 'execute:post', '--active-cap', '--cwd', tddOffDir],
|
||||
{ cwd: ROOT, encoding: 'utf8' },
|
||||
);
|
||||
assert.notStrictEqual(result.status, 0, 'Expected non-zero exit when --active-cap has no value');
|
||||
assert.match(result.stderr, /active-cap/i, 'Expected error message referencing --active-cap');
|
||||
});
|
||||
|
||||
test('--active-cap output is exactly "true" or "false" (no JSON envelope, clean for shell capture)', () => {
|
||||
// The entire stdout must be just "true" or "false" + newline — no envelope object
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[GSD_TOOLS, 'loop', 'render-hooks', 'execute:post', '--active-cap', 'tdd', '--cwd', tddOnDir],
|
||||
{ cwd: ROOT, encoding: 'utf8' },
|
||||
);
|
||||
assert.strictEqual(result.status, 0, 'Expected exit 0. stderr: ' + (result.stderr || ''));
|
||||
// Must be exactly "true" or "false" — not a JSON object/envelope
|
||||
const trimmed = result.stdout.trim();
|
||||
assert.ok(
|
||||
trimmed === 'true' || trimmed === 'false',
|
||||
`stdout must be "true" or "false", got: ${JSON.stringify(result.stdout)}`,
|
||||
);
|
||||
// Must not be a JSON object (no envelope with point/activeHooks/rendered keys)
|
||||
let parsed;
|
||||
try { parsed = JSON.parse(trimmed); } catch { parsed = null; }
|
||||
assert.ok(
|
||||
typeof parsed !== 'object' || parsed === null,
|
||||
'stdout must not be a JSON object/envelope when --active-cap is used',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,8 @@ 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 { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const HOST_LOOP_FILES = [
|
||||
@@ -145,17 +147,29 @@ describe('ADR-857 Phase 6 capstone conformance (#1139)', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('all ADR-857-named optional features are migrated to Capabilities (#1169)', () => {
|
||||
test('all ADR-857-named optional features are real Capabilities, not empty stubs (#1169)', () => {
|
||||
// ADR-857 §53 + Decision 7 enumerate these optional, non-loop modules as
|
||||
// Capabilities. Until each is a registered feature capability (or documented
|
||||
// as core substrate), phase 6 is incomplete and the capstone is a false green.
|
||||
// Capabilities. "Migrated" means the feature OWNS its behavior: hook-based
|
||||
// features (tdd/schema-gate/drift/gap-analysis) must declare >=1 hook;
|
||||
// command-family features (profile-pipeline) must declare a command family.
|
||||
// A registration-only stub (role:feature but no hooks/commands) games this
|
||||
// gate while the logic stays welded into the loop — rejected here.
|
||||
const REQUIRED = ['tdd', 'schema-gate', 'drift', 'gap-analysis', 'profile-pipeline'];
|
||||
const unmigrated = REQUIRED.filter((id) => registry.capabilities[id]?.role !== 'feature');
|
||||
const problems = [];
|
||||
for (const id of REQUIRED) {
|
||||
const cap = registry.capabilities[id];
|
||||
if (!cap) { problems.push(`${id}: not registered`); continue; }
|
||||
if (cap.role !== 'feature') { problems.push(`${id}: role="${cap.role}", must be "feature"`); continue; }
|
||||
const hookCount = (cap.steps?.length || 0) + (cap.contributions?.length || 0) + (cap.gates?.length || 0);
|
||||
const isCommandFamily = (cap.commands?.length || 0) > 0;
|
||||
if (hookCount === 0 && !isCommandFamily) {
|
||||
problems.push(`${id}: EMPTY STUB (no hooks, no command family) — inline logic was not migrated; declare the real hooks/commands and remove the inline branch`);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(
|
||||
unmigrated, [],
|
||||
`ADR-857 phase 6 is NOT complete: these ADR-named optional features are not yet ` +
|
||||
`feature Capabilities (still inline in plan-phase.md / execute-phase.md): ` +
|
||||
`${unmigrated.join(', ')}. Migrate each, or document it as core substrate (#1169).`,
|
||||
problems, [],
|
||||
`ADR-857 phase 6 is NOT complete:\n ${problems.join('\n ')}\n` +
|
||||
`Each feature must OWN its behavior via hooks or a command family — not exist as a registration-only stub (#1169).`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -180,4 +194,179 @@ describe('ADR-857 Phase 6 capstone conformance (#1139)', () => {
|
||||
`inline:\n ${leaks.join('\n ')}\nThe owning capability must render/consume these (#1169).`,
|
||||
);
|
||||
});
|
||||
|
||||
test('host loop bodies are materially smaller than the pre-phase-6 baseline (#1168)', () => {
|
||||
// #1139 AC: plan-phase.md / execute-phase.md must shrink as optional features
|
||||
// extract to capabilities. Frozen pre-phase-6 sizes (LF bytes); the files must
|
||||
// drop strictly below these. This also defeats double-run gaming — declaring a
|
||||
// hook while leaving the inline block keeps the file from shrinking -> red.
|
||||
const { lfByteCount } = require('../scripts/workflow-size.cjs');
|
||||
const PRE_PHASE6 = { 'plan-phase.md': 94519, 'execute-phase.md': 93166 };
|
||||
const notShrunk = [];
|
||||
for (const [file, frozen] of Object.entries(PRE_PHASE6)) {
|
||||
const now = lfByteCount(path.join(ROOT, 'gsd-core', 'workflows', file));
|
||||
if (now >= frozen) notShrunk.push(`${file}: ${now} bytes (must be < pre-phase-6 ${frozen})`);
|
||||
}
|
||||
assert.deepEqual(
|
||||
notShrunk, [],
|
||||
`ADR-857 phase 6 is NOT complete: host loop bodies have not shrunk — the optional ` +
|
||||
`feature logic has not actually been extracted:\n ${notShrunk.join('\n ')}`,
|
||||
);
|
||||
});
|
||||
|
||||
describe('ADR-857 phase 6 — capabilities must not bake install paths into the registry', () => {
|
||||
// Matches GSD install paths that LEAK when copied verbatim to non-Claude runtimes.
|
||||
// (~/.claude/projects is a legit runtime feature and is intentionally NOT matched.)
|
||||
const LEAK = /\.claude[/\\](?:gsd-core|commands|agents|hooks)\b/;
|
||||
|
||||
test('no capability source (capability.json or fragment) embeds a ~/.claude install path', () => {
|
||||
const capsDir = path.join(__dirname, '..', 'capabilities');
|
||||
const offenders = [];
|
||||
for (const id of fs.readdirSync(capsDir)) {
|
||||
const dir = path.join(capsDir, id);
|
||||
if (!fs.statSync(dir).isDirectory()) continue;
|
||||
const cj = path.join(dir, 'capability.json');
|
||||
if (fs.existsSync(cj) && LEAK.test(fs.readFileSync(cj, 'utf8'))) {
|
||||
offenders.push(`capabilities/${id}/capability.json`);
|
||||
}
|
||||
const fragDir = path.join(dir, 'fragments');
|
||||
if (fs.existsSync(fragDir)) {
|
||||
for (const f of fs.readdirSync(fragDir)) {
|
||||
if (LEAK.test(fs.readFileSync(path.join(fragDir, f), 'utf8'))) {
|
||||
offenders.push(`capabilities/${id}/fragments/${f}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepEqual(offenders, [],
|
||||
`capability sources embed ~/.claude install paths — these leak into the verbatim-copied capability-registry.cjs on non-Claude runtimes. Make the fragment path-free. Offenders: ${offenders.join(', ')}`);
|
||||
});
|
||||
|
||||
test('generated capability-registry.cjs contains no ~/.claude install path', () => {
|
||||
const reg = fs.readFileSync(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'capability-registry.cjs'), 'utf8');
|
||||
const leakLines = reg.split('\n').map((l, i) => [i + 1, l]).filter(([, l]) => LEAK.test(l)).map(([n]) => n);
|
||||
assert.deepEqual(leakLines, [],
|
||||
`capability-registry.cjs leaks ~/.claude install paths at line(s) ${leakLines.join(', ')} — the registry is copied verbatim to non-Claude runtimes (only workflow .md files are path-converted at install). Make the source capability fragment path-free.`);
|
||||
});
|
||||
});
|
||||
|
||||
test('every plan:pre planner contribution is injected generically (not per-capId hardcode)', () => {
|
||||
// FIX C regression guard: plan-phase.md must inject planner contributions
|
||||
// generically (by into == "planner") rather than only injecting a single
|
||||
// hardcoded capId (e.g. "tdd"). A generic injection ensures any active
|
||||
// plan:pre contribution with into=="planner" reaches the planner — including
|
||||
// tdd, schema-gate, and security contributions.
|
||||
//
|
||||
// Heuristic: the planner prompt section must reference injecting where
|
||||
// into == "planner" (or iterate contributions), AND must NOT rely solely
|
||||
// on a single capId == "tdd" injection as the only planner contribution
|
||||
// delivery mechanism.
|
||||
const planPhase = readRepoFile('gsd-core/workflows/plan-phase.md');
|
||||
|
||||
// The file must contain a generic reference to into == "planner" contribution injection.
|
||||
assert.match(
|
||||
planPhase,
|
||||
/into\s*==\s*["']planner["']/,
|
||||
'plan-phase.md must inject planner contributions generically via into == "planner" ' +
|
||||
'(not just a single hardcoded capId). Fix C regression: all active planner contributions must reach the planner.',
|
||||
);
|
||||
|
||||
// Verify the file does NOT rely SOLELY on a hardcoded capId == "tdd" injection
|
||||
// for the planner contribution. If only a tdd-specific injection exists (old form),
|
||||
// the schema-gate and security contributions are silently dropped.
|
||||
// We check: every occurrence of 'capId == "tdd"' contribution injection must be
|
||||
// accompanied somewhere by a generic into=="planner" dispatch (already verified above).
|
||||
// Additionally, the old exact tdd-only injection prose must not be the only delivery.
|
||||
const onlyTddInjection = /\bRead from `PLAN_PRE_HOOKS_JSON` where `kind == "contribution"` and `capId == "tdd"`\b/;
|
||||
// If the old tdd-only prose still exists WITHOUT the generic into=="planner" prose,
|
||||
// that's a regression. Since we already asserted into=="planner" exists, we just
|
||||
// confirm the tdd-only prose is no longer the sole injection mechanism.
|
||||
if (onlyTddInjection.test(planPhase)) {
|
||||
// Old prose still present: acceptable only if generic prose is ALSO present (already asserted).
|
||||
// Verify the into=="planner" injection appears NEAR the planner prompt (within 5000 chars of it).
|
||||
const plannerPromptIdx = planPhase.indexOf('into == "planner"');
|
||||
assert.ok(
|
||||
plannerPromptIdx >= 0,
|
||||
'plan-phase.md has tdd-only injection prose but no generic into=="planner" injection. ' +
|
||||
'Remove the tdd-only injection and replace with generic contribution dispatch.',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('every declared gate check.query returns a uniform boolean `block` field', () => {
|
||||
// FIX A regression guard: every gate check command must return a top-level
|
||||
// boolean `block` field so the host-loop dispatch can read a single consistent
|
||||
// field regardless of which capability owns the gate.
|
||||
//
|
||||
// For each unique check.query declared in the registry's gate hooks, invoke
|
||||
// the check command against a temp directory and assert the JSON output
|
||||
// contains `block` as a boolean. Uses a minimal temp dir so the command
|
||||
// returns quickly without real project state.
|
||||
const os = require('node:os');
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gate-block-contract-'));
|
||||
|
||||
// Collect unique gate check.queries from the registry
|
||||
const queries = new Set();
|
||||
for (const cap of Object.values(registry.capabilities)) {
|
||||
for (const gate of cap.gates || []) {
|
||||
if (gate.check && gate.check.query) queries.add(gate.check.query);
|
||||
}
|
||||
}
|
||||
assert.ok(queries.size > 0, 'Registry must declare at least one gate check.query');
|
||||
|
||||
const gsdTools = path.join(ROOT, 'gsd-core', 'bin', 'gsd-tools.cjs');
|
||||
const failures = [];
|
||||
|
||||
for (const query of [...queries].sort()) {
|
||||
let rawOut = '';
|
||||
try {
|
||||
// Invoke with --raw (the real dispatch form used by the host loop).
|
||||
// Most commands accept a phase number and return valid JSON even when
|
||||
// no real project state exists.
|
||||
rawOut = execFileSync(
|
||||
process.execPath,
|
||||
[gsdTools, 'check', query, '1', '--raw'],
|
||||
{ cwd: tmpDir, encoding: 'utf-8', timeout: 10000 },
|
||||
);
|
||||
const parsed = JSON.parse(rawOut.trim());
|
||||
if (typeof parsed.block !== 'boolean') {
|
||||
failures.push(
|
||||
`check ${query}: returned JSON without a boolean \`block\` field ` +
|
||||
`(got: ${JSON.stringify(parsed.block)}, type: ${typeof parsed.block}). ` +
|
||||
`Add \`block\` to the command's output per the uniform gate contract.`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
// If it threw because the command required a different arg shape, try with a path
|
||||
try {
|
||||
rawOut = execFileSync(
|
||||
process.execPath,
|
||||
[gsdTools, 'check', query, tmpDir, '--raw'],
|
||||
{ cwd: tmpDir, encoding: 'utf-8', timeout: 10000 },
|
||||
);
|
||||
const parsed = JSON.parse(rawOut.trim());
|
||||
if (typeof parsed.block !== 'boolean') {
|
||||
failures.push(
|
||||
`check ${query}: returned JSON without a boolean \`block\` field ` +
|
||||
`(got: ${JSON.stringify(parsed.block)}, type: ${typeof parsed.block}).`,
|
||||
);
|
||||
}
|
||||
} catch (err2) {
|
||||
failures.push(
|
||||
`check ${query}: command failed or returned non-JSON output. ` +
|
||||
`Error: ${err2 instanceof Error ? err2.message : String(err2)}. ` +
|
||||
`Stdout: ${rawOut.slice(0, 200)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up temp dir
|
||||
cleanup(tmpDir);
|
||||
|
||||
assert.deepEqual(
|
||||
failures, [],
|
||||
`Gate check commands must all return a top-level boolean \`block\` field:\n ${failures.join('\n ')}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,7 +49,7 @@ describe('ADR-857 phase 6 planning capability migration', () => {
|
||||
|
||||
test('plan-phase generic plan:pre dispatch supports skill and agent step hooks', () => {
|
||||
const content = readPlanPhase();
|
||||
const section = extractSection(content, '## 5.6.', '## 5.7.');
|
||||
const section = extractSection(content, '## 5.6.', '## 6.');
|
||||
assert.match(section, /ref\.skill/);
|
||||
assert.match(section, /ref\.agent/);
|
||||
assert.match(section, /Agent\(/);
|
||||
|
||||
@@ -84,11 +84,11 @@ describe('plan-phase workflow: intel.enabled gate for API-SURFACE injection (#22
|
||||
});
|
||||
|
||||
test('workflow skips surface injection when intel.enabled is false', () => {
|
||||
// The gate must have an explicit false/skip branch
|
||||
assert.ok(
|
||||
workflow.includes("INTEL_CFG") &&
|
||||
(workflow.includes("'false'") || workflow.includes('"false"') || workflow.includes('false')),
|
||||
'workflow must skip the intel step when intel.enabled is false (config defaults to false)'
|
||||
workflow.includes('no active intel step hook exists') &&
|
||||
workflow.includes('API_SURFACE_PATH') &&
|
||||
(workflow.includes('when: intel.enabled') || workflow.includes('"when": "intel.enabled"')),
|
||||
'workflow must skip the intel step when intel.enabled is false — enforced via capability registry when: gate and explicit no-active-hook skip branch'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -124,11 +124,12 @@ describe('plan-phase §5.6 UI Design Contract Gate', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('Branch 2: no frontend indicators → skip silently to §5.7', () => {
|
||||
test('Branch 2: no frontend indicators → skip silently to step 6 (§5.7 removed)', () => {
|
||||
const section = extractSection56(workflowPath);
|
||||
assert.ok(section.includes('frontend'), '§5.6 Branch 2 must reference frontend');
|
||||
assert.ok(
|
||||
section.includes('frontend') && section.includes('5.7'),
|
||||
'§5.6 Branch 2 must route non-frontend phases to §5.7'
|
||||
/Branch 2[\s\S]*?step 6/.test(section),
|
||||
'§5.6 Branch 2 must skip to step 6 (§5.7 schema-gate section was removed; schema-gate is now a capability)'
|
||||
);
|
||||
});
|
||||
|
||||
@@ -301,7 +302,7 @@ function extractSection56(workflowPath) {
|
||||
const content = fs.readFileSync(workflowPath, 'utf8');
|
||||
const start = content.indexOf('## 5.6.');
|
||||
assert.ok(start !== -1, '§5.6 heading must be present in plan-phase.md');
|
||||
const end = content.indexOf('## 5.7.', start);
|
||||
assert.ok(end !== -1, '§5.7 heading must follow §5.6 in plan-phase.md');
|
||||
const end = content.indexOf('\n## 6.', start);
|
||||
assert.ok(end !== -1, '## 6. heading must follow §5.6 in plan-phase.md (§5.7 was removed; schema-gate is now a capability)');
|
||||
return content.slice(start, end);
|
||||
}
|
||||
|
||||
555
tests/plan-pre-hook-e2e.test.cjs
Normal file
555
tests/plan-pre-hook-e2e.test.cjs
Normal file
@@ -0,0 +1,555 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* plan-pre-hook-e2e.test.cjs — E2E content tests for plan:pre hook resolution.
|
||||
*
|
||||
* ADR-857 phase 6 capstone conformance — gap-backlog: plan:pre
|
||||
* All tests drive real CLI subprocess or real resolver with real registry.
|
||||
* No source-grep — RULESET.TESTS.no-source-grep.
|
||||
*
|
||||
* Covers:
|
||||
* 1. intel.enabled=true → step ref.command='intel api-surface', rendered contains 'intel api-surface'
|
||||
* 2. tdd_mode=true → tdd contribution fragment contains '<tdd_mode_active>'
|
||||
* 3. security_enforcement=true + explicit asvs/block_on → configValues resolved from config
|
||||
* 4. BVA: security defaults (asvs_level=1, block_on='high') when only enforcement=true
|
||||
* 5. All plan:pre when-keys false → empty activeHooks + placeholder rendered
|
||||
* 6. check ui.plan-gate: frontend + no-spec → block:true, exit 0
|
||||
* 7. check ui.plan-gate: frontend + spec present → block:false, exit 0
|
||||
* 8. check ui.plan-gate: missing phase arg → exit 1, clear error message
|
||||
* 9. BVA: check ui.plan-gate phase=99 (nonexistent) → phaseLookupFailed:true, block:false
|
||||
* 10. BVA: intel api-surface symbolCount=0 → stale:true + Incomplete banner
|
||||
* 11. BVA: intel api-surface symbolCount=1 fresh → stale:false + symbol in file
|
||||
* 12. ui cluster disabled via surface + tdd + intel on → no ui hooks, intel+tdd present
|
||||
*/
|
||||
|
||||
const { describe, test, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const GSD_TOOLS = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
|
||||
|
||||
// ─── Subprocess helper (isolated env, no ambient GSD_ vars) ───────────────────
|
||||
|
||||
const CLEAN_ENV = {
|
||||
GSD_SESSION_KEY: '',
|
||||
CODEX_THREAD_ID: '',
|
||||
CLAUDE_SESSION_ID: '',
|
||||
CLAUDE_CODE_SSE_PORT: '',
|
||||
OPENCODE_SESSION_ID: '',
|
||||
GEMINI_SESSION_ID: '',
|
||||
CURSOR_SESSION_ID: '',
|
||||
WINDSURF_SESSION_ID: '',
|
||||
TERM_SESSION_ID: '',
|
||||
WT_SESSION: '',
|
||||
TMUX_PANE: '',
|
||||
ZELLIJ_SESSION_NAME: '',
|
||||
TTY: '',
|
||||
SSH_TTY: '',
|
||||
GSD_WORKSTREAM: '',
|
||||
GSD_PROJECT: '',
|
||||
};
|
||||
|
||||
/**
|
||||
* Run gsd-tools via spawnSync. Returns { status, stdout, stderr }.
|
||||
* Passes env overrides merged on top of process.env + CLEAN_ENV.
|
||||
*/
|
||||
function runTools(args, cwd, envOverrides = {}) {
|
||||
return spawnSync(
|
||||
process.execPath,
|
||||
[GSD_TOOLS, ...args],
|
||||
{
|
||||
cwd: cwd || process.cwd(),
|
||||
encoding: 'utf8',
|
||||
timeout: 30000,
|
||||
env: { ...process.env, ...CLEAN_ENV, ...envOverrides },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse JSON stdout from runTools result — throws with diagnostic on failure.
|
||||
*/
|
||||
function parseEnvelope(result, label = '') {
|
||||
try {
|
||||
return JSON.parse(result.stdout.trim());
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`${label}: JSON.parse failed.\n` +
|
||||
`stdout=${result.stdout?.slice(0, 300)}\n` +
|
||||
`stderr=${result.stderr?.slice(0, 300)}\n` +
|
||||
`status=${result.status}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Fixture factory helpers ──────────────────────────────────────────────────
|
||||
|
||||
function makePlanningDir(tmpDir, configObj = null) {
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases'), { recursive: true });
|
||||
if (configObj !== null) {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify(configObj),
|
||||
'utf8',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function makeProject(configObj = null) {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-pre-e2e-'));
|
||||
makePlanningDir(tmpDir, configObj);
|
||||
return tmpDir;
|
||||
}
|
||||
|
||||
// ─── 1. intel.enabled=true emits step with ref.command and rendered text ──────
|
||||
|
||||
describe('plan:pre intel step — ref.command and rendered text', () => {
|
||||
let tmpDir;
|
||||
before(() => {
|
||||
tmpDir = makeProject({ intel: { enabled: true } });
|
||||
});
|
||||
after(() => cleanup(tmpDir));
|
||||
|
||||
test('[happy] intel.enabled=true: activeHooks has intel step with ref.command="intel api-surface"', () => {
|
||||
const result = runTools(['loop', 'render-hooks', 'plan:pre', '--cwd', tmpDir, '--raw'], tmpDir);
|
||||
assert.strictEqual(result.status, 0, `exit non-zero. stderr=${result.stderr?.slice(0, 300)}`);
|
||||
const envelope = parseEnvelope(result, 'intel-step');
|
||||
|
||||
assert.strictEqual(envelope.point, 'plan:pre');
|
||||
assert.ok(Array.isArray(envelope.activeHooks), 'activeHooks must be array');
|
||||
|
||||
const intelHook = envelope.activeHooks.find(h => h.capId === 'intel');
|
||||
assert.ok(intelHook !== undefined, 'intel hook must be present when intel.enabled=true');
|
||||
assert.strictEqual(intelHook.kind, 'step', 'intel hook kind must be step');
|
||||
assert.deepEqual(intelHook.ref, { command: 'intel api-surface' },
|
||||
'ref must be {command:"intel api-surface"} not a JSON blob');
|
||||
|
||||
// Rendered text: check it contains "intel api-surface" (as part of the JSON or label)
|
||||
assert.ok(
|
||||
envelope.rendered.includes('intel api-surface'),
|
||||
`rendered must contain 'intel api-surface'. Got: ${envelope.rendered.slice(0, 200)}`,
|
||||
);
|
||||
// Rendered must be structured step text, not a bare JSON object dump.
|
||||
// A structured render includes human-readable step metadata (- produces:, - when:).
|
||||
assert.ok(
|
||||
envelope.rendered.includes('- produces:') && envelope.rendered.includes('- when:'),
|
||||
'intel step must render as a structured step (with produces/when labels), not an opaque blob',
|
||||
);
|
||||
// Also assert the produces path appears
|
||||
assert.ok(
|
||||
envelope.rendered.includes('.planning/intel/API-SURFACE.md'),
|
||||
'rendered must include the produced file path',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 2. tdd_mode=true emits tdd contribution with <tdd_mode_active> ───────────
|
||||
|
||||
describe('plan:pre tdd contribution — fragment.inline contains <tdd_mode_active>', () => {
|
||||
let tmpDir;
|
||||
before(() => {
|
||||
tmpDir = makeProject({ workflow: { tdd_mode: true } });
|
||||
});
|
||||
after(() => cleanup(tmpDir));
|
||||
|
||||
test('[happy] tdd_mode=true: tdd contribution fragment.inline contains <tdd_mode_active>', () => {
|
||||
const result = runTools(['loop', 'render-hooks', 'plan:pre', '--cwd', tmpDir, '--raw'], tmpDir);
|
||||
assert.strictEqual(result.status, 0, `exit non-zero. stderr=${result.stderr?.slice(0, 300)}`);
|
||||
const envelope = parseEnvelope(result, 'tdd-contribution');
|
||||
|
||||
const tddHook = envelope.activeHooks.find(h => h.capId === 'tdd');
|
||||
assert.ok(tddHook !== undefined, 'tdd hook must be present when tdd_mode=true');
|
||||
assert.strictEqual(tddHook.kind, 'contribution', 'tdd hook kind must be contribution');
|
||||
assert.ok(
|
||||
tddHook.fragment && typeof tddHook.fragment.inline === 'string',
|
||||
'tdd hook must have fragment.inline string',
|
||||
);
|
||||
assert.ok(
|
||||
tddHook.fragment.inline.includes('<tdd_mode_active>'),
|
||||
`fragment.inline must contain '<tdd_mode_active>'. Got: ${tddHook.fragment.inline.slice(0, 200)}`,
|
||||
);
|
||||
|
||||
// Rendered text must also contain the tag
|
||||
assert.ok(
|
||||
envelope.rendered.includes('<tdd_mode_active>'),
|
||||
`rendered must contain '<tdd_mode_active>'. Got: ${envelope.rendered.slice(0, 200)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 3. security contribution emits configValues from explicit config ──────────
|
||||
|
||||
describe('plan:pre security contribution — configValues from explicit config', () => {
|
||||
let tmpDir;
|
||||
before(() => {
|
||||
tmpDir = makeProject({
|
||||
workflow: {
|
||||
security_enforcement: true,
|
||||
security_asvs_level: 3,
|
||||
security_block_on: 'critical',
|
||||
},
|
||||
});
|
||||
});
|
||||
after(() => cleanup(tmpDir));
|
||||
|
||||
test('[happy] security_enforcement=true + explicit asvs=3 + block_on=critical: configValues match config', () => {
|
||||
const result = runTools(['loop', 'render-hooks', 'plan:pre', '--cwd', tmpDir, '--raw'], tmpDir);
|
||||
assert.strictEqual(result.status, 0, `exit non-zero. stderr=${result.stderr?.slice(0, 300)}`);
|
||||
const envelope = parseEnvelope(result, 'security-configValues-explicit');
|
||||
|
||||
const secHook = envelope.activeHooks.find(h => h.capId === 'security');
|
||||
assert.ok(secHook !== undefined, 'security hook must be present when security_enforcement=true');
|
||||
assert.strictEqual(secHook.kind, 'contribution', 'security hook kind must be contribution');
|
||||
assert.ok(secHook.configValues !== undefined, 'security hook must have configValues');
|
||||
assert.strictEqual(secHook.configValues.security_asvs_level, 3,
|
||||
'security_asvs_level must be 3 (from config, not default 1)');
|
||||
assert.strictEqual(secHook.configValues.security_block_on, 'critical',
|
||||
'security_block_on must be critical (from config, not default high)');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 4. BVA: security defaults when enforcement=true but levels not set ────────
|
||||
|
||||
describe('plan:pre security contribution — BVA: default configValues', () => {
|
||||
let tmpDir;
|
||||
before(() => {
|
||||
tmpDir = makeProject({ workflow: { security_enforcement: true } });
|
||||
});
|
||||
after(() => cleanup(tmpDir));
|
||||
|
||||
test('[bva] security_enforcement=true only: configValues use schema defaults (asvs=1, block_on=high)', () => {
|
||||
const result = runTools(['loop', 'render-hooks', 'plan:pre', '--cwd', tmpDir, '--raw'], tmpDir);
|
||||
assert.strictEqual(result.status, 0, `exit non-zero. stderr=${result.stderr?.slice(0, 300)}`);
|
||||
const envelope = parseEnvelope(result, 'security-configValues-defaults');
|
||||
|
||||
const secHook = envelope.activeHooks.find(h => h.capId === 'security');
|
||||
assert.ok(secHook !== undefined, 'security hook must be present');
|
||||
assert.ok(secHook.configValues !== undefined, 'security hook must have configValues');
|
||||
// BVA: schema defaults — must be 1 and 'high', NOT 3 or 'critical'
|
||||
assert.strictEqual(secHook.configValues.security_asvs_level, 1,
|
||||
'default asvs_level must be 1 when not set in config');
|
||||
assert.strictEqual(secHook.configValues.security_block_on, 'high',
|
||||
'default block_on must be high when not set in config');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 5. All plan:pre when-keys false → empty activeHooks + placeholder ─────────
|
||||
|
||||
describe('plan:pre all-off — empty resolution', () => {
|
||||
let tmpDir;
|
||||
before(() => {
|
||||
tmpDir = makeProject({
|
||||
workflow: {
|
||||
ai_integration_phase: false,
|
||||
tdd_mode: false,
|
||||
security_enforcement: false,
|
||||
ui_phase: false,
|
||||
ui_safety_gate: false,
|
||||
research: false,
|
||||
pattern_mapper: false,
|
||||
schema_push_detection: false,
|
||||
},
|
||||
intel: { enabled: false },
|
||||
});
|
||||
});
|
||||
after(() => cleanup(tmpDir));
|
||||
|
||||
test('[negative] all plan:pre when-keys false: activeHooks empty, rendered is placeholder', () => {
|
||||
const result = runTools(['loop', 'render-hooks', 'plan:pre', '--cwd', tmpDir, '--raw'], tmpDir);
|
||||
assert.strictEqual(result.status, 0, `exit non-zero. stderr=${result.stderr?.slice(0, 300)}`);
|
||||
const envelope = parseEnvelope(result, 'all-off');
|
||||
|
||||
assert.deepEqual(envelope.activeHooks, [],
|
||||
`activeHooks must be empty when all flags false. Got: ${JSON.stringify(envelope.activeHooks.map(h=>h.capId))}`);
|
||||
assert.strictEqual(envelope.rendered, '_No active hooks at plan:pre._',
|
||||
'rendered must be placeholder when no active hooks');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 6. check ui.plan-gate: frontend + no-spec → block:true ──────────────────
|
||||
|
||||
describe('check ui.plan-gate — frontend phase, no UI-SPEC', () => {
|
||||
let tmpDir;
|
||||
before(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-pre-ui-gate-'));
|
||||
const planningDir = path.join(tmpDir, '.planning');
|
||||
fs.mkdirSync(path.join(planningDir, 'phases', '01-dashboard'), { recursive: true });
|
||||
fs.writeFileSync(path.join(planningDir, 'config.json'), '{}', 'utf8');
|
||||
fs.writeFileSync(
|
||||
path.join(planningDir, 'ROADMAP.md'),
|
||||
[
|
||||
'# Project Roadmap',
|
||||
'',
|
||||
'## Phase 1: Dashboard',
|
||||
'',
|
||||
'Build the user interface and frontend dashboard components.',
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf8',
|
||||
);
|
||||
// No UI-SPEC.md in phase dir
|
||||
});
|
||||
after(() => cleanup(tmpDir));
|
||||
|
||||
test('[happy] frontend phase + no UI-SPEC: block:true, frontend:true, hasUiSpec:false, exit 0', () => {
|
||||
const result = runTools(['check', 'ui.plan-gate', '1', '--raw'], tmpDir);
|
||||
assert.strictEqual(result.status, 0, `exit non-zero. stderr=${result.stderr?.slice(0, 300)}`);
|
||||
const out = parseEnvelope(result, 'ui-plan-gate-no-spec');
|
||||
|
||||
assert.strictEqual(out.frontend, true, 'frontend must be true for frontend-keyword phase');
|
||||
assert.strictEqual(out.hasUiSpec, false, 'hasUiSpec must be false when no spec file exists');
|
||||
assert.strictEqual(out.block, true, 'block must be true (frontend && !hasUiSpec)');
|
||||
assert.strictEqual(out.uiSpecPath, null, 'uiSpecPath must be null when spec absent');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 7. check ui.plan-gate: frontend + spec present → block:false ─────────────
|
||||
|
||||
describe('check ui.plan-gate — frontend phase, UI-SPEC present', () => {
|
||||
let tmpDir;
|
||||
before(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-pre-ui-gate-spec-'));
|
||||
const planningDir = path.join(tmpDir, '.planning');
|
||||
fs.mkdirSync(path.join(planningDir, 'phases', '01-dashboard'), { recursive: true });
|
||||
fs.writeFileSync(path.join(planningDir, 'config.json'), '{}', 'utf8');
|
||||
fs.writeFileSync(
|
||||
path.join(planningDir, 'ROADMAP.md'),
|
||||
[
|
||||
'# Project Roadmap',
|
||||
'',
|
||||
'## Phase 1: Dashboard',
|
||||
'',
|
||||
'Build the frontend React dashboard with UI forms.',
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf8',
|
||||
);
|
||||
// Add a UI-SPEC.md
|
||||
fs.writeFileSync(
|
||||
path.join(planningDir, 'phases', '01-dashboard', '01-UI-SPEC.md'),
|
||||
'# UI Design Contract\n',
|
||||
'utf8',
|
||||
);
|
||||
});
|
||||
after(() => cleanup(tmpDir));
|
||||
|
||||
test('[happy] frontend phase + UI-SPEC present: block:false, hasUiSpec:true, uiSpecPath ends with -UI-SPEC.md', () => {
|
||||
const result = runTools(['check', 'ui.plan-gate', '1', '--raw'], tmpDir);
|
||||
assert.strictEqual(result.status, 0, `exit non-zero. stderr=${result.stderr?.slice(0, 300)}`);
|
||||
const out = parseEnvelope(result, 'ui-plan-gate-with-spec');
|
||||
|
||||
assert.strictEqual(out.frontend, true, 'frontend must be true');
|
||||
assert.strictEqual(out.hasUiSpec, true, 'hasUiSpec must be true when spec file exists');
|
||||
assert.strictEqual(out.block, false, 'block must be false (spec present)');
|
||||
assert.ok(
|
||||
typeof out.uiSpecPath === 'string' && out.uiSpecPath.endsWith('-UI-SPEC.md'),
|
||||
`uiSpecPath must end with -UI-SPEC.md. Got: ${out.uiSpecPath}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 8. check ui.plan-gate: missing phase arg → exit 1 + error message ────────
|
||||
|
||||
describe('check ui.plan-gate — missing phase argument', () => {
|
||||
test('[negative] missing phase arg: exit code 1, stderr contains ui-plan-gate requires a phase argument', () => {
|
||||
const result = runTools(['check', 'ui.plan-gate', '--raw']);
|
||||
assert.strictEqual(result.exitCode ?? result.status, 1,
|
||||
`exit code must be 1. Got: ${result.status}. stderr=${result.stderr?.slice(0, 300)}`);
|
||||
assert.ok(
|
||||
(result.stderr || '').includes('ui-plan-gate requires a phase argument'),
|
||||
`stderr must include 'ui-plan-gate requires a phase argument'. Got: ${result.stderr?.slice(0, 300)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 9. BVA: check ui.plan-gate phase=99 → phaseLookupFailed:true ─────────────
|
||||
|
||||
describe('check ui.plan-gate — BVA: non-existent phase 99', () => {
|
||||
let tmpDir;
|
||||
before(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-pre-ui-gate-99-'));
|
||||
const planningDir = path.join(tmpDir, '.planning');
|
||||
fs.mkdirSync(path.join(planningDir, 'phases', '01-dashboard'), { recursive: true });
|
||||
fs.writeFileSync(path.join(planningDir, 'config.json'), '{}', 'utf8');
|
||||
fs.writeFileSync(
|
||||
path.join(planningDir, 'ROADMAP.md'),
|
||||
[
|
||||
'# Project Roadmap',
|
||||
'',
|
||||
'## Phase 1: Dashboard',
|
||||
'',
|
||||
'Build the frontend dashboard.',
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf8',
|
||||
);
|
||||
});
|
||||
after(() => cleanup(tmpDir));
|
||||
|
||||
test('[bva] phase=99 (nonexistent in ROADMAP.md with only Phase 1): phaseLookupFailed:true, block:false', () => {
|
||||
const result = runTools(['check', 'ui.plan-gate', '99', '--raw'], tmpDir);
|
||||
assert.strictEqual(result.status, 0, `exit must be 0. stderr=${result.stderr?.slice(0, 300)}`);
|
||||
const out = parseEnvelope(result, 'ui-plan-gate-phase-99');
|
||||
|
||||
assert.strictEqual(out.phaseLookupFailed, true,
|
||||
'phaseLookupFailed must be true for a phase not found in ROADMAP.md');
|
||||
// When phase lookup fails, frontend defaults to false and block must be false
|
||||
assert.strictEqual(out.frontend, false,
|
||||
'frontend must be false when phase lookup fails (not silently block:false)');
|
||||
assert.strictEqual(out.block, false,
|
||||
'block must be false when phase not found — phaseLookupFailed distinguishes this from a clean pass');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 10. BVA: intel api-surface symbolCount=0 → stale:true + Incomplete ──────
|
||||
|
||||
describe('intel api-surface — BVA: symbolCount=0 (empty entries)', () => {
|
||||
let tmpDir;
|
||||
before(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-pre-intel-empty-'));
|
||||
const planningDir = path.join(tmpDir, '.planning');
|
||||
fs.mkdirSync(path.join(planningDir, 'intel'), { recursive: true });
|
||||
fs.writeFileSync(path.join(planningDir, 'config.json'), JSON.stringify({ intel: { enabled: true } }), 'utf8');
|
||||
// api-map.json with empty object entries
|
||||
fs.writeFileSync(
|
||||
path.join(planningDir, 'intel', 'api-map.json'),
|
||||
JSON.stringify({ entries: {} }),
|
||||
'utf8',
|
||||
);
|
||||
});
|
||||
after(() => cleanup(tmpDir));
|
||||
|
||||
test('[bva] symbolCount=0: exit 0, stale:true, symbolCount:0, written file contains Incomplete banner', () => {
|
||||
const result = runTools(['intel', 'api-surface', '--raw'], tmpDir);
|
||||
assert.strictEqual(result.status, 0, `exit non-zero. stderr=${result.stderr?.slice(0, 300)}`);
|
||||
const out = parseEnvelope(result, 'intel-api-surface-empty');
|
||||
|
||||
assert.strictEqual(out.symbolCount, 0, 'symbolCount must be 0 for empty entries');
|
||||
assert.strictEqual(out.stale, true, 'stale must be true when no _meta.updated_at');
|
||||
assert.ok(typeof out.written === 'string' && out.written.endsWith('API-SURFACE.md'),
|
||||
`written must be a path ending in API-SURFACE.md. Got: ${out.written}`);
|
||||
|
||||
// Content check — the written file must contain the Incomplete banner
|
||||
const content = fs.readFileSync(out.written, 'utf8');
|
||||
assert.ok(
|
||||
content.includes('Incomplete'),
|
||||
`API-SURFACE.md must contain 'Incomplete' banner when symbolCount=0. Got: ${content.slice(0, 300)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 11. BVA: intel api-surface symbolCount=1 fresh → stale:false ─────────────
|
||||
|
||||
describe('intel api-surface — BVA: symbolCount=1, fresh _meta', () => {
|
||||
let tmpDir;
|
||||
before(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-pre-intel-one-'));
|
||||
const planningDir = path.join(tmpDir, '.planning');
|
||||
fs.mkdirSync(path.join(planningDir, 'intel'), { recursive: true });
|
||||
fs.writeFileSync(path.join(planningDir, 'config.json'), JSON.stringify({ intel: { enabled: true } }), 'utf8');
|
||||
// api-map.json with one entry object and fresh _meta
|
||||
fs.writeFileSync(
|
||||
path.join(planningDir, 'intel', 'api-map.json'),
|
||||
JSON.stringify({
|
||||
entries: {
|
||||
getUserById: {
|
||||
file: 'src/api/users.ts',
|
||||
kind: 'function',
|
||||
signature: 'getUserById(id: string): Promise<User>',
|
||||
},
|
||||
},
|
||||
_meta: { updated_at: new Date().toISOString() },
|
||||
}),
|
||||
'utf8',
|
||||
);
|
||||
});
|
||||
after(() => cleanup(tmpDir));
|
||||
|
||||
test('[bva] symbolCount=1 with fresh _meta.updated_at: exit 0, stale:false, symbolCount:1', () => {
|
||||
const result = runTools(['intel', 'api-surface', '--raw'], tmpDir);
|
||||
assert.strictEqual(result.status, 0, `exit non-zero. stderr=${result.stderr?.slice(0, 300)}`);
|
||||
const out = parseEnvelope(result, 'intel-api-surface-one-entry');
|
||||
|
||||
assert.strictEqual(out.symbolCount, 1, 'symbolCount must be 1 for one entry');
|
||||
assert.strictEqual(out.stale, false, 'stale must be false when _meta.updated_at is fresh (<24h)');
|
||||
assert.ok(typeof out.written === 'string' && out.written.endsWith('API-SURFACE.md'),
|
||||
`written must be API-SURFACE.md path. Got: ${out.written}`);
|
||||
|
||||
// Content check — symbol must appear in the file
|
||||
const content = fs.readFileSync(out.written, 'utf8');
|
||||
assert.ok(
|
||||
content.includes('getUserById'),
|
||||
`API-SURFACE.md must contain 'getUserById' symbol. Got: ${content.slice(0, 300)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 12. ui cluster disabled + tdd + intel on → no ui hooks, intel+tdd present ─
|
||||
|
||||
describe('plan:pre surface cluster filter — ui disabled, tdd+intel active', () => {
|
||||
let tmpDir;
|
||||
before(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-pre-surface-'));
|
||||
const planningDir = path.join(tmpDir, '.planning');
|
||||
fs.mkdirSync(planningDir, { recursive: true });
|
||||
// Enable intel, tdd, and also ui things in project config
|
||||
fs.writeFileSync(
|
||||
path.join(planningDir, 'config.json'),
|
||||
JSON.stringify({
|
||||
intel: { enabled: true },
|
||||
workflow: {
|
||||
tdd_mode: true,
|
||||
ui_phase: true,
|
||||
ui_safety_gate: true,
|
||||
security_enforcement: false,
|
||||
research: false,
|
||||
pattern_mapper: false,
|
||||
schema_push_detection: false,
|
||||
ai_integration_phase: false,
|
||||
},
|
||||
}),
|
||||
'utf8',
|
||||
);
|
||||
// .gsd-surface.json disabling UI cluster in same dir (config-dir = tmpDir)
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.gsd-surface.json'),
|
||||
JSON.stringify({
|
||||
baseProfile: 'full',
|
||||
disabledClusters: ['ui'],
|
||||
explicitAdds: [],
|
||||
explicitRemoves: [],
|
||||
}),
|
||||
'utf8',
|
||||
);
|
||||
});
|
||||
after(() => cleanup(tmpDir));
|
||||
|
||||
test('[empty-resolution/surface] ui cluster disabled: no ui hooks; intel and tdd hooks present', () => {
|
||||
const result = runTools(
|
||||
['loop', 'render-hooks', 'plan:pre', '--cwd', tmpDir, '--config-dir', tmpDir, '--raw'],
|
||||
tmpDir,
|
||||
);
|
||||
assert.strictEqual(result.status, 0, `exit non-zero. stderr=${result.stderr?.slice(0, 300)}`);
|
||||
const envelope = parseEnvelope(result, 'ui-cluster-disabled');
|
||||
|
||||
// No ui hooks — specific assertion on the differing field
|
||||
const uiHooks = envelope.activeHooks.filter(h => h.capId === 'ui');
|
||||
assert.strictEqual(uiHooks.length, 0,
|
||||
`ui cluster disabled must suppress all ui hooks. Got ui hooks: ${JSON.stringify(uiHooks)}`);
|
||||
|
||||
// intel hook must be present (not suppressed by ui cluster disable)
|
||||
const intelHooks = envelope.activeHooks.filter(h => h.capId === 'intel');
|
||||
assert.strictEqual(intelHooks.length, 1,
|
||||
`intel hook must be present. Got: ${JSON.stringify(envelope.activeHooks.map(h => h.capId))}`);
|
||||
|
||||
// tdd hook must be present
|
||||
const tddHooks = envelope.activeHooks.filter(h => h.capId === 'tdd');
|
||||
assert.strictEqual(tddHooks.length, 1,
|
||||
`tdd hook must be present. Got: ${JSON.stringify(envelope.activeHooks.map(h => h.capId))}`);
|
||||
});
|
||||
});
|
||||
@@ -362,9 +362,22 @@ describe('workflow.post_planning_gaps config (#2493)', () => {
|
||||
beforeEach(() => { tmpDir = createTempProject(); });
|
||||
afterEach(() => { cleanup(tmpDir); });
|
||||
|
||||
test('VALID_CONFIG_KEYS contains workflow.post_planning_gaps', () => {
|
||||
test('workflow.post_planning_gaps is owned by the gap-analysis capability (ADR-857 federation)', () => {
|
||||
// After ADR-857 phase-6 migration, workflow.post_planning_gaps is federally owned by
|
||||
// the gap-analysis capability — it must NOT be in the central VALID_CONFIG_KEYS schema
|
||||
// and MUST appear in the capability registry configKeys map.
|
||||
const { VALID_CONFIG_KEYS } = require('../gsd-core/bin/lib/config-schema.cjs');
|
||||
assert.ok(VALID_CONFIG_KEYS.has('workflow.post_planning_gaps'));
|
||||
const registry = require('../gsd-core/bin/lib/capability-registry.cjs');
|
||||
assert.equal(
|
||||
VALID_CONFIG_KEYS.has('workflow.post_planning_gaps'),
|
||||
false,
|
||||
'workflow.post_planning_gaps must NOT be in central VALID_CONFIG_KEYS after ADR-857 federation',
|
||||
);
|
||||
assert.equal(
|
||||
registry.configKeys['workflow.post_planning_gaps'],
|
||||
'gap-analysis',
|
||||
'workflow.post_planning_gaps must be owned by gap-analysis capability in the registry',
|
||||
);
|
||||
});
|
||||
|
||||
test('CONFIG_DEFAULTS contains post_planning_gaps default true', () => {
|
||||
|
||||
@@ -117,10 +117,10 @@ describe('descriptor-driven equivalence: defaults (no env vars, no probe hits)',
|
||||
for (const [runtime, expected] of Object.entries(GOLDEN_DEFAULTS).filter(
|
||||
([r]) => r !== 'antigravity',
|
||||
)) {
|
||||
test(`${runtime} default → ${expected}`, () => {
|
||||
test(`${runtime} default resolves to its golden config dir`, () => {
|
||||
const saved = clearAllEnvKeys();
|
||||
try {
|
||||
assert.strictEqual(getGlobalConfigDir(runtime), expected);
|
||||
assert.strictEqual(getGlobalConfigDir(runtime), expected, `${runtime} default → ${expected}`);
|
||||
} finally {
|
||||
restoreEnvKeys(saved);
|
||||
}
|
||||
@@ -693,13 +693,13 @@ describe('descriptor-driven parity: 14 non-probe registry runtimes × no-env-var
|
||||
);
|
||||
|
||||
for (const runtime of registryRuntimes) {
|
||||
test(`${runtime} via getGlobalConfigDir matches golden: ${GOLDEN_DEFAULTS[runtime]}`, () => {
|
||||
test(`${runtime} via getGlobalConfigDir matches its golden default`, () => {
|
||||
const saved = clearAllEnvKeys();
|
||||
try {
|
||||
assert.strictEqual(
|
||||
getGlobalConfigDir(runtime),
|
||||
GOLDEN_DEFAULTS[runtime],
|
||||
`getGlobalConfigDir('${runtime}') diverged from golden`,
|
||||
`${runtime} via getGlobalConfigDir matches golden: ${GOLDEN_DEFAULTS[runtime]}`,
|
||||
);
|
||||
} finally {
|
||||
restoreEnvKeys(saved);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
/**
|
||||
* GSD Tools Tests — workflow.tdd_mode config key
|
||||
* GSD Tools Tests — workflow.tdd_mode config key (capability-owned)
|
||||
*
|
||||
* Validates that the tdd_mode workflow toggle is a first-class config key
|
||||
* with correct default, round-trip behavior, and presence in VALID_CONFIG_KEYS.
|
||||
* Validates that the tdd_mode workflow toggle is a capability-owned config key
|
||||
* (owned by the tdd capability). Post ADR-857 phase-6 migration, workflow.tdd_mode
|
||||
* is no longer a central config key — it is owned by capabilities/tdd/capability.json
|
||||
* and resolved via the capability registry's federated config layer.
|
||||
*
|
||||
* Requirements: #1871
|
||||
* Requirements: #1871 / ADR-857 phase 6 (#1139)
|
||||
*/
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
@@ -20,46 +22,55 @@ function readConfig(tmpDir) {
|
||||
return JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
}
|
||||
|
||||
// ─── VALID_CONFIG_KEYS ──────────────────────────────────────────────────────
|
||||
// ─── capability ownership ─────────────────────────────────────────────────────
|
||||
|
||||
describe('workflow.tdd_mode in VALID_CONFIG_KEYS', () => {
|
||||
test('workflow.tdd_mode is a recognized config key', () => {
|
||||
describe('workflow.tdd_mode capability ownership (ADR-857 phase 6)', () => {
|
||||
test('workflow.tdd_mode is owned by the tdd capability in the registry', () => {
|
||||
const registry = require('../gsd-core/bin/lib/capability-registry.cjs');
|
||||
assert.strictEqual(
|
||||
registry.configKeys['workflow.tdd_mode'],
|
||||
'tdd',
|
||||
'workflow.tdd_mode must be owned by the tdd capability'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow.tdd_mode is NOT in VALID_CONFIG_KEYS (no longer a central key)', () => {
|
||||
const { VALID_CONFIG_KEYS } = require('../gsd-core/bin/lib/config.cjs');
|
||||
assert.ok(
|
||||
VALID_CONFIG_KEYS.has('workflow.tdd_mode'),
|
||||
'workflow.tdd_mode should be in VALID_CONFIG_KEYS'
|
||||
!VALID_CONFIG_KEYS.has('workflow.tdd_mode'),
|
||||
'workflow.tdd_mode must NOT be in central VALID_CONFIG_KEYS — it is capability-owned'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── config default value ───────────────────────────────────────────────────
|
||||
|
||||
describe('workflow.tdd_mode default value', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('defaults to false in new project config', () => {
|
||||
// Ensure config is created with defaults
|
||||
const result = runGsdTools('config-ensure-section', tmpDir, { HOME: tmpDir });
|
||||
assert.ok(result.success, `config-ensure-section failed: ${result.error}`);
|
||||
|
||||
const config = readConfig(tmpDir);
|
||||
test('isCentralConfigKey returns false for workflow.tdd_mode', () => {
|
||||
const { isCentralConfigKey } = require('../gsd-core/bin/lib/config-schema.cjs');
|
||||
assert.strictEqual(
|
||||
config.workflow.tdd_mode,
|
||||
isCentralConfigKey('workflow.tdd_mode'),
|
||||
false,
|
||||
'workflow.tdd_mode should default to false'
|
||||
'workflow.tdd_mode must not be a central config key post-migration'
|
||||
);
|
||||
});
|
||||
|
||||
test('tdd capability has role:feature with plan:pre contribution and execute:post gate', () => {
|
||||
const registry = require('../gsd-core/bin/lib/capability-registry.cjs');
|
||||
const tdd = registry.capabilities['tdd'];
|
||||
assert.ok(tdd, 'tdd capability must be registered');
|
||||
assert.strictEqual(tdd.role, 'feature');
|
||||
assert.ok(tdd.contributions && tdd.contributions.length > 0, 'tdd must have at least one contribution');
|
||||
assert.ok(tdd.gates && tdd.gates.length > 0, 'tdd must have at least one gate');
|
||||
const contribution = tdd.contributions[0];
|
||||
assert.strictEqual(contribution.point, 'plan:pre');
|
||||
assert.ok(contribution.fragment && contribution.fragment.inline.includes('<tdd_mode_active>'), 'contribution must include tdd_mode_active block');
|
||||
const gate = tdd.gates[0];
|
||||
assert.strictEqual(gate.point, 'execute:post');
|
||||
assert.strictEqual(gate.blocking, false, 'execute:post gate must be advisory (non-blocking)');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── config round-trip (set / get) ─────────────────────────────────────────
|
||||
// workflow.tdd_mode is capability-owned: config-set/config-get still work via
|
||||
// raw config.json read/write (capability-owned keys bypass the central whitelist
|
||||
// but are still persisted to config.json by config-set).
|
||||
|
||||
describe('workflow.tdd_mode config round-trip', () => {
|
||||
let tmpDir;
|
||||
@@ -105,6 +116,9 @@ describe('workflow.tdd_mode config round-trip', () => {
|
||||
});
|
||||
|
||||
// ─── init JSON exposure ────────────────────────────────────────────────────
|
||||
// init plan-phase and init execute-phase still emit tdd_mode in their JSON
|
||||
// output from options['tdd'] (CLI flag) or config.tdd_mode (raw config.json
|
||||
// value — now undefined when not set, so defaults to false).
|
||||
|
||||
describe('tdd_mode in init plan-phase JSON output', () => {
|
||||
let tmpDir;
|
||||
@@ -141,22 +155,6 @@ describe('tdd_mode in init plan-phase JSON output', () => {
|
||||
const json = JSON.parse(result.output);
|
||||
assert.strictEqual(json.tdd_mode, true);
|
||||
});
|
||||
|
||||
test('config workflow.tdd_mode: true surfaces in init plan-phase without flag', () => {
|
||||
runGsdTools('config-set workflow.tdd_mode true', tmpDir);
|
||||
const result = runGsdTools('init plan-phase 1', tmpDir);
|
||||
assert.ok(result.success, `init plan-phase failed: ${result.error}`);
|
||||
const json = JSON.parse(result.output);
|
||||
assert.strictEqual(json.tdd_mode, true);
|
||||
});
|
||||
|
||||
test('--tdd flag overrides config value of false', () => {
|
||||
runGsdTools('config-set workflow.tdd_mode false', tmpDir);
|
||||
const result = runGsdTools('init plan-phase 1 --tdd', tmpDir);
|
||||
assert.ok(result.success, `init plan-phase --tdd failed: ${result.error}`);
|
||||
const json = JSON.parse(result.output);
|
||||
assert.strictEqual(json.tdd_mode, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tdd_mode in init execute-phase JSON output', () => {
|
||||
@@ -194,20 +192,4 @@ describe('tdd_mode in init execute-phase JSON output', () => {
|
||||
const json = JSON.parse(result.output);
|
||||
assert.strictEqual(json.tdd_mode, true);
|
||||
});
|
||||
|
||||
test('config workflow.tdd_mode: true surfaces in init execute-phase without flag', () => {
|
||||
runGsdTools('config-set workflow.tdd_mode true', tmpDir);
|
||||
const result = runGsdTools('init execute-phase 1', tmpDir);
|
||||
assert.ok(result.success, `init execute-phase failed: ${result.error}`);
|
||||
const json = JSON.parse(result.output);
|
||||
assert.strictEqual(json.tdd_mode, true);
|
||||
});
|
||||
|
||||
test('--tdd flag overrides config value of false', () => {
|
||||
runGsdTools('config-set workflow.tdd_mode false', tmpDir);
|
||||
const result = runGsdTools('init execute-phase 1 --tdd', tmpDir);
|
||||
assert.ok(result.success, `init execute-phase --tdd failed: ${result.error}`);
|
||||
const json = JSON.parse(result.output);
|
||||
assert.strictEqual(json.tdd_mode, true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"docs-update.md": 54770,
|
||||
"edit-phase.md": 12883,
|
||||
"eval-review.md": 9923,
|
||||
"execute-phase.md": 93166,
|
||||
"execute-phase.md": 92934,
|
||||
"execute-plan.md": 29980,
|
||||
"explore.md": 10497,
|
||||
"extract-learnings.md": 12849,
|
||||
@@ -51,7 +51,7 @@
|
||||
"note.md": 6563,
|
||||
"pause-work.md": 13654,
|
||||
"plan-milestone-gaps.md": 11765,
|
||||
"plan-phase.md": 94519,
|
||||
"plan-phase.md": 92120,
|
||||
"plan-review-convergence.md": 22949,
|
||||
"plant-seed.md": 11741,
|
||||
"pr-branch.md": 4994,
|
||||
|
||||
Reference in New Issue
Block a user