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:
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(foundGapAnalysisCall, 'expected at least one gap-analysis invocation in plan-phase.md');
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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