Files
msd-core/tests/capability-registry.test.cjs
Dennis Alexis Valin Dittrich 18c899def5 enhance(#4209): optional external source reviewer lanes for /gsd:code-review (#4323)
* test(01-01): define reviewer-support trait contract

Add failing coverage for step.supportsReviewerLanes (#4209 DISP-02):
validator rejects non-boolean values with an exact field path, accepts
missing/true/false, and the real code-review capability.json steps
must declare supportsReviewerLanes: true. Add loop-resolver projection
coverage proving the trait reaches activeHooks verbatim for a
provider-neutral synthetic step (not code-review-specific), and that
omitted/false values stay inert (no key on the active hook).

All 8 new assertions fail today: the validator has no such field, and
loop-resolver has nothing to project. RED before GREEN.

* feat(01-01): declare reviewer-capable steps

Add step.supportsReviewerLanes (#4209 DISP-02): a strict optional
boolean opt-in trait, step-scoped (not capability-wide). Only a
literal true validates and projects; false/omitted stay inert (no
key on the projected active hook), and every non-boolean type fails
capability-validator.cjs with an exact field-path error.

Opt both existing code-review steps (execute:post, execute:wave:post)
into the trait in capabilities/code-review/capability.json. Project
the validated field through src/loop-resolver.cts into activeHooks
so a provider-neutral generic interpreter can read it without any
code-review-specific knowledge. Document the field in
docs/reference/capability-manifest.md and regenerate
gsd-core/bin/lib/capability-registry.cjs via the generator (never
hand-edited).

Makes all 8 RED assertions from the prior commit pass.

* test(01-02): define shared reviewer dispatch

- Add tests/reviewer-step-dispatch.test.cjs covering dispatchReviewerLanes:
  inert when the supportsReviewerLanes trait is off or nothing is selected,
  exactly-once plan/invoke per selected lane, duplicate-alias dedup, the
  bounded metadata-only source-review prompt (repo root, paths+baseSha,
  depth, four fixed prohibitions), and capability-neutral reuse via a
  second synthetic step context.
- RED: module under test (src/reviewer-step-dispatch.cts) does not exist
  yet, so require() fails and every assertion is unreached.

* feat(01-02): dispatch reviewers for opted-in steps

- Add src/reviewer-step-dispatch.cts: dispatchReviewerLanes(input, deps),
  ONE interpreter for a step's supportsReviewerLanes trait. Reuses
  resolveReviewerSelection for selection and resolveLanePlan for planning
  (both already-existing, pure building blocks); invocation is the one
  required, caller-injected seam (deps.invoke) since runLane needs
  OS-aware spawn plumbing this module does not own.
- trait !== true, or a selection resolving to zero lanes, dispatches
  nothing (zero plan/invoke calls). Each selected lane is planned and
  invoked exactly once, in the selector's deduped/sorted order.
- buildSourceReviewPrompt assembles a metadata-only bounded prompt
  (repo root, canonical paths + base SHA, depth, four fixed
  prohibitions) — never file contents — written once per dispatch and
  shared across every invoked lane.
- GREEN: tests/reviewer-step-dispatch.test.cjs now passes.

* test(01-02): define reviewer dispatch failures

- Extend tests/reviewer-step-dispatch.test.cjs with the fail-closed
  matrix: an explicitly requested lane the selector could not resolve
  still lets the OTHER resolved lane run, but the aggregate result must
  never read as a clean success (and 'every explicit lane unavailable'
  must be distinguishable from the plain no-flags-passed inert case);
  request-level validation (path traversal, absolute paths outside
  repoRoot, empty/non-string paths, missing depth/base SHA) halts the
  whole dispatch before any lane is planned or invoked; a per-lane
  prompt-budget overflow hard-fails only that lane before invoke while
  its sibling still runs.
- RED: src/reviewer-step-dispatch.cts does not yet implement any of
  these guards, so 9 of the new assertions fail against the current
  (Task 1) implementation.

* fix(01-02): fail closed in reviewer dispatch

- src/reviewer-step-dispatch.cts: add the fail-closed guards the prior
  commit deliberately left out. An explicitly requested lane the
  selector could not resolve no longer lets the aggregate read as a
  clean success — lanes that DID resolve still run and keep their
  results (never narrow the requested set), but selection.errors now
  flips the aggregate ok to false, and 'every explicit lane
  unavailable' is now distinguishable (SELECTION_FAILED) from the
  plain no-flags-passed inert case (NO_LANES_SELECTED).
- Add request-level validation (validatePaths, depth/baseSha presence)
  that halts the WHOLE dispatch before any lane is planned or invoked:
  path traversal, absolute paths outside repoRoot, empty/non-string
  paths, and missing provenance are all rejected up front.
- Add per-lane prompt-budget enforcement (resolveBudget, mirroring
  gsd-tools.cjs's budgetFor convention including budget 0 = unbounded):
  a lane whose resolved budget the prompt exceeds hard-fails before
  invoke runs for it, without cancelling a sibling lane already
  planned.
- Document the supportsReviewerLanes trait and its dispatch-step
  interpreter in gsd-core/references/loop-hook-dispatch.md.
- GREEN: all 19 tests in tests/reviewer-step-dispatch.test.cjs pass;
  no regressions in the review-lane/reviewer-selection/prompt-budget
  suites (356 passing).

* test(01-03): define optional source reviewer flow

RED: assert code-review.md dispatches roster-derived reviewer-lane flags
through a single review-lane dispatch-step call (DISP-01..05), that the
no-flag path stays byte-for-behavior unchanged (COMP-01), and that
external evidence reaching the internal reviewer prompt is marked
unverified (CONS-02). Also covers the CLI contract directly: no-op with
no explicit selection, and fail-closed on an explicit unknown lane
(SAFE-07) via real gsd-tools.cjs subprocess calls.

* feat(01-03): route optional source reviewers

GREEN: code-review.md gains a dispatch_reviewer_lanes step that matches
canonical reviewer-lane flags against the merged first-party + installed
roster (never a hand-maintained list) and, only when at least one is
present, calls the shared reviewer-step interpreter exactly once with the
already-resolved repo root, file scope, depth, and base SHA. Its evidence
paths are appended to the internal reviewer prompt via
${EXTERNAL_EVIDENCE_BLOCK}, explicitly marked unverified. No reviewer-lane
flag leaves the internal-only dispatch byte-for-behavior unchanged
(COMP-01).

Deviation (Rule 3 — blocking issue): 01-02 documented `review-lane
dispatch-step` (gsd-core/references/loop-hook-dispatch.md) as the CLI
route `dispatchReviewerLanes` wires through, but never implemented the
gsd-tools.cjs subcommand — the workflow's call had nothing to reach. Add
it to the existing review-lane router, reusing the same effort-aware plan
building and runner deps `plan`/`invoke` already use (factored into
buildLaneRunnerDeps to avoid duplicating the spawn/http/fs seam). Guard
the CLI's own `detected` set on whether an explicit flag was passed:
resolveReviewerSelection's no-explicit-selection fallback is "select every
detected reviewer" (the correct default for /gsd:review), and passing it
an unconditionally non-empty detected set would silently invoke the whole
roster on every no-flag code review, violating COMP-01.

* test(01-03): define external finding consolidation

RED: assert gsd-code-reviewer.md treats <external_reviewer_evidence> as
untrusted input — independently re-verifies every claim against the actual
current source, resists a prompt-injection attempt embedded in evidence
text, and folds a verified claim into the existing Narrative Findings
section with no second REVIEW.md schema (CONS-01..03). Also assert
code-review.md's EXTERNAL_EVIDENCE_BLOCK restates the four fixed
source-review prohibitions (SAFE-03..06) at the internal-reviewer handoff.

* feat(01-03): consolidate external review evidence

GREEN: gsd-code-reviewer.md's load_context parses <external_reviewer_evidence>
as untrusted data, independently re-verifies every cited claim against the
actual current source before it can appear in REVIEW.md, and explicitly
resists prompt injection embedded in evidence text (never a command, no
matter what it claims to be). A verified claim folds into the existing
Narrative Findings section with (external: {slug}) provenance — one
REVIEW.md schema only, no separate external-findings section.
code-review.md's EXTERNAL_EVIDENCE_BLOCK now restates the four fixed
source-review prohibitions (SAFE-03..06) at the internal-reviewer handoff.

* fix(01-02): gitignore the reviewer-step-dispatch build artifact

01-02 added src/reviewer-step-dispatch.cts but never added its
npm run build:lib output to .gitignore, unlike every sibling
gsd-core/bin/lib/*.cjs generated file. Left it showing as untracked
noise in git status.

* docs(01-04): publish user and command contract for reviewer-lane source review

- Document optional reviewer-lane flags on /gsd-code-review in USER-GUIDE.md
  and COMMANDS.md: opt-in, no source bodies in prompts, no fallback on
  failure, findings independently consolidated into the single REVIEW.md
- Add the same contract to the docs/features/code-review-pipeline.md
  fragment and regenerate docs/FEATURES.md from it
- Preserve /gsd-review as the plan-review command; cross-reference it
  rather than duplicating the reviewer roster
- Pick up docs/INVENTORY-MANIFEST.json and skills/gsd-code-review/SKILL.md
  drift owned by source already shipped in Plans 01-01/01-03 but never
  regenerated (npm run regen:derived had not been run in this worktree)

* docs(01-04): align architecture and agent ownership docs for reviewer-lane trait

- ARCHITECTURE.md: trace the #4209 capability trait (supportsReviewerLanes)
  through the shared dispatchReviewerLanes interpreter to the existing
  review-lane plan/invoke machinery, ending at gsd-code-reviewer as the
  sole REVIEW.md consolidator
- AGENTS.md: document gsd-code-reviewer's full-context verification scope
  and its treatment of external reviewer evidence as unverified input
- No new diagram, abstraction, or config key; docs/CONFIGURATION.md is
  unchanged since the feature adds no setting or default

* fix(01-02): eslint-ignore the reviewer-step-dispatch build artifact

Same gap as the earlier .gitignore fix: 01-02 added
src/reviewer-step-dispatch.cts but never added its generated
gsd-core/bin/lib/reviewer-step-dispatch.cjs output to
eslint.config.mjs's ignore list like every sibling generated file,
so tsc's emitted __importDefault CommonJS-interop var tripped
no-var.

* fix(01-04): add the reviewer-step-dispatch.cjs roster row to docs/INVENTORY.md

01-04 regenerated docs/INVENTORY-MANIFEST.json (which now lists
cli_modules/reviewer-step-dispatch.cjs) but the hand-written roster
row in docs/INVENTORY.md — required by design, since a role sentence
cannot be generated — was never added.

* fix(01-01): update the code-review capability-step fixture for supportsReviewerLanes

refactor-trigger-cli.test.cjs's preservesCodeReviewHookShapeAlongsideRefactorHook
strict-deep-equals the code-review step's exact shape at execute:post; 01-01 added
supportsReviewerLanes: true to that step and this fixture was not updated.

* chore(01-03): acknowledge emitted-doc growth for code-review.md and gsd-code-reviewer.md

Both files grew as a direct, intended consequence of wiring optional
reviewer lanes into /gsd:code-review (the new dispatch_reviewer_lanes
step and the untrusted-evidence consolidation contract) — not
incidental drift.

Emitted-Drift-Ack-Growth: code-review.md — new dispatch_reviewer_lanes step and EXTERNAL_EVIDENCE_BLOCK wiring for optional reviewer lanes (#4209)
Emitted-Drift-Ack-Growth: gsd-code-reviewer.md — untrusted external-evidence consolidation contract for optional reviewer lanes (#4209)

* test(01-05): define WR-01/WR-02 reliability contract for dispatchReviewerLanes

From internal code review: dispatched must be false when zero lanes
actually reached plan(), and a throwing plan()/invoke() for one lane
must not discard results already collected for a sibling lane —
matching the fail-closed pattern gsd-tools.cjs already uses for the
same resolveLanePlan call (#2494/#2605/#1698/#1936/#2073/#2176/#2589/#2794).

Refs: gsd-core-dks.16, gsd-core-dks.17

* fix(01-05): close WR-01/WR-02/IN-01/IN-02 from internal review

- WR-01: dispatched now tracks whether any lane actually reached
  plan(), not results.length — an unresolvable selected slug no
  longer reports dispatched:true.
- WR-02: plan()/writePromptFile()/invoke() wrapped per-lane so a
  throw for one lane can never discard results already collected
  for a sibling lane, matching the same guard gsd-tools.cjs already
  has around the identical resolveLanePlan call.
- IN-01: documents the intentional budget===0-is-unbounded
  convention (#2797) the caller already relies on.
- IN-02: review-lane dispatch-step no longer blocks indefinitely on
  an un-piped interactive TTY; fails closed to empty paths instead.

Refs: gsd-core-dks.16, gsd-core-dks.17

* docs(01-05): add changeset fragment for PR #17

* fix(01-03): allowlist prompt-injection-scan false positive on the untrusted-evidence contract

agents/gsd-code-reviewer.md's untrusted-evidence section and its
pinning regression test both quote injection phrases as the exact
attack they defend against/detect — same
DEFECT.PROMPT-INJECTION-SCAN-COLLISION class as the existing
allowlist entries, not an actual injection vector.

* test(01-05): extend WR-02 coverage to writePromptFile/invoke throws; DIFF_BASE-empty skip

From CodeRabbit review: WR-02's earlier fix only wrapped plan() —
writePromptFile()/deps.invoke() still ran unguarded, so a throw
there still aborted every later selected lane. Also covers the
dispatch_reviewer_lanes DIFF_BASE-empty-provenance gap (explicit
lanes silently not running when no prior review and no phase-start
commit exist).

* fix(01-05): skip dispatch_reviewer_lanes with a clear warning when DIFF_BASE cannot be resolved

Previously an explicit reviewer-lane request with no prior review and
no resolvable phase-start commit reached dispatch-step with an empty
--base-sha, which fails closed via missing_provenance — correct, but
silent about why explicitly requested lanes didn't run. Now skip
dispatch entirely in that case with a stderr warning naming the
actual cause.

* fix(01-05): wrap writePromptFile/invoke in the same per-lane try/catch as plan()

WR-02's original fix only guarded plan() — a throw from
writePromptFile() or deps.invoke() still aborted the whole dispatch,
discarding results already collected for lanes processed earlier in
the loop. CodeRabbit caught the gap; WR-02b/WR-02c pin it.

* fix(01-05): WR-02b mock must throw only on the first writePromptFile() call

The committed mock threw unconditionally, so codex's retry also threw and
failed for the same reason as claude's — the test could not distinguish
'sibling still runs' from 'sibling also breaks'. Gate the throw to the
first call, matching WR-02/WR-02c's single-failure intent.

* fix(#4209): close review findings from adversarial + critical-code-reviewer pass

Two independent reviews (agy adversarial review, Opus critical-code-reviewer +
ponytail) found 6 Blocking and 7 Required issues in the reviewer-lane dispatch
wiring around dispatchReviewerLanes. All 13 tracked in gsd-core-dks.18-30 and
fixed here:

- dispatch-step's reducer silently swallowed whole-dispatch rejections
  (invalid paths, missing provenance, etc); it now checks parsed.ok/reason.
- spawn_reviewer recomputed its own stale DIFF_BASE, diverging from the
  LAST_REVIEW_COMMIT-aware value dispatch_reviewer_lanes uses on re-review;
  now shares the single compute_file_scope derivation.
- the external reviewer prompt had no actual review request or citation
  requirement, only prohibitions; added both.
- removed the supportsReviewerLanes trait plumbing (capability registry,
  validator, loop-resolver, docs, tests) — it was never consulted by the
  real dispatch path, which gates on explicit CLI flags instead.
- flag-resolution require() was a fragile cwd-relative literal that failed
  silently on non-vendored installs; now resolves via GSD_TOOLS's own
  directory and warns instead of swallowing failure.
- reducer didn't unwrap the @file: overflow protocol for large payloads.
- deduplicated resolveBudget/budgetFor into one resolveLaneBudget.
- lane artifacts now write to a mktemp run dir instead of $PHASE_DIR, so a
  second dispatch can't overwrite prior evidence.
- validatePaths rejects control characters, closing a markdown-injection
  vector into the external prompt via crafted filenames.
- reworded the one line that tripped prompt-injection-scan.sh instead of
  allowlisting the whole production prompt file.
- fixed a stale docstring range and a dispatched-field ordering bug.
- added 3 integration tests executing the actual reducer against synthetic
  dispatch-step JSON, replacing markdown-substring-only assertions.

771/771 tests pass across every touched suite; tsc --noEmit clean.

* fix(#4209): wire supportsReviewerLanes as the maintainer's required reusable trait

The maintainer's approval on issue #4209 explicitly redirected implementation
shape: reviewer-lane dispatch must be a reusable capability/step-dispatch
trait ("supportsReviewerLanes"), not code-review.md hand-wiring the call
itself. My previous commit (e2558326) deleted that trait entirely after
finding it declared-but-never-consulted, which was backwards — the fix was to
wire it, not remove it.

Restores the trait (capability.json, generated registry, validator,
loop-resolver.cts, docs, tests) and wires it for real: dispatch_reviewer_lanes
now resolves its own active hook via `gsd_run loop render-hooks` for the
configured workflow.code_review_point and only proceeds to CLI-flag matching
when supportsReviewerLanes reads true. Explicit flags no longer bypass the
trait; a matching flag with the trait false resolves zero slugs (proven by a
new integration test executing the real fence with both trait states).

Emitted-Drift-Ack-Growth: gsd-core/workflows/code-review.md — the
dispatch_reviewer_lanes step grows a trait-resolution fence (#4209 maintainer
redirect requires the capability layer, not the workflow, own the opt-in
decision).

* fix(#4209): dispatch-step self-verifies the reviewer-lane trait via --cap-id/--point

Both an agy adversarial review and an Opus critical-code-reviewer pass
independently found the same gap in my previous commit (9b2c3773d): the trait
check I wired into code-review.md only protected code-review's OWN
invocation — gsd-tools.cjs's dispatch-step handler still hardcoded
`trait: true` unconditionally, so a second capability declaring
supportsReviewerLanes would get zero enforcement from the shared CLI unless
it correctly re-implemented the ~15-line render-hooks scrape itself. That is
exactly the "each workflow.md hand-wiring the call" the maintainer's redirect
said to eliminate.

Moves the trait check into dispatch-step itself: given --cap-id/--point, it
self-invokes `loop render-hooks <point>` (relocating the one subprocess
code-review.md used to spawn for this, not adding a new one) and derives the
real trait from that capId's active hook, rather than trusting a
caller-passed boolean. code-review.md now only passes
--cap-id code-review --point "$CODE_REVIEW_POINT" and no longer resolves or
gates on the trait itself — the ~20-line scrape it previously carried is
gone. Any other capability opts into the identical enforcement by declaring
the trait and passing the same two flags.

Replaced the two tests that stipulated SUPPORTS_REVIEWER_LANES as an input
variable (they proved a bash branch honors a variable, not that the variable
reflects the real capability manifest) with three integration tests that
invoke the real dispatch-step CLI against the real first-party capability
registry: the real code-review trait resolves true, an unknown --cap-id
resolves false (trait_not_enabled, fail-closed), and omitting
--cap-id/--point entirely resolves false (no context means no opt-in).

Also: reject \x7f/U+2028/U+2029 in validatePaths' control-character check
(agy-F1 was incomplete), and delete the promptWritten per-lane coupling
flag — the prompt write is idempotent, so writing it once per lane instead
of gating on "did any lane write it yet" removes a latent bug where a
deps.plan override that ever varies promptPath per lane would silently skip
writing for a later lane.

Emitted-Drift-Ack-Growth: gsd-core/workflows/code-review.md — net line count
drops (the trait scrape moved into dispatch-step), but the file still grew
this session across multiple commits; acknowledging per the growth-tracking
convention.

* fix(#4209): remove per-run token waste from the shipped prompts

Runtime prompt content, not session tokens: two real, per-invocation token
costs in the code that ships.

1. agents/gsd-code-reviewer.md's critical_rules restated nearly all of
   load_context step 5's ~180-word untrusted-evidence contract in ~90 more
   words, breaking this section's own established terse one-liner style
   (every other rule here is 1-2 sentences). This prompt loads fresh on
   every /gsd:code-review invocation. Shrunk to a one-line cross-reference,
   matching how write_review's own reference to step 5 already does it.

2. buildSourceReviewPrompt repeated the base SHA on every single file line
   even though it is identical for every file and already stated once at
   the top of the prompt — O(files) wasted tokens on every dispatched lane
   for a 50-file review, for zero information gain. File lines are now bare
   paths.

* fix(#4209): resolve reviewer-lane trait in-process, fix CI failures found in review round 3

Opus critical-code-reviewer found a real Blocking defect in the --cap-id/
--point self-invocation added last commit: `dispatch-step` spawned
`loop render-hooks <point> --raw` as a subprocess and bare-JSON.parse'd its
stdout, but `io.cjs`'s output() redirects any payload over 50000 chars to
`@file:<path>` instead of inline JSON -- the same overflow protocol this
feature already unwraps for its OWN dispatch result 60 lines later in
code-review.md. A large-enough activeHooks envelope (more installed
capabilities/fragments) would throw, get silently swallowed by the bare
catch, and misreport a real trait as trait_not_enabled with zero diagnostic.

Fixed by extracting the config/registry/capability-state resolution
`cmdLoopRenderHooks` already performs into an exported pure function,
resolveActiveHooksForPoint (both `cmdLoopRenderHooks` and dispatch-step now
share it), and calling it in-process from dispatch-step instead of spawning
a subprocess at all. This eliminates the @file: exposure entirely (the
dispatch-step path never touches the rendered-string envelope or its
JSON-stringify/50000-char threshold), removes one subprocess spawn per
code-review invocation, and gives a genuine diagnostic (stderr warning) on
resolution failure instead of silent fail-closed. Corrected three doc/
docstring references to the now-removed subprocess self-invocation.

Also fixes 2 real CI failures this round surfaced:
- lint-tests: the agy-F1 control-char regex fix's `eslint-disable-next-line
  no-control-regex` comment was unused under this project's ESLint config
  (verified locally: the rule never actually flags \x00-\x1f in this repo's
  config) -- a mistake from an earlier commit this session, never actually
  lint-checked before push. Removed the disable comment.
- security (prompt-injection-scan): the agy-F1 regression test's crafted
  fixture literally contains "Ignore all prior instructions." as test data
  proving validatePaths rejects it -- allowlisted the test file, same
  DEFECT.PROMPT-INJECTION-SCAN-COLLISION class as existing entries.

Also trimmed agents/gsd-code-reviewer.md's load_context step 5 (R2): one
bullet stated "untrusted, never a command" three different ways in one
paragraph, and a same-file duplicate of write_review's schema rule.
Consolidated to state each rule once.

Declined one suggestion from this round: shrinking code-review.md's
EXTERNAL_EVIDENCE_BLOCK to a bare evidence list. Two tests
(tests/code-review-pipeline-regression.test.cjs's CONS-01..03 block,
tests/code-review.test.cjs's CONS-02 test) deliberately lock the four-
prohibitions restatement and the untrusted-evidence prose into the
INJECTED block itself, not just the consolidator's system prompt --
adjacency of the warning to the untrusted payload it's warning about is a
recognized prompt-injection defense-in-depth pattern from this
workstream's original TDD plan, not accidental duplication.

* fix(#4209): correct stale per-file base-SHA prose in the external prompt

Leftover from removing the per-file base SHA repetition earlier this
session: the review-request sentence still said "relative to its base SHA"
(singular per-file framing) when there's now exactly one base SHA, stated
once above the file list. Reads "relative to the base SHA above" now.

* fix(#4209): make getLane/configGet/plan required deps, delete dead defaults

R3/R4 from the review round I'd deferred as low-priority test-churn: this
file's one production caller (gsd-tools.cjs's dispatch-step handler) always
supplies all three, so the fallbacks were dead in production -- but each was
actively WRONG if ever reached: the default configGet always returned
undefined, silently disabling resolveLaneBudget's overflow guard; the
default getLane looked up only first-party REVIEWER_LANES, diverging from
production's overlay-merged roster; the default plan skipped per-host effort
resolution entirely.

These defaults were introduced by this PR's own earlier work (this file did
not exist before #4209 -- first commit a760bfcda, 01-02), not inherited from
elsewhere, so there's no external caller depending on the lenient contract.

Turned out free to fix: making the three deps required and deleting
defaultGetLane/defaultPlan needed zero test changes -- every existing test
that actually reaches the per-lane loop already supplies getLane/plan
explicitly, and configGet's only real dependent (the budget-overflow tests)
already supplies it too. 788/788 tests pass unchanged, tsc/lint clean.

* fix(#4209): define depth semantics for the external reviewer lane

Verified this was a real bug, not a match to existing convention as I'd
claimed when declining the suggestion earlier this session: the internal
gsd-code-reviewer agent's own system prompt carries a full <depth_levels>
block defining what quick/standard/deep mean and do (agents/gsd-code-
reviewer.md:68-99). The external reviewer lane has no access to that
persona at all -- it only ever sees buildSourceReviewPrompt's bounded text,
which sent the bare depth label with zero definition to a third-party CLI
with no other source of truth for what "standard" means.

Added depthMeaning(), condensed from the internal reviewer's own
<depth_levels> definitions so the two stay consistent, and interpolated it
into the review-request sentence. 150/150 tests pass, tsc/lint clean.

* fix(#4209): merge dispatch_reviewer_lanes' split fences into one shell invocation

CR-01 (Opus critical-code-reviewer, confirmed by direct execution): the
roster-matching fence set EXPLICIT_JOINED/EXPLICIT_REVIEWER_SLUGS, and a
SEPARATE later fence read them via ${#EXPLICIT_REVIEWER_SLUGS[@]} to decide
whether to dispatch at all. This file's own documented rule (its
depth-resolution guard, stated explicitly a few hundred lines earlier) is
that a guard and the extraction it protects must run as one shell
control-flow decision, because markdown-fenced blocks do not share shell
state -- this step violated its own file's rule for the entire feature's
gating condition.

Merged the roster-resolution fence and the dispatch-decision fence into one
continuous bash block, removing the intervening prose that split them.
Fixed the stderr-based failure detection in the same edit (RQ-01: checking
whether stderr is non-empty misfires on any benign Node warning; now checks
the actual exit status of the roster-resolution command).

Verified by extracting the merged fence and executing it standalone, driving
both branches: --codex resolves EXPLICIT_JOINED=codex, SLUGS_COUNT=1, and a
real dispatch-step call succeeds; no flags resolves EXPLICIT_JOINED empty,
SLUGS_COUNT=0, dispatch-step never invoked (COMP-01). 141/141 workflow tests
pass, tsc/lint clean.

* fix(#4209): depthMeaning accuracy, injection defense on all embedded fields, hoisted prompt write

Batch of Required/Suggestion fixes from the Opus critical-code-reviewer +
writing-for-agents pass:

- CR-02/CR-03: depthMeaning() dropped real categories from quick (empty catch
  blocks, commented-out code) and deep (error propagation, state mutation
  consistency, circular dependencies) relative to the real <depth_levels>
  block, and had zero test coverage. Restored full accuracy and added tests
  that read the real agents/gsd-code-reviewer.md file directly, so drift
  between the two can't recur silently. Unrecognised depth now normalizes to
  standard's definition, matching that agent's own documented rule, instead
  of rendering an undefined bare label.

- RQ-04: depth/baseSha/repoRoot/runDir land in the same markdown prompt
  `paths` does, but weren't checked for control characters like paths were
  (agy-F1's original finding). Hoisted CONTROL_CHAR to module scope and
  applied it to all four fields at the same provenance-check boundary.
  runDir previously had zero validation at all.

- S1: deleted the dead `identity` parameter on `invoke` -- the one production
  caller already ignores it, no test read it by name.

- S2: hoisted the shared prompt write above the per-lane loop -- promptPath
  is derived from runDir alone (constant across lanes by construction), so
  writing it once is both correct and cheaper than the per-lane write R1
  introduced earlier this session. Discovered and fixed a real regression
  from the naive version of this hoist: an unguarded throw would have
  escaped dispatchReviewerLanes as an uncaught exception instead of a clean
  per-lane failure. Added a new PROMPT_WRITE_FAILED whole-dispatch reason,
  matching the existing validatePaths/MISSING_PROVENANCE halt pattern, with
  a dedicated regression test.

- S3: moved `planned = true` past the budget-overflow gate, so `dispatched`
  only reports true once a lane has cleared BOTH plan and budget checks.

- S5: relayed gsd-code-reviewer.md's own "performance issues are out of
  scope unless also correctness issues" policy into the external-lane
  prompt, which previously had no such guidance and could return findings
  the internal reviewer's own contract excludes.

- RQ-05 (partial): shrunk this file's own header docstring's restatement of
  the trait-reuse architecture to a pointer at
  gsd-core/references/loop-hook-dispatch.md, the canonical home.

234/234 tests pass across the full reviewer-lane test suite, tsc/lint clean.

* fix(#4209): dedupe roster-merge logic, consolidate trait architecture prose, add step completion criterion

RQ-02: added a `review-lane explicit-from-argv` subcommand that reuses the
SAME merged-roster logic (`laneBySlug`) `dispatch-step`/`plan`/`invoke`
already share. code-review.md's ~18-line inline `node -e` reimplementing
`loadRegistry`+`mergeReviewerLanes` (a rename-only copy of the block in
gsd-tools.cjs) is now a single call to this subcommand -- the exact
violation code-review-flags.cjs's own header warns against ("this is the
canonical flag-parsing surface -- do not replicate inline bash parsing").

RQ-03: an empty --cap-id XOR --point now warns distinctly from the
legitimate no-context opt-out (both absent) -- a caller that named a
capability without its point was silently indistinguishable from a correct
opt-out. Also hardened the CODE_REVIEW_POINT config-get fallback: it only
ever fires when the config-get COMMAND ITSELF fails (config-get already
resolves the manifest's own schema default in the normal case), but that
failure was previously silent.

RQ-05/W-01/W-12/W-13: the "supportsReviewerLanes is a reusable trait
resolved inside dispatch-step" explanation was restated in full in 5
places across this session's own review cycles. Consolidated to ONE
canonical statement in gsd-core/references/loop-hook-dispatch.md; the other
4 (this file's own header, gsd-tools.cjs's comment, docs/ARCHITECTURE.md,
code-review.md's step-opening comment) now point at it instead.

W-05/W-06: loop-hook-dispatch.md described "false or non-boolean" as two
inert cases when capability-validator.cjs already rejects non-boolean at
load -- restated as the two cases that actually reach this code. Removed a
"do not hand-roll trait resolution" prohibition whose target no longer
exists once the positive description precedes it.

W-04: deleted a no-op sentence in agents/gsd-code-reviewer.md ("missing
block means proceed as normal") -- an absent optional block already means
proceed as normal without being told.

W-08/W-09: replaced longhand "zero selection/plan/invoke calls" and the
made-up compound "byte-for-behavior [un]changed" with the token this
session's own docs already coined for this concept (inert) and the word
that means what byte-for-behavior was reaching for (unchanged).

W-10: dispatch_reviewer_lanes had no completion criterion -- added one
sentence naming the checkable end state (EXTERNAL_EVIDENCE_BLOCK is set,
either populated or empty). This exact sentence would have caught the
cross-fence bug fixed two commits ago at authoring time.

Declined from this round, with reasoning: W-02/W-03 (trim the
untrusted-evidence restatement in EXTERNAL_EVIDENCE_BLOCK/critical_rules) --
two tests deliberately lock this as intentional adjacency-based
prompt-injection defense-in-depth, not accidental duplication (see this
branch's own earlier commit). S4 (wrap LANE_RUN_DIR in a creation-site
`trap ... EXIT`) -- would fire at the end of the CREATING fence, before
spawn_reviewer's agent ever reads the evidence files, given this file's own
documented fenced-block execution model; the existing named cross-reference
between creation and cleanup already satisfies the co-location concern
without introducing that regression.

853/853 tests pass across the full reviewer-lane test suite, tsc/lint clean.

* fix(#4209): merge CODE_REVIEW_POINT into dispatch_reviewer_lanes' one fence, stop test from spawning real codex

Round-5 review (agy) found the same cross-fence-split bug CR-01 already fixed
for EXPLICIT_JOINED/EXPLICIT_REVIEWER_SLUGS: CODE_REVIEW_POINT's config-get
fallback lived in an earlier, separate fence from the fence that consumes it
via --point, split only by prose (not a guard, per this step's own documented
rule). Merged into the single continuous fence and added a structural test
asserting exactly one bash fence in the step.

The new end-to-end regression test for this used --codex, which drives the
fence's real `review-lane dispatch-step` call and, with the codex binary
present on PATH, spawns the real external CLI — which then blocks on
interactive auth with no stdin (BL-01). Stubbed gsd_run for
`review-lane dispatch-step` only (captures argv instead of executing),
keeping the real config-get/explicit-from-argv calls the test is actually
about.

* fix(#4209): split control-char vs missing provenance reason, realpath-check path escapes, stale comment

Round-5 review (Opus) warning-tier findings:

- WR-04: MISSING_PROVENANCE covered both "field absent" and "field present but
  a control-character injection attempt" — a caller distinguishing a config
  problem from a security event couldn't tell them apart. Split into
  MISSING_PROVENANCE (absent) and INVALID_PROVENANCE (present but invalid).
- WR-05: validatePaths' containment check was lexical only (path.resolve),
  so a symlink whose own path sits inside repoRoot could still point outside
  it. Added an fs.realpathSync check (ENOENT-tolerant — a git-diff path can
  legitimately name a file already deleted in a stale worktree), realpathing
  repoRoot itself too so a symlinked repoRoot (e.g. /tmp on macOS) doesn't
  false-positive-reject its own real children.
- WR-08: a comment in the per-lane loop still said a throwing writePromptFile()
  was caught there — stale since the prompt write was hoisted above the loop
  in an earlier round.

WR-03 (validate depth against the quick/standard/deep enum) was considered
and declined: this dispatcher is deliberately capability-neutral (see the
existing "synthetic step context" test, which passes a non-code-review depth
label on purpose to prove no code-review-specific special-casing exists).
WR-01 (double registry load), WR-02 (trim-vs-hard-fail budget semantics), and
WR-07 (reason omitted on the aggregate return) were verified against source
and are not bugs — see review notes.

* docs(#4209): document LANE_RUN_DIR's early-exit trade-off as accepted, not a gap

Round-5 review (Opus, BL-03) flagged that an early exit between
dispatch_reviewer_lanes and commit_review leaks the run-scoped temp dir. A
trap-based cleanup was considered and rejected: if a step genuinely runs as
a separate process, a trap set at creation time would fire at the end of
that SAME fence, deleting the directory before spawn_reviewer/commit_review
ever read it — worse than the leak it would fix.

review.md's own gather_context/cleanup pair for the identical resource class
(a run-scoped reviewer temp dir) already makes and documents this exact
trade-off: cleanup runs only on a documented success path, and a leftover
$TMPDIR entry is explicitly called cheaper than destroyed evidence. Recording
that precedent here so this isn't re-raised as a live gap in a future review.

* fix(#4209): register the WR-05 symlink-escape test's synthetic docs/ path

reviewer-step-dispatch.test.cjs's "capability-neutral reuse" fixture passes
paths: ['docs/spec.md'] as a synthetic, never-read path proving the
dispatcher has no code-review-specific special-casing. lint-docs-guard-
registration correctly flagged this as an unregistered docs/ path reference —
add the docs-guard-exempt marker and its pinned baseline entry, the same
pattern every other synthetic docs/ literal in this test suite already uses.

* fix(#4209): backfill changeset pr: field with the real upstream PR number

changeset-lint's fail_pr_field_drift caught the fragment still pointing at
the fork PR (17) instead of the upstream one (open-gsd/gsd-core#4323) this
branch is now also open against.

* docs(#4209): amend ADR-2782 for the supportsReviewerLanes step-trait seam

trek-e's review (2026-09-07, gsd-core#4323) found a real ADR gap: every
decision in ADR-2782 (D1-D9) and every prior dated amendment governs the
`role: "reviewer"` capability body and its one consumer, /gsd:review. This
PR's actual new seam - a `supportsReviewerLanes: true` trait on an ordinary
feature capability's `steps[]` entry, projected through loop-resolver.cts
and resolved in-process via resolveActiveHooksForPoint - is a different
capability axis (steps/gates/contributions) that the ADR's own scope note
explicitly places out of reach. Per docs/contributor-standards.md's
"Amending an accepted ADR", an in-place dated section is the established,
lighter-weight path for an addition that stays within the ADR's existing
decisions - used twice already in this same file - so this appends a third
dated entry documenting the new seam, its consumer, and why it reuses the
existing D1-D9-governed plan/invoke machinery rather than adding a second
one. No decision is reversed; no new Amends/Amended-by pair is needed since
the steps/gates/contributions axis already carries reciprocal links to
ADR-857 and ADR-894.

* fix(#4209): close two test-quality gaps trek-e's review found

Minor 1: validatePaths (a path-shape parser guarding the prompt-
injection/path-traversal trust boundary) had only example-based coverage,
violating ADR-456's rule that parsers/budget limits carry at least one
fast-check property test. Adds three: safe-segment paths are never
rejected, a single leading "../" always escapes the one-segment repoRoot,
and a control character anywhere is always rejected - one property per
rejection reason validatePaths owns.

Minor 2: the budget-overflow check (`estimatedTokens > budget`) was only
ever exercised far below budget or at budget:0 (unbounded), never at the
exact threshold crossing where a `>` vs `>=` off-by-one would hide. Adds
three exact-boundary tests using the real estimateTokens/
buildSourceReviewPrompt the module calls internally, so the resolved
token count is exact rather than approximated: budget == estimate (must
pass), budget == estimate - 1 (must fail), budget == estimate + 1 (must
pass).

Also extracts okPlan()'s fixture timeoutMs into a named constant -
local/no-adhoc-timeout-literal (#4446) landed on next after this branch
was authored and flagged the pre-existing literal on rebase; it is fixture
data for a synthetic plan object dispatchReviewerLanes never waits on, a
distinct class from tests/helpers/timeouts.cjs's real subprocess norms.

* fix(#4209): update docs-guard-registration baseline for the new ADR citation

reviewer-step-dispatch.test.cjs's new fast-check property tests cite
docs/adr/456-test-rigor-architecture.md in a justifying comment (never a
real read). lint-docs-guard-registration fingerprints every docs/ path
string an exempted test file mentions and fails on drift so a human
re-confirms the exemption still holds - re-confirmed, and the baseline is
updated to match.

* fix(#4209): point changeset pr: field at the fork PR for CI validation

changeset-lint's fail_pr_field_drift check compares the fragment's pr:
field against the PR the CI run is actually attached to (GITHUB_EVENT_PATH),
not a fixed target. Rehearsing this branch on fork PR
davdittrich/gsd-core#17 needs pr: 17 to pass that check; the prior commit's
pr: 4323 (the real open-gsd upstream PR number) is correct for that PR but
fails here. Backfill to 4323 happens again, as the last commit, immediately
before the approved push to open-gsd#4323 - never leaving pr: 17 on the
branch that ships upstream.

* fix(#4209): reject promptChannel:none lanes from source-review dispatch

CodeRabbit found a real scope mismatch: coderabbit's lane declares
promptChannel: 'none' and reviews the working tree on its own terms,
fed nothing (review.md:367). Silently dispatching it through
dispatchReviewerLanes would ignore the bounded paths/depth/baseSha scope
buildSourceReviewPrompt promises and let the lane review whatever it
independently sees fit, violating this interpreter's own scoped,
metadata-only contract. Reject before plan()/invoke(), same as an
unresolved slug.

* fix(#4209): scope CONS-02 test to the evidence-block line, not the whole file

CodeRabbit found the whole-file match on workflowContent would still
pass if UNVERIFIED and re-open/reopen appeared in two unrelated parts
of this 1000+-line workflow, proving nothing about the actual evidence
block's contract. Line-filtered via splitLines (not a bare-\n regex
spanning readFileSync content) so this stays CRLF-portable and passes
local/no-unbounded-quantifier and local/no-crlf-fragile-split.

* fix(#4209): guard DISPATCH_JSON substitution and capture its stderr

CodeRabbit found the dispatch-step command substitution unguarded: a
non-zero exit could leave DISPATCH_JSON empty (or halt the step under
errexit with no warning), and the downstream reducer would only ever
report the generic unparseable_dispatch_output reason, discarding the
command's own diagnostic. Guarded like the existing CODE_REVIEW_POINT/
EXPLICIT_JOINED calls above it: capture stderr to a temp file, surface
it in a warning on failure, and fall back to a parseable dispatch_
command_failed JSON stub so the reducer's existing reason-reporting
path still fires.

* docs(#4209): fix byte-for-behavior wording and missing colon, regenerate

CodeRabbit found "byte-for-behavior" should read "byte-for-byte" (the
established repo term for output-identical unchanged behavior) and a
missing colon after the bold "Optional external reviewer lanes (#4209)"
lead-in in docs/features/code-review-pipeline.md. Fixed in the two
hand-authored sources (commands/gsd/code-review.md, docs/features/
code-review-pipeline.md) and regenerated the two derived projections
(skills/gsd-code-review/SKILL.md via gen-plugin-skills.cjs, docs/
FEATURES.md via gen-features.cjs) so they stay in sync.

* fix(#4209): drop the fabricated DISPATCH_JSON fallback stub (Windows CI)

The prior fix's fallback `DISPATCH_JSON='{"ok":false,...}'` embeds
double-quoted JSON keys inside a single-quoted shell literal. That
extra quote density, inside an already quote-heavy ~8KB driver string,
passed bash -n and the full local suite on Linux but broke Windows
Git-Bash: `dispatch_reviewer_lanes computes CODE_REVIEW_POINT ... end
to end (#4209 round 5)` failed on two Windows CI shards with `bash -c:
unexpected EOF while looking for matching '''` — a Windows argv-to-
command-line re-quoting edge case, reproducible on rerun, not a flake.
Root-caused via gh api job logs plus a byte-identical local
reconstruction of the test's own driver script.

Fix: drop the fabricated stub. The downstream node -e reducer already
falls back to reason `unparseable_dispatch_output` on any JSON.parse
failure, so an empty/partial DISPATCH_JSON on command failure is still
handled correctly, with zero new quoting risk.

* revert(#4209): drop the DISPATCH_JSON stderr-guard nitpick (Windows CI)

Two materially different mechanisms for the same CodeRabbit Nitpick
("Trivial | Quick win") both broke Windows Git-Bash reproducibly:
a single-quoted JSON-literal fallback ("bash -c: unexpected EOF ...
matching '''") and, after removing that, a plain `head -1 "$VAR"`
inside a nested command substitution ("unexpected EOF ... matching
'"'"). Both passed bash -n and the full local suite on Linux every
time; both failed the SAME test deterministically on Windows CI. Two
attempts at the same class of fix (nested-quote construction near
this exact step) is the retry limit - reverting to the original,
already-shipped, Windows-verified unguarded form rather than
continuing to guess at a third quoting mechanism for a Trivial-
severity nitpick. Logged as bug-221/bug-222 in .wolf/buglog.json for
anyone attempting this again: the fix belongs outside this specific
markdown-fence-driver test harness (e.g., a real .sh helper script)
if it's worth doing at all.

* fix(#4209): backfill changeset pr: field to the real upstream PR before push

Fork validation (davdittrich/gsd-core#17) needed pr: 17 to satisfy
changeset-lint's PR-number check while rehearsing there; this is the
last commit before the approved push to the real upstream PR
(open-gsd/gsd-core#4323), so the field points at that PR number again.

---------

Co-authored-by: Test <test@test.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-09-07 22:52:33 -04:00

7807 lines
333 KiB
JavaScript

'use strict';
/**
* capability-registry.test.cjs — behavioral tests for the capability registry generator.
*
* ADR-894 phase 3a-impl.
* Uses node:test + node:assert/strict.
* Tests use in-memory fixtures (not real files) for adversarial cases.
* The UI pilot test loads from the real capabilities/ui/ directory.
*/
const { describe, test } = 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 { scanFencedBlocks } = require('../gsd-core/bin/lib/markdown-sectionizer.cjs');
const {
validateCapability,
validateAgainstContract,
validateConsumesGlobal,
validateCrossCapability,
classifyCrossErrors,
loadAndValidate,
buildRegistry,
serializeRegistry,
computeRequiresClosure,
topoSortSteps,
normalizeLineEndings,
stripGeneratedComment,
validateConfigSliceEntry,
VALID_CONFIG_SLICE_TYPES,
SCHEMA_VERSION,
// ADR-857 phase 4a
deriveCapabilityClusters,
deriveProfileMembership,
runConsistencyGate,
PROFILE_RANK,
// ADR-959
validateCommandEntry,
// ADR-857 phase 5e
VALID_CONVERTER_NAMES,
validateArtifactKindEntry,
runConfigFormatParityGate,
INSTALL_SURFACE_TO_CONFIG_FORMAT,
// ADR-857 phase 5f: cross-field consistency gates
INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES,
VALID_INSTALL_SURFACES,
VALID_EXTENDED_HOOK_EVENTS,
VALID_PERMISSION_WRITERS,
validateRuntimeCompat,
validateRuntimeBody,
loadCentralConfigKeys,
validateHooksWired,
POINT_ORDER,
} = require('../scripts/gen-capability-registry.cjs');
const {
STEP_WORKFLOWS,
HOST_LOOP_FILES,
buildContract,
scanWiredPoints,
getWiredLoopPoints,
CANONICAL_POINTS,
} = require('../scripts/gen-loop-host-contract.cjs');
const { LOOP_HOST_CONTRACT } = require('../gsd-core/bin/lib/loop-host-contract.cjs');
// ADR-1244 D2: the validator was extracted to a shared runtime-callable module.
// The generator must re-export it verbatim — the parity suite below proves no drift.
const capValidatorModule = require('../gsd-core/bin/lib/capability-validator.cjs');
const generatorModule = require('../scripts/gen-capability-registry.cjs');
const fc = require('fast-check');
const ROOT = path.resolve(__dirname, '..');
// ─── UI pilot fixture (from capabilities/ui/capability.json) ─────────────────
const UI_CAP_PATH = path.join(ROOT, 'capabilities', 'ui', 'capability.json');
const UI_CAP = JSON.parse(fs.readFileSync(UI_CAP_PATH, 'utf8'));
// ─── Helper: write temporary capability dir ───────────────────────────────────
function makeTempCapDir(capabilities) {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-test-'));
for (const [id, cap] of Object.entries(capabilities)) {
const subDir = path.join(tmpDir, id);
fs.mkdirSync(subDir, { recursive: true });
fs.writeFileSync(path.join(subDir, 'capability.json'), JSON.stringify(cap), 'utf8');
}
return tmpDir;
}
// ─── 1. Valid UI pilot ────────────────────────────────────────────────────────
describe('UI pilot capability', () => {
test('UI capability.json passes per-file validation', () => {
const errors = validateCapability(UI_CAP, 'ui');
assert.deepEqual(errors, [], 'Expected no validation errors: ' + JSON.stringify(errors));
});
test('UI capability passes contract validation', () => {
const errors = validateAgainstContract(UI_CAP, 'ui');
assert.deepEqual(errors, [], 'Expected no contract errors: ' + JSON.stringify(errors));
});
test('UI pilot generates a registry with correct shape', () => {
// Pass empty central keys so the pre-migration config keys do not cause collision errors
const capDir = makeTempCapDir({ ui: UI_CAP });
const { capMap, errors } = loadAndValidate(new Set(), capDir);
assert.deepEqual(errors, [], 'Expected no errors: ' + JSON.stringify(errors));
const registry = buildRegistry(capMap);
// capabilities.ui exists
assert.ok(registry.capabilities.ui, 'registry.capabilities.ui should exist');
assert.strictEqual(registry.version, SCHEMA_VERSION);
assert.deepStrictEqual(
registry.capabilities.ui.runtimeCompat,
{ supported: ['*'], unsupported: [] },
'feature runtime compatibility contract should be preserved in the registry',
);
// bySkill maps ui-phase and ui-review to 'ui'
assert.strictEqual(registry.bySkill['ui-phase'], 'ui');
assert.strictEqual(registry.bySkill['ui-review'], 'ui');
// byAgent maps gsd-ui-checker and gsd-ui-auditor to 'ui'
assert.strictEqual(registry.byAgent['gsd-ui-checker'], 'ui');
assert.strictEqual(registry.byAgent['gsd-ui-auditor'], 'ui');
// byLoopPoint['plan:pre'].steps contains the ui-phase step
const planPreSteps = registry.byLoopPoint['plan:pre'].steps;
assert.ok(Array.isArray(planPreSteps), 'plan:pre.steps should be an array');
const uiPhaseStep = planPreSteps.find((s) => s.ref && s.ref.skill === 'ui-phase');
assert.ok(uiPhaseStep, 'plan:pre.steps should contain the ui-phase step');
assert.strictEqual(uiPhaseStep.capId, 'ui');
// byLoopPoint['plan:pre'].gates contains the new ui.plan-gate (#1026)
const planPreGates = registry.byLoopPoint['plan:pre'].gates;
assert.ok(Array.isArray(planPreGates), 'plan:pre.gates should be an array');
const uiPlanGate = planPreGates.find(
(g) => g.check && g.check.query === 'ui.plan-gate',
);
assert.ok(uiPlanGate, 'plan:pre.gates should contain the ui.plan-gate (#1026)');
assert.strictEqual(uiPlanGate.capId, 'ui');
assert.strictEqual(uiPlanGate.blocking, true);
assert.strictEqual(uiPlanGate.when, 'workflow.ui_safety_gate');
assert.strictEqual(uiPlanGate.onError, 'halt');
// byLoopPoint['execute:wave:post'].gates contains the UI safety gate
const execWavePostGates = registry.byLoopPoint['execute:wave:post'].gates;
assert.ok(Array.isArray(execWavePostGates), 'execute:wave:post.gates should be an array');
const uiGate = execWavePostGates.find(
(g) => g.check && g.check.query === 'ui.safety-gate',
);
assert.ok(uiGate, 'execute:wave:post.gates should contain the ui safety gate');
assert.strictEqual(uiGate.capId, 'ui');
assert.strictEqual(uiGate.blocking, true);
// configKeys maps the 3 UI keys to 'ui' (ownership map — preserved)
assert.strictEqual(registry.configKeys['workflow.ui_phase'], 'ui');
assert.strictEqual(registry.configKeys['workflow.ui_review'], 'ui');
assert.strictEqual(registry.configKeys['workflow.ui_safety_gate'], 'ui');
// configSchema index — new in phase 3b
assert.ok(registry.configSchema, 'registry.configSchema should exist');
// workflow.ui_phase
assert.ok(registry.configSchema['workflow.ui_phase'], 'configSchema should have workflow.ui_phase');
assert.strictEqual(registry.configSchema['workflow.ui_phase'].owner, 'ui');
assert.strictEqual(registry.configSchema['workflow.ui_phase'].type, 'boolean');
assert.strictEqual(registry.configSchema['workflow.ui_phase'].default, true);
assert.strictEqual(typeof registry.configSchema['workflow.ui_phase'].description, 'string');
assert.ok(registry.configSchema['workflow.ui_phase'].description.length > 0);
// workflow.ui_review
assert.ok(registry.configSchema['workflow.ui_review'], 'configSchema should have workflow.ui_review');
assert.strictEqual(registry.configSchema['workflow.ui_review'].owner, 'ui');
assert.strictEqual(registry.configSchema['workflow.ui_review'].type, 'boolean');
assert.strictEqual(registry.configSchema['workflow.ui_review'].default, true);
// workflow.ui_safety_gate
assert.ok(registry.configSchema['workflow.ui_safety_gate'], 'configSchema should have workflow.ui_safety_gate');
assert.strictEqual(registry.configSchema['workflow.ui_safety_gate'].owner, 'ui');
assert.strictEqual(registry.configSchema['workflow.ui_safety_gate'].type, 'boolean');
assert.strictEqual(registry.configSchema['workflow.ui_safety_gate'].default, true);
});
test('requiresClosure("ui") returns empty set (no requires)', () => {
const capMap = new Map([['ui', UI_CAP]]);
const closure = computeRequiresClosure('ui', capMap);
assert.deepEqual([...closure], []);
});
});
// ─── 2. Adversarial invalid declarations ─────────────────────────────────────
describe('validateCapability adversarial cases', () => {
test('missing id rejected', () => {
const cap = { ...UI_CAP };
delete cap.id;
const errors = validateCapability(cap, 'ui');
assert.ok(errors.length > 0, 'Expected errors for missing id');
assert.ok(
errors.some((e) => e.includes('id')),
'Error should mention id, got: ' + JSON.stringify(errors),
);
});
test('id not equal to folder name rejected', () => {
const cap = { ...UI_CAP, id: 'not-ui' };
const errors = validateCapability(cap, 'ui');
assert.ok(errors.length > 0);
assert.ok(errors.some((e) => e.includes('folder')));
});
test('bad role rejected', () => {
const cap = { ...UI_CAP, role: 'plugin' };
const errors = validateCapability(cap, 'ui');
assert.ok(errors.length > 0);
assert.ok(errors.some((e) => e.includes('role')));
});
test('feature capability without runtimeCompat is rejected', () => {
const cap = { ...UI_CAP };
delete cap.runtimeCompat;
const errors = validateCapability(cap, 'ui');
assert.ok(
errors.some((e) => e.includes('runtimeCompat')),
'Expected missing runtimeCompat validation error, got: ' + JSON.stringify(errors),
);
});
test('runtimeCompat.supported must be a non-empty array', () => {
const errors = validateRuntimeCompat('ui', { supported: [], unsupported: [] });
assert.ok(
errors.some((e) => e.includes('runtimeCompat.supported')),
'Expected supported-array validation error, got: ' + JSON.stringify(errors),
);
});
test('runtimeCompat.supported wildcard cannot be mixed with runtime ids', () => {
const errors = validateRuntimeCompat('ui', { supported: ['*', 'claude'], unsupported: [] });
assert.ok(
errors.some((e) => e.includes('wildcard')),
'Expected wildcard validation error, got: ' + JSON.stringify(errors),
);
});
test('bad tier enum rejected', () => {
const cap = { ...UI_CAP, tier: 'premium' };
const errors = validateCapability(cap, 'ui');
assert.ok(errors.length > 0);
assert.ok(errors.some((e) => e.includes('tier')));
});
test('step with invalid point rejected', () => {
const cap = {
...UI_CAP,
steps: [
{ ...UI_CAP.steps[0], point: 'notapoint:pre' },
],
};
const errors = validateCapability(cap, 'ui');
assert.ok(errors.length > 0);
assert.ok(errors.some((e) => e.includes('notapoint:pre')));
});
test('gate with agentVerdict and blocking:true rejected', () => {
const cap = {
...UI_CAP,
gates: [
{
point: 'execute:wave:post',
check: { agentVerdict: { ref: 'gsd-ui-checker', prompt: 'check' } },
blocking: true,
onError: 'halt',
},
],
};
const errors = validateCapability(cap, 'ui');
assert.ok(errors.length > 0);
assert.ok(
errors.some((e) => e.includes('agentVerdict') && e.includes('blocking')),
'Expected error about agentVerdict forcing blocking:false, got: ' + JSON.stringify(errors),
);
});
test('#1634: a valid tool-scoping matcher is accepted on a lifecycle hook', () => {
const cap = {
...UI_CAP,
hooks: [{ event: 'PreToolUse', script: 'hooks/genfile-guard.cjs', matcher: 'Write|Edit' }],
};
const errors = validateCapability(cap, 'ui');
assert.ok(
!errors.some((e) => e.includes('matcher')),
'A valid matcher must not produce a matcher error, got: ' + JSON.stringify(errors),
);
});
test('#1634: an absent matcher is accepted (match-all)', () => {
const cap = { ...UI_CAP, hooks: [{ event: 'PreToolUse', script: 'hooks/g.js' }] };
const errors = validateCapability(cap, 'ui');
assert.ok(
!errors.some((e) => e.includes('matcher')),
'An absent matcher must not error, got: ' + JSON.stringify(errors),
);
});
test('#1634: an empty-string matcher is rejected', () => {
const cap = { ...UI_CAP, hooks: [{ event: 'PreToolUse', script: 'hooks/g.js', matcher: '' }] };
const errors = validateCapability(cap, 'ui');
assert.ok(
errors.some((e) => e.includes('matcher') && e.includes('non-empty')),
'Expected a non-empty matcher error, got: ' + JSON.stringify(errors),
);
});
test('#1634: a non-string matcher is rejected', () => {
const cap = { ...UI_CAP, hooks: [{ event: 'PreToolUse', script: 'hooks/g.js', matcher: 42 }] };
const errors = validateCapability(cap, 'ui');
assert.ok(
errors.some((e) => e.includes('matcher')),
'Expected a matcher type error, got: ' + JSON.stringify(errors),
);
});
test('#1634: a matcher containing control characters is rejected', () => {
const cap = {
...UI_CAP,
hooks: [{ event: 'PreToolUse', script: 'hooks/g.js', matcher: 'Write\n|Edit' }],
};
const errors = validateCapability(cap, 'ui');
assert.ok(
errors.some((e) => e.includes('matcher') && e.includes('control')),
'Expected a control-character matcher error, got: ' + JSON.stringify(errors),
);
});
});
describe('validateAgainstContract adversarial cases', () => {
test('contribution.into not in step agentRoles rejected', () => {
const cap = {
...UI_CAP,
contributions: [
{
point: 'plan:pre',
into: 'notarole',
fragment: { inline: 'test' },
when: 'workflow.ui_phase',
onError: 'skip',
},
],
};
const errors = validateAgainstContract(cap, 'ui');
assert.ok(errors.length > 0);
assert.ok(errors.some((e) => e.includes('notarole')));
});
});
// ─── validateStep / validateAgainstContract: step.pointFrom (#3661, matrix Section E) ──
describe('capability-validator: step.pointFrom (#3661, matrix Section E)', () => {
/**
* Minimal synthetic capability, independent of UI_CAP, carrying one enum
* config key (usable as a pointFrom target) and one boolean key (usable as
* a non-enum negative fixture).
*/
function makePointFromCap(overrides = {}) {
return {
id: 'test-point-from-cap',
role: 'feature',
version: '1.0.0',
title: 'Test PointFrom Cap',
description: 'Synthetic fixture for pointFrom (#3661) validator tests.',
tier: 'standard',
requires: [],
runtimeCompat: { supported: ['*'], unsupported: [] },
skills: ['test-skill'],
agents: [],
hooks: [],
config: {
'workflow.test_point': {
type: 'enum',
values: ['execute:post', 'execute:wave:post'],
default: 'execute:post',
description: 'Test enum key for pointFrom.',
},
'workflow.test_bool': {
type: 'boolean',
default: true,
description: 'Test non-enum key.',
},
},
steps: [],
contributions: [],
gates: [],
...overrides,
};
}
test('E1: validateStepRejectsNonStringPointFrom', () => {
const cap = makePointFromCap({
steps: [{
point: 'execute:post', ref: { skill: 'test-skill' }, produces: [], consumes: [],
pointFrom: 42, onError: 'skip',
}],
});
const errors = validateCapability(cap, 'test-point-from-cap');
assert.ok(
errors.some((e) => e.includes('.pointFrom must be a string if present')),
'Expected a pointFrom type error, got: ' + JSON.stringify(errors),
);
});
test('E2: validateStepAcceptsMissingPointFrom — fully optional', () => {
const cap = makePointFromCap({
steps: [{
point: 'execute:post', ref: { skill: 'test-skill' }, produces: [], consumes: [],
onError: 'skip',
}],
});
const capErrors = validateCapability(cap, 'test-point-from-cap');
assert.deepEqual(capErrors, [], 'Expected no validateCapability errors: ' + JSON.stringify(capErrors));
const contractErrors = validateAgainstContract(cap, 'test-point-from-cap');
assert.deepEqual(contractErrors, [], 'Expected no validateAgainstContract errors: ' + JSON.stringify(contractErrors));
});
test('E3: validateAgainstContractRejectsUndefinedPointFromKey', () => {
const cap = makePointFromCap({
steps: [{
point: 'execute:post', ref: { skill: 'test-skill' }, produces: [], consumes: [],
pointFrom: 'workflow.nonexistent_key', onError: 'skip',
}],
});
const errors = validateAgainstContract(cap, 'test-point-from-cap');
assert.ok(
errors.some((e) => e.includes('pointFrom "workflow.nonexistent_key" is not defined in capability config keys')),
'Expected an undefined-key pointFrom error, got: ' + JSON.stringify(errors),
);
});
test('E4: validateAgainstContractRejectsNonEnumPointFromKey', () => {
const cap = makePointFromCap({
steps: [{
point: 'execute:post', ref: { skill: 'test-skill' }, produces: [], consumes: [],
pointFrom: 'workflow.test_bool', onError: 'skip',
}],
});
const errors = validateAgainstContract(cap, 'test-point-from-cap');
assert.ok(
errors.some((e) => e.includes('must reference an enum config key')),
'Expected a non-enum pointFrom error, got: ' + JSON.stringify(errors),
);
});
test('E5: validateAgainstContractRejectsPointFromEnumMissingOwnPoint', () => {
const cap = makePointFromCap({
steps: [{
point: 'verify:post', ref: { skill: 'test-skill' }, produces: [], consumes: [],
pointFrom: 'workflow.test_point', onError: 'skip',
}],
});
const errors = validateAgainstContract(cap, 'test-point-from-cap');
assert.ok(
errors.some((e) => e.includes('enum values do not include this step') && e.includes('verify:post')),
'Expected an enum-missing-own-point error, got: ' + JSON.stringify(errors),
);
});
test('E6: validateAgainstContractAcceptsWellFormedTwoPointDeclaration — mirrors real code-review shape', () => {
const cap = makePointFromCap({
steps: [
{
point: 'execute:post', ref: { skill: 'test-skill' }, produces: ['REVIEW.md'], consumes: [],
when: 'workflow.test_bool', pointFrom: 'workflow.test_point', onError: 'skip',
},
{
point: 'execute:wave:post', ref: { skill: 'test-skill' }, produces: ['REVIEW.md'], consumes: [],
when: 'workflow.test_bool', pointFrom: 'workflow.test_point', onError: 'skip',
},
],
});
const capErrors = validateCapability(cap, 'test-point-from-cap');
assert.deepEqual(capErrors, [], 'Expected no validateCapability errors: ' + JSON.stringify(capErrors));
const contractErrors = validateAgainstContract(cap, 'test-point-from-cap');
assert.deepEqual(contractErrors, [], 'Expected no validateAgainstContract errors: ' + JSON.stringify(contractErrors));
});
test('E7: validateAgainstContractSkipsAlreadyReportedMalformedPointFrom — non-string pointFrom does not double-report or crash', () => {
// Uses a non-string pointFrom (not an empty string): validateStep's own type
// check is `typeof step.pointFrom !== 'string'`, which an empty string PASSES
// (typeof '' === 'string') — so an empty string is not actually "already
// reported" by validateStep. A non-string value (here: an array) is the
// fixture that genuinely exercises the `continue` / "already reported above"
// skip-pattern inside validateAgainstContract's own pointFrom loop, mirroring
// the identical pattern already used for `when`.
const cap = makePointFromCap({
steps: [{
point: 'execute:post', ref: { skill: 'test-skill' }, produces: [], consumes: [],
pointFrom: ['workflow.test_point'], onError: 'skip',
}],
});
const capErrors = validateCapability(cap, 'test-point-from-cap');
assert.ok(
capErrors.some((e) => e.includes('.pointFrom must be a string if present')),
'validateStep must flag the non-string pointFrom, got: ' + JSON.stringify(capErrors),
);
// validateAgainstContract must not crash and must not ALSO emit a
// "not defined in capability config keys" / "must reference an enum" /
// "enum values do not include" error for the same malformed field — it
// silently skips (continue) because the type error was already reported.
const contractErrors = validateAgainstContract(cap, 'test-point-from-cap');
assert.ok(
!contractErrors.some((e) => e.includes('pointFrom')),
'validateAgainstContract must not double-report an already-malformed pointFrom, got: ' + JSON.stringify(contractErrors),
);
});
});
// ─── validateStep: step.supportsReviewerLanes (#4209 DISP-02) ─────────────────
describe('capability-validator: step.supportsReviewerLanes (#4209 DISP-02)', () => {
/**
* Minimal synthetic non-code-review capability, independent of UI_CAP,
* proving the trait is provider-neutral (not code-review-specific).
*/
function makeReviewerLaneCap(overrides = {}) {
return {
id: 'test-reviewer-lane-cap',
role: 'feature',
version: '1.0.0',
title: 'Test Reviewer Lane Cap',
description: 'Synthetic fixture for supportsReviewerLanes (#4209) validator tests.',
tier: 'standard',
requires: [],
runtimeCompat: { supported: ['*'], unsupported: [] },
skills: ['test-skill'],
agents: [],
hooks: [],
config: {},
steps: [],
contributions: [],
gates: [],
...overrides,
};
}
test('RL1: validateStepAcceptsMissingSupportsReviewerLanes', () => {
const cap = makeReviewerLaneCap({
steps: [{
point: 'execute:post', ref: { skill: 'test-skill' }, produces: [], consumes: [], onError: 'skip',
}],
});
const errors = validateCapability(cap, 'test-reviewer-lane-cap');
assert.deepEqual(errors, [], 'Expected no validateCapability errors: ' + JSON.stringify(errors));
});
test('RL2: validateStepAcceptsLiteralTrue', () => {
const cap = makeReviewerLaneCap({
steps: [{
point: 'execute:post', ref: { skill: 'test-skill' }, produces: [], consumes: [], onError: 'skip',
supportsReviewerLanes: true,
}],
});
const errors = validateCapability(cap, 'test-reviewer-lane-cap');
assert.deepEqual(errors, [], 'Expected no validateCapability errors: ' + JSON.stringify(errors));
});
test('RL3: validateStepAcceptsLiteralFalse', () => {
const cap = makeReviewerLaneCap({
steps: [{
point: 'execute:post', ref: { skill: 'test-skill' }, produces: [], consumes: [], onError: 'skip',
supportsReviewerLanes: false,
}],
});
const errors = validateCapability(cap, 'test-reviewer-lane-cap');
assert.deepEqual(errors, [], 'Expected no validateCapability errors: ' + JSON.stringify(errors));
});
for (const [label, badValue] of [
['string', 'true'],
['number', 1],
['object', {}],
['array', []],
['null', null],
]) {
test(`RL4-${label}: validateStepRejectsNonBoolean`, () => {
const cap = makeReviewerLaneCap({
steps: [{
point: 'execute:post', ref: { skill: 'test-skill' }, produces: [], consumes: [], onError: 'skip',
supportsReviewerLanes: badValue,
}],
});
const errors = validateCapability(cap, 'test-reviewer-lane-cap');
assert.ok(
errors.some((e) => e.includes('steps[0].supportsReviewerLanes must be a boolean if present')),
`Expected a supportsReviewerLanes type error for ${label}, got: ` + JSON.stringify(errors),
);
});
}
test('RL5: registryOptsInBothCodeReviewSteps (real capabilities/code-review/capability.json)', () => {
const codeReviewCapPath = path.join(ROOT, 'capabilities', 'code-review', 'capability.json');
const codeReviewCap = JSON.parse(fs.readFileSync(codeReviewCapPath, 'utf8'));
const errors = validateCapability(codeReviewCap, 'code-review');
assert.deepEqual(errors, [], 'Expected no validateCapability errors: ' + JSON.stringify(errors));
const postStep = codeReviewCap.steps.find((s) => s.point === 'execute:post' && s.ref && s.ref.skill === 'code-review');
const wavePostStep = codeReviewCap.steps.find((s) => s.point === 'execute:wave:post' && s.ref && s.ref.skill === 'code-review');
assert.ok(postStep, 'Expected an execute:post code-review step');
assert.ok(wavePostStep, 'Expected an execute:wave:post code-review step');
assert.strictEqual(postStep.supportsReviewerLanes, true, 'execute:post code-review step must declare supportsReviewerLanes: true');
assert.strictEqual(wavePostStep.supportsReviewerLanes, true, 'execute:wave:post code-review step must declare supportsReviewerLanes: true');
});
});
describe('validateCrossCapability adversarial cases', () => {
test('duplicate skill ownership across two capabilities rejected', () => {
const cap1 = { ...UI_CAP };
const cap2 = {
...UI_CAP,
id: 'ui2',
skills: ['ui-phase'], // duplicate
agents: ['gsd-other-agent'],
config: {},
};
const capMap = new Map([['ui', cap1], ['ui2', cap2]]);
const errors = validateCrossCapability(capMap, new Set());
assert.ok(errors.length > 0);
assert.ok(errors.some((e) => e.includes('ui-phase')));
});
test('requires referencing nonexistent id rejected', () => {
const cap = { ...UI_CAP, requires: ['nonexistent-cap'] };
const capMap = new Map([['ui', cap]]);
const errors = validateCrossCapability(capMap, new Set());
assert.ok(errors.length > 0);
assert.ok(errors.some((e) => e.includes('nonexistent-cap')));
});
test('runtimeCompat explicit runtime ids must exist', () => {
const cap = {
...UI_CAP,
runtimeCompat: { supported: ['claude', 'future-runtime'], unsupported: [] },
};
const runtime = {
id: 'claude',
role: 'runtime',
title: 'Claude',
description: 'Runtime fixture.',
tier: 'core',
requires: [],
runtime: {
configHome: { kind: 'dot-home', name: '.claude', env: ['CLAUDE_CONFIG_DIR'] },
configFormat: 'settings-json',
artifactLayout: { global: [], local: [] },
commandStyle: 'slash-hyphen',
hooksSurface: 'settings-json',
sandboxTier: 'none',
supportTier: 1,
installSurface: 'settings-json',
writesSharedSettings: true,
permissionWriter: null,
extendedHookEvents: [],
},
};
const capMap = new Map([['ui', cap], ['claude', runtime]]);
const errors = validateCrossCapability(capMap, new Set());
assert.ok(
errors.some((e) => e.includes('runtimeCompat.supported') && e.includes('future-runtime')),
'Expected unknown runtimeCompat runtime error, got: ' + JSON.stringify(errors),
);
});
test('requires cycle rejected', () => {
const capA = { ...UI_CAP, id: 'cap-a', tier: 'standard', requires: ['cap-b'] };
const capB = {
id: 'cap-b', role: 'feature', title: 'B', tier: 'standard', requires: ['cap-a'],
skills: [], agents: [], hooks: [], config: {}, steps: [], contributions: [], gates: [],
};
const capMap = new Map([['cap-a', capA], ['cap-b', capB]]);
const errors = validateCrossCapability(capMap, new Set());
assert.ok(errors.length > 0);
assert.ok(errors.some((e) => e.includes('cycle')));
});
test('tier-monotone violation: core requires full rejected', () => {
const coreCap = {
id: 'core-cap', role: 'feature', title: 'Core', tier: 'core', requires: ['full-cap'],
skills: ['core-skill'], agents: ['gsd-core-agent'], hooks: [], config: {},
steps: [], contributions: [], gates: [],
};
const fullCap = {
id: 'full-cap', role: 'feature', title: 'Full', tier: 'full', requires: [],
skills: ['full-skill'], agents: ['gsd-full-agent'], hooks: [], config: {},
steps: [], contributions: [], gates: [],
};
const capMap = new Map([['core-cap', coreCap], ['full-cap', fullCap]]);
const errors = validateCrossCapability(capMap, new Set());
assert.ok(errors.length > 0);
assert.ok(errors.some((e) => e.includes('tier-monotone')));
});
test('config key colliding with central config-schema rejected', () => {
const cap = { ...UI_CAP };
const centralKeys = new Set(['workflow.ui_phase']); // simulate key present in both
const capMap = new Map([['ui', cap]]);
const errors = validateCrossCapability(capMap, centralKeys);
assert.ok(errors.length > 0);
assert.ok(
errors.some((e) => e.includes('workflow.ui_phase') && e.includes('central config-schema')),
'Expected central config-schema collision error, got: ' + JSON.stringify(errors),
);
});
test('config key owned by two capabilities rejected', () => {
const cap1 = { ...UI_CAP };
const cap2 = {
id: 'ui2', role: 'feature', title: 'UI2', tier: 'standard', requires: [],
skills: ['other-skill'], agents: ['gsd-other-agent'], hooks: [],
config: { 'workflow.ui_phase': { type: 'boolean', default: true, description: 'dup' } },
steps: [], contributions: [], gates: [],
};
const capMap = new Map([['ui', cap1], ['ui2', cap2]]);
const errors = validateCrossCapability(capMap, new Set());
assert.ok(errors.length > 0);
assert.ok(errors.some((e) => e.includes('workflow.ui_phase')));
});
});
// ─── 3. Materialized ordering ─────────────────────────────────────────────────
describe('topological step ordering', () => {
test('two steps at one point with produces/consumes dependency order correctly', () => {
// Step B consumes what step A produces → A must come before B
const stepA = {
capId: 'cap-a',
step: { point: 'plan:pre', ref: { skill: 'a-skill' }, produces: ['A-OUTPUT.md'], consumes: [], when: undefined, onError: 'skip' },
};
const stepB = {
capId: 'cap-b',
step: { point: 'plan:pre', ref: { skill: 'b-skill' }, produces: ['B-OUTPUT.md'], consumes: ['A-OUTPUT.md'], when: undefined, onError: 'skip' },
};
// Pass in reverse order to verify sort happens
const sorted = topoSortSteps([stepB, stepA]);
assert.strictEqual(sorted[0].capId, 'cap-a', 'cap-a (producer) should come first');
assert.strictEqual(sorted[1].capId, 'cap-b', 'cap-b (consumer) should come second');
});
test('steps with no dependency order by capId tiebreak', () => {
const stepZ = {
capId: 'z-cap',
step: { point: 'plan:pre', ref: { skill: 'z' }, produces: ['Z.md'], consumes: [], when: undefined, onError: 'skip' },
};
const stepA = {
capId: 'a-cap',
step: { point: 'plan:pre', ref: { skill: 'a' }, produces: ['A.md'], consumes: [], when: undefined, onError: 'skip' },
};
const sorted = topoSortSteps([stepZ, stepA]);
assert.strictEqual(sorted[0].capId, 'a-cap', 'a-cap should come first (alphabetical tiebreak)');
assert.strictEqual(sorted[1].capId, 'z-cap');
});
test('contributions at one point use produces/consumes dependency order', () => {
const capMap = new Map([
['a-consumer', {
id: 'a-consumer',
role: 'feature',
title: 'Consumer',
tier: 'full',
requires: [],
runtimeCompat: { supported: ['*'], unsupported: [] },
skills: [],
agents: [],
hooks: [],
config: {},
steps: [],
contributions: [{
point: 'plan:pre',
into: 'planner',
fragment: { inline: 'Consume produced planning note.' },
produces: [],
consumes: ['PLAN-NOTE.md'],
onError: 'skip',
}],
gates: [],
}],
['b-producer', {
id: 'b-producer',
role: 'feature',
title: 'Producer',
tier: 'full',
requires: [],
skills: [],
agents: [],
hooks: [],
config: {},
steps: [],
contributions: [{
point: 'plan:pre',
into: 'planner',
fragment: { inline: 'Produce planning note.' },
produces: ['PLAN-NOTE.md'],
consumes: [],
onError: 'skip',
}],
gates: [],
}],
]);
const registry = buildRegistry(capMap);
assert.deepEqual(
registry.byLoopPoint['plan:pre'].contributions.map((c) => c.capId),
['b-producer', 'a-consumer'],
);
});
test('contribution produces/consumes cycle throws a clear error', () => {
const capMap = new Map([
['cap-a', {
id: 'cap-a',
role: 'feature',
title: 'A',
tier: 'full',
requires: [],
skills: [],
agents: [],
hooks: [],
config: {},
steps: [],
contributions: [{
point: 'plan:pre',
into: 'planner',
fragment: { inline: 'A.' },
produces: ['A.md'],
consumes: ['B.md'],
onError: 'skip',
}],
gates: [],
}],
['cap-b', {
id: 'cap-b',
role: 'feature',
title: 'B',
tier: 'full',
requires: [],
skills: [],
agents: [],
hooks: [],
config: {},
steps: [],
contributions: [{
point: 'plan:pre',
into: 'planner',
fragment: { inline: 'B.' },
produces: ['B.md'],
consumes: ['A.md'],
onError: 'skip',
}],
gates: [],
}],
]);
assert.throws(
() => buildRegistry(capMap),
(err) => {
assert.ok(err instanceof Error);
assert.match(err.message, /contributions/);
assert.match(err.message, /cycle/);
return true;
},
);
});
});
// ─── 4. --check drift detection ──────────────────────────────────────────────
//
// The real --check pipeline (gen-capability-registry.cjs main()) compares
// committed vs live via:
//
// normalizeLineEndings(stripGeneratedComment(committed))
// !== normalizeLineEndings(stripGeneratedComment(live))
//
// stripGeneratedComment is private (not exported), so these tests replicate the
// same filter inline and call the exported normalizeLineEndings to exercise the
// ACTUAL comparison semantics rather than doing a bare string-equality tautology.
//
// The subprocess tests additionally prove that the real --check CLI exits 1 on a
// tampered registry and exits 0 when only the auto-generated timestamp comment
// changes (comment immunity).
const REGISTRY_PATH = path.join(ROOT, 'gsd-core', 'bin', 'lib', 'capability-registry.cjs');
/**
* Mirror of the private stripGeneratedComment() from gen-capability-registry.cjs.
* Kept here intentionally: the test validates BEHAVIOR, and the implementation is
* stable (a single-line filter). If the source changes the sentinel string, this
* test will correctly start failing — that is the desired red signal.
*
* NOTE (#1191): The equivalence test below pins the exported stripGeneratedComment
* to this mirror. If the export's sentinel ever drifts from the mirror's sentinel,
* the equivalence test goes RED — making the implicit "sentinel drift = red signal"
* comment above into an explicit automated check.
*/
function applyStripGeneratedComment(content) {
return content
.split('\n')
.filter((line) => !line.includes('generated by scripts/gen-capability-registry.cjs'))
.join('\n');
}
/** Apply the full --check comparison pipeline to a single content string. */
function checkPipeline(content) {
return normalizeLineEndings(applyStripGeneratedComment(content));
}
// ─── Seam-3 equivalence test (#1191) ─────────────────────────────────────────
//
// Verifies that the now-exported stripGeneratedComment from gen-capability-registry.cjs
// matches the local oracle (applyStripGeneratedComment) on a representative sample.
// This turns the oracle's implicit "fails if sentinel drifts" into an explicit guard.
describe('exported stripGeneratedComment matches the test oracle (no sentinel drift)', () => {
test('exported stripGeneratedComment matches the test oracle (no sentinel drift)', () => {
const sample = [
'// generated by scripts/gen-capability-registry.cjs — DO NOT EDIT',
"'use strict';",
'// normal comment (not a generated-by line)',
"module.exports = { version: '1' };",
'// generated by scripts/gen-capability-registry.cjs (second occurrence)',
].join('\n');
assert.strictEqual(
stripGeneratedComment(sample),
applyStripGeneratedComment(sample),
'exported stripGeneratedComment must produce identical output to the test oracle — ' +
'a sentinel mismatch means the export and the oracle have drifted'
);
});
});
describe('--check drift detection', () => {
test('stale VERSION survives stripGeneratedComment+normalizeLineEndings and IS detected as drift', () => {
// Build a fresh registry from the real UI cap — this is the "live" content
const capDir = makeTempCapDir({ ui: UI_CAP });
const { capMap } = loadAndValidate(new Set(), capDir);
const registry = buildRegistry(capMap);
const liveContent = serializeRegistry(registry, capMap);
// Tamper: replace the schema version field — this simulates a stale committed file
// (version: '1' → version: '0-stale')
const staledContent = liveContent.replace(
"version: '" + SCHEMA_VERSION + "'",
"version: '0-stale'",
);
assert.notStrictEqual(staledContent, liveContent, 'precondition: tampered content differs before pipeline');
// The tampered version must SURVIVE both pipeline steps and still differ from live.
// This is what actually matters: a raw string diff is trivial; the test must show
// the comparison survives stripping + normalization — i.e. it IS real drift.
assert.notStrictEqual(
checkPipeline(staledContent),
checkPipeline(liveContent),
'stale VERSION must be detected as drift after stripGeneratedComment + normalizeLineEndings',
);
});
test('comment-only timestamp change is NOT flagged as drift after stripping', () => {
// Build a fresh registry
const capDir = makeTempCapDir({ ui: UI_CAP });
const { capMap } = loadAndValidate(new Set(), capDir);
const registry = buildRegistry(capMap);
const liveContent = serializeRegistry(registry, capMap);
// Confirm the generated comment is present in the serialized output
assert.ok(
liveContent.includes('generated by scripts/gen-capability-registry.cjs'),
'precondition: generated comment must be present in serialized output',
);
// Simulate a Windows git checkout that adds a fake timestamp annotation on the
// generated-comment line — the kind of comment-only mutation that must NOT trigger drift
const commentVariant = liveContent.replace(
' * capability-registry.cjs — generated by scripts/gen-capability-registry.cjs',
' * capability-registry.cjs — generated by scripts/gen-capability-registry.cjs on 2024-01-01T00:00:00Z',
);
assert.notStrictEqual(commentVariant, liveContent, 'precondition: variant differs before stripping');
// After stripping the generated-comment line, both must be identical — NOT flagged as drift
assert.strictEqual(
checkPipeline(commentVariant),
checkPipeline(liveContent),
'comment-only change must NOT be detected as drift (stripGeneratedComment must neutralize it)',
);
});
test('no drift when registry is freshly generated', () => {
// Determinism check: two calls to serializeRegistry must produce identical output
const capDir = makeTempCapDir({ ui: UI_CAP });
const { capMap } = loadAndValidate(new Set(), capDir);
const registry = buildRegistry(capMap);
const content1 = serializeRegistry(registry, capMap);
const content2 = serializeRegistry(registry, capMap);
assert.strictEqual(content1, content2, 'Two calls to serializeRegistry should be identical');
});
test('--check comparison pipeline detects a tampered VERSION (in-memory, no file mutation)', () => {
// Prove that the --check comparison pipeline (the same pipeline used by
// gen-capability-registry.cjs main()) exits 1 on a stale committed registry.
//
// NOTE: We intentionally do NOT write to the committed REGISTRY_PATH here.
// Writing to a committed file during a test is unsafe: it races with concurrent
// test runners that require() the same module and leaves the worktree dirty on
// SIGKILL. Instead, we exercise the comparison logic using the same exported
// helpers the CLI uses, applied to in-memory strings — giving identical coverage
// without touching the filesystem.
const originalContent = fs.readFileSync(REGISTRY_PATH, 'utf8');
// allow-test-rule: source-text-is-the-product (#3545) — checkPipeline() below is a
// raw-text diffing pipeline; this .replace() builds an in-memory tampered
// TEXT fixture to drive that real pipeline call, not a text-grep proxy for
// module behavior
const tamperedContent = originalContent.replace(
"version: '" + SCHEMA_VERSION + "'",
"version: '0-stale'",
);
assert.notStrictEqual(tamperedContent, originalContent, 'precondition: tamper must change the file');
// Build the "live" content the same way --check does.
const capDir = makeTempCapDir({ ui: UI_CAP });
const { capMap } = loadAndValidate(new Set(), capDir);
const registry = buildRegistry(capMap);
const liveContent = serializeRegistry(registry, capMap);
// The tampered committed content must NOT equal the live content after the
// same stripGeneratedComment + normalizeLineEndings pipeline that --check uses.
// If this assertion passes, --check would exit 1 (drift detected) and emit "stale".
assert.notStrictEqual(
checkPipeline(tamperedContent),
checkPipeline(liveContent),
'--check comparison pipeline must flag a tampered VERSION as drift.\n' +
'If this fails, the pipeline no longer detects stale VERSION strings.',
);
// Also verify the tampered content contains the stale marker (so the above
// assertion is meaningful and not vacuously true due to other diff).
assert.ok(
// allow-test-rule: source-text-is-the-product (#3545) — sanity check on the
// same in-memory tampered TEXT fixture, not a proxy for module behavior
tamperedContent.includes("version: '0-stale'"),
'precondition: tampered content must contain the stale version marker',
);
});
});
// ─── 4b. normalizeLineEndings — Windows CRLF regression guard ────────────────
describe('normalizeLineEndings', () => {
test('strips \\r so LF and CRLF content compare as equal', () => {
const lf = 'line1\nline2\nline3\n';
const crlf = 'line1\r\nline2\r\nline3\r\n';
assert.strictEqual(
normalizeLineEndings(lf),
normalizeLineEndings(crlf),
'LF and CRLF variants should normalize to the same string',
);
});
test('standalone \\r (old Mac line endings) is also stripped', () => {
const cr = 'line1\rline2\r';
const lf = 'line1\nline2\n';
assert.notStrictEqual(normalizeLineEndings(cr), normalizeLineEndings(lf),
'standalone CR collapses differently from LF — only \\r is stripped, not newlines added');
// The key property: \\r is gone
assert.ok(!normalizeLineEndings(cr).includes('\r'), 'result must not contain \\r');
});
test('real registry content: CRLF variant compares equal to LF variant after normalization', () => {
const capDir = makeTempCapDir({ ui: UI_CAP });
const { capMap } = loadAndValidate(new Set(), capDir);
const registry = buildRegistry(capMap);
const lfContent = serializeRegistry(registry, capMap);
// Simulate Windows git checkout by converting LF -> CRLF
const crlfContent = lfContent.replace(/\n/g, '\r\n');
assert.notStrictEqual(lfContent, crlfContent, 'CRLF and LF versions are byte-different');
assert.strictEqual(
normalizeLineEndings(lfContent),
normalizeLineEndings(crlfContent),
'--check must treat CRLF-checked-out registry as up to date (Windows autocrlf regression guard)',
);
});
});
// ─── 5. Registry shape from multiple capabilities ────────────────────────────
describe('registry structure', () => {
test('byLoopPoint contains all 12 valid points', () => {
const capDir = makeTempCapDir({ ui: UI_CAP });
const { capMap } = loadAndValidate(new Set(), capDir);
const registry = buildRegistry(capMap);
const expectedPoints = [
'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 point of expectedPoints) {
assert.ok(
Object.prototype.hasOwnProperty.call(registry.byLoopPoint, point),
'byLoopPoint should contain point: ' + point,
);
}
});
test('requiresClosure works for a cap with transitive requires', () => {
const capA = {
id: 'cap-a', role: 'feature', title: 'A', tier: 'standard', requires: ['cap-b'],
skills: ['a-skill'], agents: ['gsd-a-agent'], hooks: [], config: {},
steps: [], contributions: [], gates: [],
};
const capB = {
id: 'cap-b', role: 'feature', title: 'B', tier: 'standard', requires: ['cap-c'],
skills: ['b-skill'], agents: ['gsd-b-agent'], hooks: [], config: {},
steps: [], contributions: [], gates: [],
};
const capC = {
id: 'cap-c', role: 'feature', title: 'C', tier: 'standard', requires: [],
skills: ['c-skill'], agents: ['gsd-c-agent'], hooks: [], config: {},
steps: [], contributions: [], gates: [],
};
const capMap = new Map([['cap-a', capA], ['cap-b', capB], ['cap-c', capC]]);
const closure = computeRequiresClosure('cap-a', capMap);
assert.ok(closure.has('cap-b'), 'closure should include cap-b');
assert.ok(closure.has('cap-c'), 'closure should include cap-c (transitive)');
assert.strictEqual(closure.size, 2);
});
});
describe('ADR-857 phase 6 planning feature capabilities', () => {
const realRegistry = require('../gsd-core/bin/lib/capability-registry.cjs');
test('real registry declares research, ai-integration, and pattern-mapper capabilities', () => {
for (const capId of ['research', 'ai-integration', 'pattern-mapper']) {
assert.ok(realRegistry.capabilities[capId], `${capId} capability must be declared`);
assert.strictEqual(realRegistry.capabilities[capId].role, 'feature');
}
});
test('planning feature capabilities own their workflow config keys', () => {
assert.strictEqual(realRegistry.configKeys['workflow.research'], 'research');
assert.strictEqual(realRegistry.configKeys['workflow.ai_integration_phase'], 'ai-integration');
assert.strictEqual(realRegistry.configKeys['workflow.pattern_mapper'], 'pattern-mapper');
});
test('planning feature capabilities register plan:pre hooks', () => {
const hooks = [
...realRegistry.byLoopPoint['plan:pre'].steps,
...realRegistry.byLoopPoint['plan:pre'].contributions,
...realRegistry.byLoopPoint['plan:pre'].gates,
];
for (const capId of ['research', 'ai-integration', 'pattern-mapper']) {
assert.ok(
hooks.some((hook) => hook.capId === capId),
`${capId} must participate in plan:pre through the Capability Registry`,
);
}
});
});
describe('#3778 — plan:pre contribution set feeding the quick.md planner dispatch', () => {
test('Quick host registration is generator-owned without changing canonical contract shape', () => {
const plan = STEP_WORKFLOWS.find((workflow) => workflow.step === 'plan');
assert.deepEqual(plan.auxiliaryHosts, [
{ file: 'quick.md', point: 'plan:pre', kinds: ['contribution'], into: 'planner' },
]);
assert.ok(
HOST_LOOP_FILES.includes('gsd-core/workflows/quick.md'),
'Quick must be enumerated by the generator-owned host set',
);
const contract = buildContract();
assert.strictEqual(STEP_WORKFLOWS.length, 5, 'canonical step rows must stay at five');
assert.strictEqual(contract.length, 5, 'serialized contract must stay at five entries');
assert.deepEqual(contract, LOOP_HOST_CONTRACT, 'auxiliary metadata must not be serialized');
const points = contract.flatMap((entry) => entry.points);
assert.strictEqual(points.length, 12, 'serialized contract must stay at 12 points');
assert.strictEqual(new Set(points).size, 12, 'serialized lifecycle points must remain unique');
});
});
// ─── 6. Fix regression guards ────────────────────────────────────────────────
describe('Fix #1: consumes-satisfiability is point-order-aware', () => {
test('plan:pre step consuming UAT.md (produced only at verify:post) is rejected', () => {
// UAT.md is produced by the host at verify:post (C1: :post availability rule).
// A plan:pre step consuming it must fail — the host hasn't produced it yet at that point.
const cap = {
...UI_CAP,
steps: [
{
point: 'plan:pre',
ref: { skill: 'ui-phase' },
produces: [],
consumes: ['UAT.md'], // UAT.md not available until verify:post
when: 'workflow.ui_phase',
onError: 'skip',
},
],
};
// C2: consumes validation is now global
const capMap = new Map([['ui', cap]]);
const errors = validateConsumesGlobal(capMap);
assert.ok(errors.length > 0, 'Expected a satisfiability error for early consumption of UAT.md');
assert.ok(
errors.some((e) => e.includes('UAT.md')),
'Error should mention UAT.md, got: ' + JSON.stringify(errors),
);
assert.ok(
errors.some((e) => e.includes('plan:pre')),
'Error should mention plan:pre, got: ' + JSON.stringify(errors),
);
});
test('verify:post step consuming UAT.md (produced at verify:post by host) is accepted', () => {
// C1: UAT.md becomes available from verify:post onward (produced by the verify host step).
// verify:post index (9) <= verify:post index (9) → accepted.
const cap = {
...UI_CAP,
steps: [
{
point: 'verify:post',
ref: { skill: 'ui-review' },
produces: ['UI-REVIEW.md'],
consumes: ['UAT.md'],
when: 'workflow.ui_review',
onError: 'skip',
},
],
};
// C2: consumes validation is now global
const capMap = new Map([['ui', cap]]);
const errors = validateConsumesGlobal(capMap);
// Should have zero satisfiability errors for UAT.md at verify:post
const satErrors = errors.filter((e) => e.includes('UAT.md'));
assert.deepEqual(satErrors, [], 'Expected no satisfiability errors for UAT.md at verify:post, got: ' + JSON.stringify(satErrors));
});
// C1 regression: PLAN.md is produced at plan:post, NOT plan:pre
test('plan:pre step consuming PLAN.md is rejected (PLAN.md only available from plan:post)', () => {
const cap = {
...UI_CAP,
steps: [
{
point: 'plan:pre',
ref: { skill: 'ui-phase' },
produces: [],
consumes: ['PLAN.md'], // PLAN.md produced at plan:post, not available at plan:pre
when: 'workflow.ui_phase',
onError: 'skip',
},
],
};
const capMap = new Map([['ui', cap]]);
const errors = validateConsumesGlobal(capMap);
assert.ok(errors.length > 0, 'Expected rejection: PLAN.md not available at plan:pre');
assert.ok(errors.some((e) => e.includes('PLAN.md')), 'Error should mention PLAN.md');
});
// C1: execute:pre consuming PLAN.md → PLAN.md available at plan:post (index 3), execute:pre is index 4 → accepted
test('execute:pre step consuming PLAN.md (produced at plan:post) is accepted', () => {
const cap = {
...UI_CAP,
steps: [
{
point: 'execute:pre',
ref: { skill: 'ui-phase' },
produces: [],
consumes: ['PLAN.md'], // PLAN.md available from plan:post onward
when: 'workflow.ui_phase',
onError: 'skip',
},
],
};
const capMap = new Map([['ui', cap]]);
const errors = validateConsumesGlobal(capMap);
const satErrors = errors.filter((e) => e.includes('PLAN.md'));
assert.deepEqual(satErrors, [], 'Expected PLAN.md to be available at execute:pre, got: ' + JSON.stringify(satErrors));
});
});
describe('Fix #2: topoSortSteps errors on a produces/consumes cycle', () => {
test('two-step cycle at the same point throws an error', () => {
// Step A produces X and consumes Y; step B produces Y and consumes X — mutual dependency
const stepA = {
capId: 'cap-a',
step: {
point: 'plan:pre',
ref: { skill: 'a-skill' },
produces: ['X.md'],
consumes: ['Y.md'],
onError: 'skip',
},
};
const stepB = {
capId: 'cap-b',
step: {
point: 'plan:pre',
ref: { skill: 'b-skill' },
produces: ['Y.md'],
consumes: ['X.md'],
onError: 'skip',
},
};
assert.throws(
() => topoSortSteps([stepA, stepB]),
(err) => {
assert.ok(err instanceof Error, 'Should throw an Error');
assert.ok(
err.message.includes('cycle') || err.message.includes('cycle'),
'Error message should mention cycle, got: ' + err.message,
);
return true;
},
);
});
});
describe('Fix #3: config-collision emits pending-migration warning, not hard error', () => {
test('validateCrossCapability still detects and reports the collision', () => {
// The underlying collision detection must still fire (regression guard for existing test)
const cap = { ...UI_CAP };
const centralKeys = new Set(['workflow.ui_phase']);
const capMap = new Map([['ui', cap]]);
const errors = validateCrossCapability(capMap, centralKeys);
assert.ok(errors.length > 0, 'Expected collision errors from validateCrossCapability');
assert.ok(
errors.some((e) => e.includes('workflow.ui_phase') && e.includes('central config-schema')),
'Expected central config-schema collision error, got: ' + JSON.stringify(errors),
);
});
test('classifyCrossErrors separates collision errors into pending-migration warnings', () => {
const cap = { ...UI_CAP };
const centralKeys = new Set(['workflow.ui_phase', 'workflow.ui_review', 'workflow.ui_safety_gate']);
const capMap = new Map([['ui', cap]]);
const allErrors = validateCrossCapability(capMap, centralKeys);
const { hardErrors, pendingMigrationWarnings } = classifyCrossErrors(allErrors);
// All three collision errors should become warnings, not hard errors
assert.strictEqual(
hardErrors.length, 0,
'No hard errors expected for collision-only cross errors, got: ' + JSON.stringify(hardErrors),
);
assert.ok(
pendingMigrationWarnings.length >= 1,
'Expected at least one pending-migration warning',
);
assert.ok(
pendingMigrationWarnings.some((w) => w.includes('pending-migration') && w.includes('workflow.ui_phase')),
'Warning should mention pending-migration and workflow.ui_phase, got: ' + JSON.stringify(pendingMigrationWarnings),
);
});
});
describe('Fix #4: step.ref must be exclusive skill XOR agent', () => {
test('step.ref with both skill and agent is rejected', () => {
const cap = {
...UI_CAP,
steps: [
{
point: 'plan:pre',
ref: { skill: 'ui-phase', agent: 'gsd-ui-checker' }, // BOTH keys — invalid
produces: ['UI-SPEC.md'],
consumes: ['CONTEXT.md'],
when: 'workflow.ui_phase',
onError: 'skip',
},
],
};
const errors = validateCapability(cap, 'ui');
assert.ok(errors.length > 0, 'Expected errors for step.ref with both skill and agent');
assert.ok(
errors.some((e) => e.includes('exactly one') || e.includes('not both') || e.includes('skill') && e.includes('agent')),
'Error should mention exclusive skill/agent constraint, got: ' + JSON.stringify(errors),
);
});
test('step.ref with only skill is accepted', () => {
const cap = {
...UI_CAP,
steps: [
{
point: 'plan:pre',
ref: { skill: 'ui-phase' },
produces: ['UI-SPEC.md'],
consumes: ['CONTEXT.md'],
when: 'workflow.ui_phase',
onError: 'skip',
},
],
};
const refErrors = validateCapability(cap, 'ui').filter((e) => e.includes('ref'));
assert.deepEqual(refErrors, [], 'No ref errors expected for skill-only ref, got: ' + JSON.stringify(refErrors));
});
test('step.ref with only agent is accepted', () => {
const cap = {
...UI_CAP,
steps: [
{
point: 'plan:pre',
ref: { agent: 'gsd-ui-checker' },
produces: ['UI-SPEC.md'],
consumes: ['CONTEXT.md'],
when: 'workflow.ui_phase',
onError: 'skip',
},
],
};
const refErrors = validateCapability(cap, 'ui').filter((e) => e.includes('ref'));
assert.deepEqual(refErrors, [], 'No ref errors expected for agent-only ref, got: ' + JSON.stringify(refErrors));
});
});
describe('double-prefix guard: step.ref.skill must not start with "gsd-"', () => {
// ref.skill is an unprefixed stem (e.g. "ui-review"). Workflow dispatch prepends
// "gsd-" at runtime. A stem already starting with "gsd-" would produce "gsd-gsd-..."
// at dispatch time, silently invoking a non-existent skill.
test('ref.skill starting with "gsd-" is rejected', () => {
const cap = {
...UI_CAP,
steps: [
{
point: 'verify:post',
ref: { skill: 'gsd-ui-review' }, // wrong: stem must NOT have gsd- prefix
produces: ['UI-REVIEW.md'],
consumes: ['UI-SPEC.md'],
when: 'workflow.ui_review',
onError: 'skip',
},
],
};
const errors = validateCapability(cap, 'ui');
assert.ok(errors.length > 0, 'Expected an error for gsd-prefixed ref.skill');
assert.ok(
errors.some((e) => e.includes('gsd-') && (e.includes('double') || e.includes('unprefixed') || e.includes('must not start'))),
'Error should mention the double-prefix problem, got: ' + JSON.stringify(errors),
);
});
test('ref.skill without "gsd-" prefix is accepted (stem only)', () => {
const cap = {
...UI_CAP,
steps: [
{
point: 'verify:post',
ref: { skill: 'ui-review' }, // correct: unprefixed stem
produces: ['UI-REVIEW.md'],
consumes: ['UI-SPEC.md'],
when: 'workflow.ui_review',
onError: 'skip',
},
],
};
const prefixErrors = validateCapability(cap, 'ui').filter((e) => e.includes('gsd-') && e.includes('stem'));
assert.deepEqual(prefixErrors, [], 'No prefix errors expected for unprefixed stem, got: ' + JSON.stringify(prefixErrors));
});
test('real UI capability.json uses unprefixed ref.skill values', () => {
// Verify the live capability uses unprefixed stems and therefore passes the new guard.
const errors = validateCapability(UI_CAP, 'ui');
const prefixErrors = errors.filter((e) => e.includes('must not start with'));
assert.deepEqual(prefixErrors, [], 'Live UI capability.json should not trigger the double-prefix guard: ' + JSON.stringify(prefixErrors));
});
});
// ─── Fix: ref.skill/ref.agent membership in declared skills/agents ────────────
describe('ref membership check: step.ref.skill must be in cap.skills', () => {
// A capability declares skills: ["ui-phase", "ui-review"].
// A step with ref.skill "typo-skill" (not in skills) must be rejected.
test('step.ref.skill NOT in cap.skills is rejected', () => {
const cap = {
...UI_CAP,
steps: [
{
point: 'plan:pre',
ref: { skill: 'typo-skill' }, // not in skills: ["ui-phase", "ui-review"]
produces: ['UI-SPEC.md'],
consumes: ['CONTEXT.md'],
when: 'workflow.ui_phase',
onError: 'skip',
},
],
};
const errors = validateCapability(cap, 'ui');
assert.ok(errors.length > 0, 'Expected errors for undeclared ref.skill');
assert.ok(
errors.some((e) => e.includes('typo-skill') && e.includes('not declared')),
'Error should mention "typo-skill" and "not declared", got: ' + JSON.stringify(errors),
);
});
test('step.ref.skill IN cap.skills is accepted', () => {
const cap = {
...UI_CAP,
steps: [
{
point: 'plan:pre',
ref: { skill: 'ui-phase' }, // declared in skills: ["ui-phase", "ui-review"]
produces: ['UI-SPEC.md'],
consumes: ['CONTEXT.md'],
when: 'workflow.ui_phase',
onError: 'skip',
},
],
};
const errors = validateCapability(cap, 'ui');
const membershipErrors = errors.filter((e) => e.includes('not declared') && e.includes('ui-phase'));
assert.deepEqual(
membershipErrors, [],
'No membership errors expected for declared ref.skill, got: ' + JSON.stringify(membershipErrors),
);
});
test('real UI capability passes: ui-phase and ui-review are both in skills', () => {
// Regression guard: the real UI capability must not trigger the new membership check.
const errors = validateCapability(UI_CAP, 'ui');
const membershipErrors = errors.filter((e) => e.includes('not declared'));
assert.deepEqual(
membershipErrors, [],
'Real UI capability should pass membership check for all ref.skill values, got: ' + JSON.stringify(membershipErrors),
);
});
});
describe('ref membership check: step.ref.agent must be in cap.agents', () => {
// A capability declares agents: ["gsd-ui-checker", "gsd-ui-auditor"].
// A step with ref.agent "gsd-unknown-agent" (not in agents) must be rejected.
test('step.ref.agent NOT in cap.agents is rejected', () => {
const cap = {
...UI_CAP,
steps: [
{
point: 'plan:pre',
ref: { agent: 'gsd-unknown-agent' }, // not in agents: ["gsd-ui-checker", "gsd-ui-auditor"]
produces: ['UI-SPEC.md'],
consumes: ['CONTEXT.md'],
when: 'workflow.ui_phase',
onError: 'skip',
},
],
};
const errors = validateCapability(cap, 'ui');
assert.ok(errors.length > 0, 'Expected errors for undeclared ref.agent');
assert.ok(
errors.some((e) => e.includes('gsd-unknown-agent') && e.includes('not declared')),
'Error should mention "gsd-unknown-agent" and "not declared", got: ' + JSON.stringify(errors),
);
});
test('step.ref.agent IN cap.agents is accepted', () => {
const cap = {
...UI_CAP,
steps: [
{
point: 'plan:pre',
ref: { agent: 'gsd-ui-checker' }, // declared in agents
produces: ['UI-SPEC.md'],
consumes: ['CONTEXT.md'],
when: 'workflow.ui_phase',
onError: 'skip',
},
],
};
const errors = validateCapability(cap, 'ui');
const membershipErrors = errors.filter((e) => e.includes('not declared') && e.includes('gsd-ui-checker'));
assert.deepEqual(
membershipErrors, [],
'No membership errors expected for declared ref.agent, got: ' + JSON.stringify(membershipErrors),
);
});
});
describe('Fix: 3-node requires cycle (A→B→C→A) is detected', () => {
test('three-node requires cycle is reported as an error', () => {
const capA = {
id: 'cyc-a', role: 'feature', title: 'CycA', tier: 'standard', requires: ['cyc-b'],
skills: ['cyc-a-skill'], agents: ['gsd-cyc-a'], hooks: [], config: {},
steps: [], contributions: [], gates: [],
};
const capB = {
id: 'cyc-b', role: 'feature', title: 'CycB', tier: 'standard', requires: ['cyc-c'],
skills: ['cyc-b-skill'], agents: ['gsd-cyc-b'], hooks: [], config: {},
steps: [], contributions: [], gates: [],
};
const capC = {
id: 'cyc-c', role: 'feature', title: 'CycC', tier: 'standard', requires: ['cyc-a'],
skills: ['cyc-c-skill'], agents: ['gsd-cyc-c'], hooks: [], config: {},
steps: [], contributions: [], gates: [],
};
const capMap = new Map([['cyc-a', capA], ['cyc-b', capB], ['cyc-c', capC]]);
const errors = validateCrossCapability(capMap, new Set());
assert.ok(errors.length > 0, 'Expected cycle errors for A→B→C→A');
assert.ok(
errors.some((e) => e.toLowerCase().includes('cycle')),
'Error should mention cycle, got: ' + JSON.stringify(errors),
);
});
});
describe('Fix: agentVerdict gate with blocking:false is accepted', () => {
test('agentVerdict gate with blocking:false generates zero errors', () => {
// Complement of the existing blocking:true rejection test
const cap = {
...UI_CAP,
gates: [
{
point: 'execute:wave:post',
check: { agentVerdict: { ref: 'gsd-ui-checker', prompt: 'check ui' } },
blocking: false, // advisory — valid
onError: 'skip',
},
],
};
const errors = validateCapability(cap, 'ui');
const gateErrors = errors.filter((e) => e.includes('agentVerdict'));
assert.deepEqual(
gateErrors, [],
'Expected no agentVerdict errors for blocking:false, got: ' + JSON.stringify(gateErrors),
);
});
});
// ─── 7. Security: fragment.path traversal (S1) ───────────────────────────────
describe('S1: fragment.path traversal guard', () => {
const makeCapWithContribPath = (fragPath) => ({
...UI_CAP,
contributions: [
{
point: 'plan:pre',
into: 'planner',
fragment: { path: fragPath },
when: 'workflow.ui_phase',
onError: 'skip',
},
],
});
test('fragment.path with ".." segments is rejected', () => {
const errors = validateCapability(makeCapWithContribPath('../../etc/passwd'), 'ui');
assert.ok(errors.length > 0, 'Expected rejection for path traversal');
assert.ok(
errors.some((e) => e.includes('fragment.path') && e.includes('..')),
'Error should mention fragment.path traversal, got: ' + JSON.stringify(errors),
);
});
test('absolute fragment.path is rejected', () => {
const errors = validateCapability(makeCapWithContribPath('/etc/passwd'), 'ui');
assert.ok(errors.length > 0, 'Expected rejection for absolute path');
assert.ok(
errors.some((e) => e.includes('fragment.path')),
'Error should mention fragment.path, got: ' + JSON.stringify(errors),
);
});
test('clean relative fragment.path is accepted', () => {
const errors = validateCapability(makeCapWithContribPath('loop/threat-model.md'), 'ui');
const pathErrors = errors.filter((e) => e.includes('fragment.path'));
assert.deepEqual(pathErrors, [], 'Expected no path errors for clean relative path, got: ' + JSON.stringify(pathErrors));
});
test('empty fragment.path string is rejected', () => {
const errors = validateCapability(makeCapWithContribPath(''), 'ui');
assert.ok(errors.length > 0, 'Expected rejection for empty path');
assert.ok(errors.some((e) => e.includes('fragment.path')));
});
test('array fragment shape is rejected', () => {
const cap = {
...UI_CAP,
contributions: [
{
point: 'plan:pre',
into: 'planner',
fragment: [],
when: 'workflow.ui_phase',
onError: 'skip',
},
],
};
const errors = validateCapability(cap, 'ui');
assert.ok(errors.some((e) => e.includes('fragment') && e.includes('object')));
});
test('non-string fragment.inline is rejected', () => {
const cap = {
...UI_CAP,
contributions: [
{
point: 'plan:pre',
into: 'planner',
fragment: { inline: 42 },
when: 'workflow.ui_phase',
onError: 'skip',
},
],
};
const errors = validateCapability(cap, 'ui');
assert.ok(errors.some((e) => e.includes('fragment.inline') && e.includes('string')));
});
test('empty fragment.inline string is rejected', () => {
const cap = {
...UI_CAP,
contributions: [
{
point: 'plan:pre',
into: 'planner',
fragment: { inline: '' },
when: 'workflow.ui_phase',
onError: 'skip',
},
],
};
const errors = validateCapability(cap, 'ui');
assert.ok(errors.some((e) => e.includes('fragment.inline') && e.includes('non-empty')));
});
test('non-array contribution produces is rejected', () => {
const cap = {
...UI_CAP,
contributions: [
{
point: 'plan:pre',
into: 'planner',
fragment: { inline: 'Plan with UI context.' },
produces: 'PLAN-NOTE.md',
consumes: [],
when: 'workflow.ui_phase',
onError: 'skip',
},
],
};
const errors = validateCapability(cap, 'ui');
assert.ok(errors.some((e) => e.includes('produces') && e.includes('array')));
});
test('non-string contribution consumes entry is rejected', () => {
const cap = {
...UI_CAP,
contributions: [
{
point: 'plan:pre',
into: 'planner',
fragment: { inline: 'Plan with UI context.' },
produces: [],
consumes: [42],
when: 'workflow.ui_phase',
onError: 'skip',
},
],
};
const errors = validateCapability(cap, 'ui');
assert.ok(errors.some((e) => e.includes('consumes entries') && e.includes('strings')));
});
test('fragment.path is materialized into inline registry content', (t) => {
const capsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cap-fragment-'));
t.after(() => cleanup(capsDir));
const capDir = path.join(capsDir, 'planning-advice');
fs.mkdirSync(path.join(capDir, 'fragments'), { recursive: true });
fs.writeFileSync(
path.join(capDir, 'fragments', 'plan-pre.md'),
'Use the capability-owned planning fragment.\n',
);
fs.writeFileSync(
path.join(capDir, 'capability.json'),
JSON.stringify({
id: 'planning-advice',
role: 'feature',
version: '1.0.0',
title: 'Planning advice',
description: 'Synthetic fixture for fragment path materialization.',
tier: 'full',
requires: [],
runtimeCompat: { supported: ['*'], unsupported: [] },
skills: [],
agents: [],
hooks: [],
config: {},
steps: [],
contributions: [{
point: 'plan:pre',
into: 'planner',
fragment: { path: 'fragments/plan-pre.md' },
produces: [],
consumes: ['CONTEXT.md'],
onError: 'skip',
}],
gates: [],
}),
);
const { capMap, errors } = loadAndValidate(new Set(), capsDir);
assert.deepEqual(errors, []);
const registry = buildRegistry(capMap);
assert.strictEqual(
registry.byLoopPoint['plan:pre'].contributions[0].fragment.inline,
'Use the capability-owned planning fragment.\n',
);
});
test('step fragment.path is materialized into inline registry content', (t) => {
const capsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cap-step-fragment-'));
t.after(() => cleanup(capsDir));
const capDir = path.join(capsDir, 'research');
fs.mkdirSync(path.join(capDir, 'fragments'), { recursive: true });
fs.writeFileSync(
path.join(capDir, 'fragments', 'plan-pre.md'),
'Research prompt owned by the capability.\n',
);
fs.writeFileSync(
path.join(capDir, 'capability.json'),
JSON.stringify({
id: 'research',
role: 'feature',
version: '1.0.0',
title: 'Research',
description: 'Synthetic fixture for step fragment materialization.',
tier: 'standard',
requires: [],
runtimeCompat: { supported: ['*'], unsupported: [] },
skills: [],
agents: ['gsd-phase-researcher'],
hooks: [],
config: {},
steps: [{
point: 'plan:pre',
ref: { agent: 'gsd-phase-researcher' },
fragment: { path: 'fragments/plan-pre.md' },
produces: ['RESEARCH.md'],
consumes: ['CONTEXT.md'],
onError: 'skip',
}],
contributions: [],
gates: [],
}),
);
const { capMap, errors } = loadAndValidate(new Set(), capsDir);
assert.deepEqual(errors, []);
const registry = buildRegistry(capMap);
assert.strictEqual(
registry.byLoopPoint['plan:pre'].steps[0].fragment.inline,
'Research prompt owned by the capability.\n',
);
});
});
// ─── 8. Security: prototype pollution (S2) ────────────────────────────────────
describe('S2: prototype pollution guards', () => {
test('skill named "__proto__" is rejected', () => {
const cap = { ...UI_CAP, skills: ['__proto__'] };
const errors = validateCapability(cap, 'ui');
assert.ok(errors.length > 0, 'Expected rejection for __proto__ skill');
assert.ok(
errors.some((e) => e.includes('__proto__') && e.includes('reserved')),
'Error should mention reserved name, got: ' + JSON.stringify(errors),
);
});
test('skill named "constructor" is rejected', () => {
const cap = { ...UI_CAP, skills: ['constructor'] };
const errors = validateCapability(cap, 'ui');
assert.ok(errors.length > 0);
assert.ok(errors.some((e) => e.includes('constructor') && e.includes('reserved')));
});
test('agent named "__proto__" is rejected', () => {
const cap = { ...UI_CAP, agents: ['__proto__'] };
const errors = validateCapability(cap, 'ui');
assert.ok(errors.length > 0);
assert.ok(errors.some((e) => e.includes('__proto__') && e.includes('reserved')));
});
test('config key named "prototype" is rejected', () => {
const configWithReserved = {
...UI_CAP.config,
'prototype': { type: 'boolean', default: false, description: 'bad key' },
};
const cap = { ...UI_CAP, config: configWithReserved };
const errors = validateCapability(cap, 'ui');
assert.ok(errors.length > 0);
assert.ok(errors.some((e) => e.includes('prototype') && e.includes('reserved')));
});
test('building registry with prototype-polluting names does not pollute Object.prototype', () => {
// Even if somehow a reserved name got through, buildRegistry must not pollute.
// We test this by checking that Object.prototype is clean after a normal build.
const capDir = makeTempCapDir({ ui: UI_CAP });
const { capMap } = loadAndValidate(new Set(), capDir);
buildRegistry(capMap);
// After registry build, Object.prototype must not have been polluted.
assert.strictEqual(({}).polluted, undefined, 'Object.prototype should not be polluted');
assert.strictEqual(({}).ui, undefined, 'Object.prototype.ui should not exist');
});
});
// ─── 9. C2: Cross-capability consumes satisfiability ─────────────────────────
describe('C2: cross-capability consumes satisfiability (global pass)', () => {
test('cap B step consuming artifact produced by cap A at earlier point is accepted', () => {
const capA = {
id: 'cap-a', role: 'feature', title: 'A', description: 'A', tier: 'standard', requires: [],
skills: ['a-skill'], agents: ['gsd-a-agent'], hooks: [], config: {},
steps: [
{
point: 'plan:pre',
ref: { skill: 'a-skill' },
produces: ['A-OUTPUT.md'],
consumes: ['CONTEXT.md'],
onError: 'skip',
},
],
contributions: [], gates: [],
};
const capB = {
id: 'cap-b', role: 'feature', title: 'B', description: 'B', tier: 'standard', requires: [],
skills: ['b-skill'], agents: ['gsd-b-agent'], hooks: [], config: {},
steps: [
{
point: 'execute:pre', // after plan:pre — A-OUTPUT.md is available
ref: { skill: 'b-skill' },
produces: [],
consumes: ['A-OUTPUT.md'],
onError: 'skip',
},
],
contributions: [], gates: [],
};
const capMap = new Map([['cap-a', capA], ['cap-b', capB]]);
const errors = validateConsumesGlobal(capMap);
const aOutputErrors = errors.filter((e) => e.includes('A-OUTPUT.md'));
assert.deepEqual(aOutputErrors, [], 'Cap B consuming A-OUTPUT at execute:pre should be accepted, got: ' + JSON.stringify(aOutputErrors));
});
test('consuming an artifact that is never produced is rejected', () => {
const cap = {
id: 'cap-a', role: 'feature', title: 'A', description: 'A', tier: 'standard', requires: [],
skills: ['a-skill'], agents: ['gsd-a-agent'], hooks: [], config: {},
steps: [
{
point: 'plan:pre',
ref: { skill: 'a-skill' },
produces: [],
consumes: ['NONEXISTENT-ARTIFACT.md'],
onError: 'skip',
},
],
contributions: [], gates: [],
};
const capMap = new Map([['cap-a', cap]]);
const errors = validateConsumesGlobal(capMap);
assert.ok(errors.length > 0, 'Expected rejection: NONEXISTENT-ARTIFACT.md is never produced');
assert.ok(errors.some((e) => e.includes('NONEXISTENT-ARTIFACT.md')));
assert.ok(errors.some((e) => e.includes('never produced')));
});
test('same-point consumer of cross-cap artifact is accepted (topo handles intra-point order)', () => {
// Cap B at plan:pre consumes A-OUTPUT.md produced by cap A also at plan:pre.
// Same-point is OK — topoSortSteps will ensure A runs before B.
const capA = {
id: 'cap-a', role: 'feature', title: 'A', description: 'A', tier: 'standard', requires: [],
skills: ['a-skill'], agents: ['gsd-a-agent'], hooks: [], config: {},
steps: [
{
point: 'plan:pre',
ref: { skill: 'a-skill' },
produces: ['A-PLAN-OUTPUT.md'],
consumes: [],
onError: 'skip',
},
],
contributions: [], gates: [],
};
const capB = {
id: 'cap-b', role: 'feature', title: 'B', description: 'B', tier: 'standard', requires: [],
skills: ['b-skill'], agents: ['gsd-b-agent'], hooks: [], config: {},
steps: [
{
point: 'plan:pre', // same point — OK for global check; topo handles ordering
ref: { skill: 'b-skill' },
produces: [],
consumes: ['A-PLAN-OUTPUT.md'],
onError: 'skip',
},
],
contributions: [], gates: [],
};
const capMap = new Map([['cap-a', capA], ['cap-b', capB]]);
const errors = validateConsumesGlobal(capMap);
const outputErrors = errors.filter((e) => e.includes('A-PLAN-OUTPUT.md'));
assert.deepEqual(outputErrors, [], 'Same-point cross-cap consume should be accepted by global check, got: ' + JSON.stringify(outputErrors));
});
});
// ─── 10. C3: role:runtime validation ─────────────────────────────────────────
describe('C3: role:runtime body validation', () => {
// ADR-1016 phase 5a: fixture updated to match tightened structured-value shapes.
// configHome is now an object (Decision 1), artifactLayout is { global, local } (Decision 3),
// commandStyle is closed enum (Decision 4), hooksSurface is closed enum (Decision 5).
const VALID_RUNTIME_CAP = {
id: 'cursor', role: 'runtime', version: '1.0.0', title: 'Cursor', description: 'Cursor IDE runtime',
tier: 'standard', requires: [],
runtime: {
configHome: { kind: 'dot-home', name: '.cursor', env: ['CURSOR_CONFIG_DIR'] },
localConfigDir: '.cursor',
configFormat: 'settings-json',
artifactLayout: { global: [], local: [] },
commandStyle: 'slash-hyphen',
hooksSurface: 'cursor-hooks-json',
hookEvents: 'claude',
sandboxTier: 'none',
supportTier: 2,
installSurface: 'cursor-hooks-json',
writesSharedSettings: false,
permissionWriter: null,
extendedHookEvents: [],
hostIntegration: {
embeddingMode: 'declarative',
commandSurface: 'slash-file',
dispatch: { namedDispatch: true, nested: true, maxDepth: 2, background: false, subagentToolkit: 'full', backgroundDispatch: false },
modelMode: 'passive',
hookBus: 'host',
stateIO: 'filesystem',
transport: 'mcp',
runtime: 'node',
},
},
};
test('valid runtime descriptor passes validation', () => {
const errors = validateCapability(VALID_RUNTIME_CAP, 'cursor');
assert.deepEqual(errors, [], 'Expected no validation errors for valid runtime cap, got: ' + JSON.stringify(errors));
});
test('runtime cap with skills present is rejected', () => {
const cap = { ...VALID_RUNTIME_CAP, skills: ['some-skill'] };
const errors = validateCapability(cap, 'cursor');
assert.ok(errors.length > 0);
assert.ok(errors.some((e) => e.includes('skills') && e.includes('feature-only')));
});
test('runtime cap with steps present is rejected', () => {
const cap = { ...VALID_RUNTIME_CAP, steps: [] };
const errors = validateCapability(cap, 'cursor');
assert.ok(errors.length > 0);
assert.ok(errors.some((e) => e.includes('steps') && e.includes('feature-only')));
});
test('runtime cap with contributions present is rejected', () => {
const cap = { ...VALID_RUNTIME_CAP, contributions: [] };
const errors = validateCapability(cap, 'cursor');
assert.ok(errors.length > 0);
assert.ok(errors.some((e) => e.includes('contributions') && e.includes('feature-only')));
});
test('runtime cap missing the runtime object is rejected', () => {
const { runtime: _r, ...capWithoutRuntime } = VALID_RUNTIME_CAP;
const errors = validateCapability(capWithoutRuntime, 'cursor');
assert.ok(errors.length > 0);
assert.ok(errors.some((e) => e.includes('runtime') && e.includes('object')));
});
test('runtime cap with invalid configFormat is rejected', () => {
const cap = { ...VALID_RUNTIME_CAP, runtime: { ...VALID_RUNTIME_CAP.runtime, configFormat: 'xml' } };
const errors = validateCapability(cap, 'cursor');
assert.ok(errors.length > 0);
assert.ok(errors.some((e) => e.includes('configFormat')));
});
test('runtime cap with supportTier 3 is rejected', () => {
const cap = { ...VALID_RUNTIME_CAP, runtime: { ...VALID_RUNTIME_CAP.runtime, supportTier: 3 } };
const errors = validateCapability(cap, 'cursor');
assert.ok(errors.length > 0);
assert.ok(errors.some((e) => e.includes('supportTier')));
});
test('runtime cap with supportTier 1 is accepted', () => {
const cap = { ...VALID_RUNTIME_CAP, runtime: { ...VALID_RUNTIME_CAP.runtime, supportTier: 1 } };
const errors = validateCapability(cap, 'cursor');
assert.deepEqual(errors, [], 'Expected no errors for supportTier:1, got: ' + JSON.stringify(errors));
});
});
// ─── 11. C4: description and hooks validation ─────────────────────────────────
describe('C4: description and hooks validation', () => {
test('missing description is rejected', () => {
const { description: _d, ...capWithoutDesc } = UI_CAP;
const errors = validateCapability(capWithoutDesc, 'ui');
assert.ok(errors.length > 0, 'Expected rejection for missing description');
assert.ok(errors.some((e) => e.includes('description')));
});
test('hooks = 42 (non-array) is rejected', () => {
const cap = { ...UI_CAP, hooks: 42 };
const errors = validateCapability(cap, 'ui');
assert.ok(errors.length > 0, 'Expected rejection for hooks = 42');
assert.ok(errors.some((e) => e.includes('hooks') && e.includes('array')));
});
test('hooks with malformed entry (missing event) is rejected', () => {
const cap = { ...UI_CAP, hooks: [{ script: 'some.sh' }] };
const errors = validateCapability(cap, 'ui');
assert.ok(errors.length > 0, 'Expected rejection for hook missing event');
assert.ok(errors.some((e) => e.includes('hooks[0].event')));
});
test('hooks with malformed entry (missing script) is rejected', () => {
const cap = { ...UI_CAP, hooks: [{ event: 'FileChanged' }] };
const errors = validateCapability(cap, 'ui');
assert.ok(errors.length > 0, 'Expected rejection for hook missing script');
assert.ok(errors.some((e) => e.includes('hooks[0].script')));
});
test('valid hooks array with well-formed entry is accepted', () => {
const cap = { ...UI_CAP, hooks: [{ event: 'FileChanged', script: 'hooks/file-changed.sh' }] };
const errors = validateCapability(cap, 'ui');
const hookErrors = errors.filter((e) => e.includes('hooks['));
assert.deepEqual(hookErrors, [], 'Expected no hook errors for valid hooks entry, got: ' + JSON.stringify(hookErrors));
});
// ─── #1460 (R) HIGH: hook script path must be shell-safe ──────────────────
// The hook `script` is resolved to an absolute path and written verbatim as the hook
// `command` STRING in settings.json (consumed by a shell). A manifest-controlled script
// name containing shell metacharacters (`;`, `|`, `$`, backtick, whitespace, …) would
// inject a second command at hook-exec time. Fail closed at the validator: reject any
// script path outside the conservative [A-Za-z0-9._/-] allowlist. revert-fails: without
// the allowlist these all pass the non-empty-string check and validate OK.
for (const [label, script] of [
['command-injection via `;`', 'run.sh; touch /tmp/pwn'],
['embedded space', 'my hook.sh'],
['command substitution `$( )`', 'run-$(whoami).sh'],
['backtick substitution', 'run-`id`.sh'],
['pipe metacharacter', 'a.sh|b.sh'],
['newline injection', 'a.sh\ntouch /tmp/pwn'],
['ampersand background', 'a.sh & evil'],
['shell glob', 'hooks/*.sh'],
['redirect', 'a.sh > /tmp/pwn'],
['leading dash (option injection)', '-rf'],
['single quote', "a'.sh"],
['double quote', 'a".sh'],
['NUL/control char', 'a\u0000.sh'],
]) {
test(`hook script with unsafe chars is rejected (${label})`, () => {
const cap = { ...UI_CAP, hooks: [{ event: 'PostToolUse', script }] };
const errors = validateCapability(cap, 'ui');
const hookErrors = errors.filter((e) => e.includes('hooks[0].script'));
assert.ok(
hookErrors.length > 0,
`Expected a hooks[0].script rejection for ${label} (script=${JSON.stringify(script)}), got: ` + JSON.stringify(errors),
);
assert.ok(
hookErrors.some((e) => /unsafe character/.test(e)),
'Error should mention unsafe characters, got: ' + JSON.stringify(hookErrors),
);
});
}
test('hook script with absolute path is rejected', () => {
const cap = { ...UI_CAP, hooks: [{ event: 'PostToolUse', script: '/etc/evil.sh' }] };
const errors = validateCapability(cap, 'ui');
assert.ok(errors.some((e) => e.includes('hooks[0].script')), 'absolute script must be rejected: ' + JSON.stringify(errors));
});
test('hook script with .. traversal is rejected', () => {
const cap = { ...UI_CAP, hooks: [{ event: 'PostToolUse', script: '../../etc/evil.sh' }] };
const errors = validateCapability(cap, 'ui');
assert.ok(errors.some((e) => e.includes('hooks[0].script')), '.. script must be rejected: ' + JSON.stringify(errors));
});
test('hook script with a normal nested relative path is still accepted', () => {
const cap = { ...UI_CAP, hooks: [{ event: 'PostToolUse', script: 'hooks/sub-dir/format_v2.sh' }] };
const errors = validateCapability(cap, 'ui');
const hookErrors = errors.filter((e) => e.includes('hooks[0].script'));
assert.deepEqual(hookErrors, [], 'Expected a normal nested relative script to be accepted, got: ' + JSON.stringify(hookErrors));
});
// ─── #3631 (defense-in-depth): a declared hook script path must not point into the space
// bundleContentHash excludes from the consent digest. A file named `x.pyc` can contain valid
// JavaScript and would be executed by `node` regardless of extension, and a __pycache__/
// .pytest_cache path segment marks digest-excluded space — so a manifest-declared executable
// surface must never be able to reach either. ───
for (const [label, script] of [
['__pycache__ path segment', '__pycache__/run.js'],
['.pytest_cache path segment', '.pytest_cache/run.js'],
['.pyc basename suffix', 'hooks/x.pyc'],
]) {
test(`hook script pointing into digest-excluded space is rejected (${label})`, () => {
const cap = { ...UI_CAP, hooks: [{ event: 'PostToolUse', script }] };
const errors = validateCapability(cap, 'ui');
const hookErrors = errors.filter((e) => e.includes('hooks[0].script'));
assert.ok(
hookErrors.length > 0,
`Expected a hooks[0].script rejection for ${label} (script=${JSON.stringify(script)}), got: ` + JSON.stringify(errors),
);
});
}
test('hook script not pointing into digest-excluded space is still accepted (hooks/check.js)', () => {
const cap = { ...UI_CAP, hooks: [{ event: 'PostToolUse', script: 'hooks/check.js' }] };
const errors = validateCapability(cap, 'ui');
const hookErrors = errors.filter((e) => e.includes('hooks[0].script'));
assert.deepEqual(hookErrors, [], 'Expected a normal .js hook script to be accepted, got: ' + JSON.stringify(hookErrors));
});
// ─── Generative-fix-divergence parity: `isSafeHookScriptPath` is duplicated hand-maintained
// logic in src/capability-lifecycle.cts (via `confinedBundleScript`, the nearest exported
// consumer) and gsd-core/bin/lib/capability-validator.cjs (via `validateCapability`, the
// nearest exported consumer). There is no src/capability-validator.cts — the .cjs is hand
// -maintained — so this drives BOTH real copies behaviorally (never source-greps either file)
// and asserts they agree on every path, catching the two implementations drifting apart. #3631
// finding 1 removed the `.DS_Store` DIGEST exclusion, but the validator never rejected
// `.DS_Store` on either side — `hooks/.DS_Store` is expected to be ACCEPTED by both.
test('isSafeHookScriptPath parity: capability-lifecycle.cts and capability-validator.cjs agree on every path', () => {
const { confinedBundleScript } = require('../gsd-core/bin/lib/capability-lifecycle.cjs');
// A capDir that does not exist on disk: confinedBundleScript falls into its lexical
// (does-not-exist-yet) branch, so the verdict reflects ONLY isSafeHookScriptPath — never a
// realpath/confinement side effect unrelated to what is under test here.
const fakeCapDir = path.join(os.tmpdir(), 'gsd-parity-probe-nonexistent-cap-dir');
const cases = [
['hooks/check.js', true],
['__pycache__/run.js', false],
['hooks/__pycache__/run.js', false],
['.pytest_cache/run.js', false],
['hooks/x.pyc', false],
['x.pyo', false],
['hooks/x.PYC', false],
['hooks/.DS_Store', true],
['hooks/../__pycache__/run.js', false],
['hooks/__pycache__./run.js', true],
['__PYCACHE__/run.js', true],
['hooks\\__pycache__\\run.js', false],
];
for (const [script, expectedAccept] of cases) {
const cap = { ...UI_CAP, hooks: [{ event: 'PostToolUse', script }] };
const errors = validateCapability(cap, 'ui');
const cjsAccept = errors.filter((e) => e.includes('hooks[0].script')).length === 0;
const ctsAccept = confinedBundleScript(fakeCapDir, script) !== null;
assert.strictEqual(
cjsAccept,
ctsAccept,
`capability-validator.cjs (accept=${cjsAccept}) and capability-lifecycle.cts (accept=${ctsAccept}) disagree on ${JSON.stringify(script)}`,
);
assert.strictEqual(
cjsAccept,
expectedAccept,
`expected accept=${expectedAccept} for ${JSON.stringify(script)}, both copies returned accept=${cjsAccept}`,
);
}
});
test('description present in UI_CAP passes validation', () => {
const errors = validateCapability(UI_CAP, 'ui');
const descErrors = errors.filter((e) => e.includes('description'));
assert.deepEqual(descErrors, [], 'UI_CAP should have valid description, got: ' + JSON.stringify(descErrors));
});
});
// ─── 12. C5: config value shape validation ────────────────────────────────────
describe('C5: config value shape validation', () => {
test('config value that is null is rejected', () => {
const config = { ...UI_CAP.config, 'workflow.ui_null_test': null };
const cap = { ...UI_CAP, config };
const errors = validateCapability(cap, 'ui');
assert.ok(errors.length > 0, 'Expected rejection for null config value');
assert.ok(
errors.some((e) => e.includes('workflow.ui_null_test') && e.includes('null')),
'Error should mention the key and null, got: ' + JSON.stringify(errors),
);
});
test('config value that is a string scalar is rejected', () => {
const config = { ...UI_CAP.config, 'workflow.ui_bad': 'just-a-string' };
const cap = { ...UI_CAP, config };
const errors = validateCapability(cap, 'ui');
assert.ok(errors.length > 0, 'Expected rejection for scalar string config value');
assert.ok(errors.some((e) => e.includes('workflow.ui_bad') && e.includes('object')));
});
test('config value that is a number is rejected', () => {
const config = { ...UI_CAP.config, 'workflow.ui_num': 42 };
const cap = { ...UI_CAP, config };
const errors = validateCapability(cap, 'ui');
assert.ok(errors.length > 0);
assert.ok(errors.some((e) => e.includes('workflow.ui_num') && e.includes('object')));
});
test('config value that is a proper object is accepted', () => {
// UI_CAP config values are all valid objects — validate it
const errors = validateCapability(UI_CAP, 'ui');
const configErrors = errors.filter((e) => e.includes('config['));
assert.deepEqual(configErrors, [], 'UI_CAP config values should all be valid objects, got: ' + JSON.stringify(configErrors));
});
test('config value {} (empty object, missing type) is rejected', () => {
const config = { ...UI_CAP.config, 'workflow.ui_no_type': {} };
const cap = { ...UI_CAP, config };
const errors = validateCapability(cap, 'ui');
assert.ok(errors.length > 0, 'Expected rejection for config value with no type field');
assert.ok(
errors.some((e) => e.includes('workflow.ui_no_type') && e.includes('type')),
'Error should mention the key and "type", got: ' + JSON.stringify(errors),
);
});
test('config value { type: "boolean", default: true } is accepted', () => {
const config = { ...UI_CAP.config, 'workflow.ui_good': { type: 'boolean', default: true } };
const cap = { ...UI_CAP, config };
const errors = validateCapability(cap, 'ui');
const configErrors = errors.filter((e) => e.includes('workflow.ui_good'));
assert.deepEqual(configErrors, [], 'config value with type:"boolean" and default should be accepted, got: ' + JSON.stringify(configErrors));
});
test('UI pilot config values all have type:"boolean" and pass FIX 2 validation', () => {
// Regression guard: UI_CAP config keys (workflow.ui_phase etc.) all have type:"boolean"
const errors = validateCapability(UI_CAP, 'ui');
const configErrors = errors.filter((e) => e.includes('config['));
assert.deepEqual(
configErrors, [],
'UI pilot config values should all pass type-field validation, got: ' + JSON.stringify(configErrors),
);
// Directly confirm each key has type:"boolean"
for (const [key, val] of Object.entries(UI_CAP.config)) {
assert.strictEqual(typeof val.type, 'string', 'config["' + key + '"].type should be a string');
assert.strictEqual(val.type, 'boolean', 'config["' + key + '"].type should be "boolean"');
}
});
});
// ─── 13. FIX 1: self-consume rejection ───────────────────────────────────────
describe('FIX 1: self-consume rejection in validateConsumesGlobal', () => {
test('a step produces:["SELF.md"] and consumes:["SELF.md"] with no other producer is rejected', () => {
const cap = {
id: 'self-cap', role: 'feature', title: 'Self', description: 'Self consume test',
tier: 'standard', requires: [],
skills: ['self-skill'], agents: ['gsd-self-agent'], hooks: [], config: {},
steps: [
{
point: 'plan:pre',
ref: { skill: 'self-skill' },
produces: ['SELF.md'],
consumes: ['SELF.md'],
onError: 'skip',
},
],
contributions: [], gates: [],
};
const capMap = new Map([['self-cap', cap]]);
const errors = validateConsumesGlobal(capMap);
assert.ok(errors.length > 0, 'Expected rejection: step cannot consume its own output');
assert.ok(
errors.some((e) => e.includes('SELF.md')),
'Error should mention SELF.md, got: ' + JSON.stringify(errors),
);
assert.ok(
errors.some((e) => e.includes('self') || e.includes('itself') || e.includes('own output')),
'Error should indicate self-consume violation, got: ' + JSON.stringify(errors),
);
});
test('a step produces:["SELF.md"] and consumes:["SELF.md"] but another capability produces SELF.md at an earlier point is accepted', () => {
const producerCap = {
id: 'producer-cap', role: 'feature', title: 'Producer', description: 'Produces SELF.md',
tier: 'standard', requires: [],
skills: ['producer-skill'], agents: ['gsd-producer-agent'], hooks: [], config: {},
steps: [
{
point: 'plan:pre', // same point, but different cap — satisfies self-cap's consume
ref: { skill: 'producer-skill' },
produces: ['SELF.md'],
consumes: [],
onError: 'skip',
},
],
contributions: [], gates: [],
};
const selfCap = {
id: 'self-cap', role: 'feature', title: 'Self', description: 'Self consume test',
tier: 'standard', requires: [],
skills: ['self-skill'], agents: ['gsd-self-agent'], hooks: [], config: {},
steps: [
{
point: 'execute:pre', // later point than plan:pre — producer-cap satisfies it
ref: { skill: 'self-skill' },
produces: ['SELF.md'],
consumes: ['SELF.md'],
onError: 'skip',
},
],
contributions: [], gates: [],
};
const capMap = new Map([['producer-cap', producerCap], ['self-cap', selfCap]]);
const errors = validateConsumesGlobal(capMap);
const selfErrors = errors.filter((e) => e.includes('SELF.md') && e.includes('self-cap'));
assert.deepEqual(
selfErrors, [],
'Expected self-cap consume of SELF.md to be accepted when producer-cap produces it at an earlier point, got: ' + JSON.stringify(selfErrors),
);
});
// (this test follows the series above)
// Updated fixture: producer-cap produces SELF.md at plan:pre; self-cap CONSUMES (not produces)
// SELF.md at plan:pre — a single producer at that point satisfies the consume.
// The prior fixture had BOTH caps producing SELF.md at plan:pre, which now violates the
// duplicate-producer invariant added in Issue #1123. The consume-satisfiability logic being
// tested here is unaffected — the key assertion remains: a step consuming an artifact that
// a DIFFERENT cap produces at the SAME point is satisfied.
test('a step consumes:["SELF.md"] and another capability produces SELF.md at the SAME point is accepted (different cap)', () => {
const producerCap = {
id: 'producer-cap', role: 'feature', title: 'Producer', description: 'Produces SELF.md',
tier: 'standard', requires: [],
skills: ['producer-skill'], agents: ['gsd-producer-agent'], hooks: [], config: {},
steps: [
{
point: 'plan:pre',
ref: { skill: 'producer-skill' },
produces: ['SELF.md'],
consumes: [],
onError: 'skip',
},
],
contributions: [], gates: [],
};
const consumerCap = {
id: 'self-cap', role: 'feature', title: 'Self', description: 'Consume test',
tier: 'standard', requires: [],
skills: ['self-skill'], agents: ['gsd-self-agent'], hooks: [], config: {},
steps: [
{
point: 'plan:pre', // same point — producer-cap (different cap) satisfies the consume
ref: { skill: 'self-skill' },
produces: [],
consumes: ['SELF.md'],
onError: 'skip',
},
],
contributions: [], gates: [],
};
const capMap = new Map([['producer-cap', producerCap], ['self-cap', consumerCap]]);
const errors = validateConsumesGlobal(capMap);
const selfErrors = errors.filter((e) => e.includes('SELF.md') && e.includes('self-cap'));
assert.deepEqual(
selfErrors, [],
'Expected self-cap consume of SELF.md to be accepted when a DIFFERENT cap produces it at the same point, got: ' + JSON.stringify(selfErrors),
);
});
});
// ─── 14. configSchema emission (ADR-857 phase 3b) ────────────────────────────
describe('configSchema emission (ADR-857 phase 3b)', () => {
test('buildRegistry emits configSchema with correct shape for UI pilot', () => {
const capDir = makeTempCapDir({ ui: UI_CAP });
const { capMap, errors } = loadAndValidate(new Set(), capDir);
assert.deepEqual(errors, [], 'No errors expected');
const registry = buildRegistry(capMap);
assert.ok(registry.configSchema, 'registry.configSchema must exist');
const uiPhase = registry.configSchema['workflow.ui_phase'];
assert.ok(uiPhase, 'configSchema must have workflow.ui_phase');
assert.strictEqual(uiPhase.owner, 'ui');
assert.strictEqual(uiPhase.type, 'boolean');
assert.strictEqual(uiPhase.default, true);
assert.ok(typeof uiPhase.description === 'string' && uiPhase.description.length > 0);
const uiReview = registry.configSchema['workflow.ui_review'];
assert.ok(uiReview, 'configSchema must have workflow.ui_review');
assert.strictEqual(uiReview.owner, 'ui');
assert.strictEqual(uiReview.type, 'boolean');
const uiSafetyGate = registry.configSchema['workflow.ui_safety_gate'];
assert.ok(uiSafetyGate, 'configSchema must have workflow.ui_safety_gate');
assert.strictEqual(uiSafetyGate.type, 'boolean');
});
test('serializeRegistry emits a configSchema block in the generated .cjs', () => {
const capDir = makeTempCapDir({ ui: UI_CAP });
const { capMap } = loadAndValidate(new Set(), capDir);
const registry = buildRegistry(capMap);
const content = serializeRegistry(registry, capMap);
assert.ok(content.includes('const configSchema'), 'Generated file must contain "const configSchema"');
assert.ok(content.includes('"workflow.ui_phase"'), 'Generated file must contain "workflow.ui_phase"');
assert.ok(content.includes('"owner"'), 'Generated file must contain "owner" field');
assert.ok(content.includes('"type"'), 'Generated file must contain "type" field');
assert.ok(content.includes('"default"'), 'Generated file must contain "default" field');
assert.ok(content.includes('"description"'), 'Generated file must contain "description" field');
assert.ok(content.includes('configSchema,'), 'Generated module.exports must include configSchema');
});
test('committed capability-registry.cjs has configSchema with correct shape', () => {
const registry = require('../gsd-core/bin/lib/capability-registry.cjs');
assert.ok(registry.configSchema, 'capability-registry.cjs must export configSchema');
const uiPhase = registry.configSchema['workflow.ui_phase'];
assert.ok(uiPhase, 'committed registry configSchema must have workflow.ui_phase');
assert.strictEqual(uiPhase.owner, 'ui', 'owner must be "ui"');
assert.strictEqual(uiPhase.type, 'boolean', 'type must be "boolean"');
assert.strictEqual(uiPhase.default, true, 'default must be true');
assert.ok(typeof uiPhase.description === 'string' && uiPhase.description.length > 0);
});
});
// ─── 15. validateConfigSliceEntry adversarial tests ───────────────────────────
describe('validateConfigSliceEntry adversarial cases (ADR-857 phase 3b)', () => {
const CAP_ID = 'test-cap';
const KEY = 'test.key';
test('VALID_CONFIG_SLICE_TYPES exports expected types', () => {
const types = [...VALID_CONFIG_SLICE_TYPES];
assert.ok(types.includes('boolean'), 'Must include boolean');
assert.ok(types.includes('string'), 'Must include string');
assert.ok(types.includes('number'), 'Must include number');
assert.ok(types.includes('enum'), 'Must include enum');
assert.strictEqual(types.length, 4, 'Must have exactly 4 types');
});
test('valid boolean slice passes validation', () => {
const errors = validateConfigSliceEntry(CAP_ID, KEY, { type: 'boolean', default: true, description: 'ok' });
assert.deepEqual(errors, [], 'Valid boolean slice should produce no errors, got: ' + JSON.stringify(errors));
});
test('valid string slice passes validation', () => {
const errors = validateConfigSliceEntry(CAP_ID, KEY, { type: 'string', default: 'x', description: 'ok' });
assert.deepEqual(errors, []);
});
test('valid number slice passes validation', () => {
const errors = validateConfigSliceEntry(CAP_ID, KEY, { type: 'number', default: 5, description: 'ok' });
assert.deepEqual(errors, []);
});
test('REJECTED: enum slice without values list → error (FIX 5a: values required)', () => {
const errors = validateConfigSliceEntry(CAP_ID, KEY, { type: 'enum', default: 'x', description: 'ok' });
assert.ok(errors.length > 0, 'Expected rejection for enum without values list, got: ' + JSON.stringify(errors));
assert.ok(
errors.some((e) => e.includes('values') || e.includes('enum')),
'Error should mention values or enum, got: ' + JSON.stringify(errors),
);
});
test('valid enum slice (with values list, default in values) passes', () => {
const errors = validateConfigSliceEntry(CAP_ID, KEY, {
type: 'enum', default: 'b', values: ['a', 'b', 'c'], description: 'ok',
});
assert.deepEqual(errors, []);
});
test('REJECTED: bad type ("xml") → error mentioning type', () => {
const errors = validateConfigSliceEntry(CAP_ID, KEY, { type: 'xml', default: '<x/>', description: 'ok' });
assert.ok(errors.length > 0, 'Expected rejection for bad type');
assert.ok(errors.some((e) => e.includes('type')), 'Error should mention type, got: ' + JSON.stringify(errors));
});
test('REJECTED: missing type → error', () => {
const errors = validateConfigSliceEntry(CAP_ID, KEY, { default: true, description: 'ok' });
assert.ok(errors.length > 0, 'Expected rejection for missing type');
assert.ok(errors.some((e) => e.includes('type')));
});
test('REJECTED: missing default → error mentioning default', () => {
const errors = validateConfigSliceEntry(CAP_ID, KEY, { type: 'boolean', description: 'ok' });
assert.ok(errors.length > 0, 'Expected rejection for missing default');
assert.ok(errors.some((e) => e.includes('default')), 'Error should mention default, got: ' + JSON.stringify(errors));
});
test('REJECTED: boolean type with string default → error', () => {
const errors = validateConfigSliceEntry(CAP_ID, KEY, { type: 'boolean', default: 'true', description: 'ok' });
assert.ok(errors.length > 0, 'Expected rejection for boolean type with string default');
assert.ok(errors.some((e) => e.includes('boolean') || e.includes('default')));
});
test('REJECTED: string type with boolean default → error', () => {
const errors = validateConfigSliceEntry(CAP_ID, KEY, { type: 'string', default: false, description: 'ok' });
assert.ok(errors.length > 0, 'Expected rejection for string type with boolean default');
});
test('REJECTED: number type with string default → error', () => {
const errors = validateConfigSliceEntry(CAP_ID, KEY, { type: 'number', default: 'five', description: 'ok' });
assert.ok(errors.length > 0, 'Expected rejection for number type with string default');
});
test('REJECTED: enum type with values list, default not in values → error', () => {
const errors = validateConfigSliceEntry(CAP_ID, KEY, {
type: 'enum', default: 'z', values: ['a', 'b', 'c'], description: 'ok',
});
assert.ok(errors.length > 0, 'Expected rejection for enum default not in values');
assert.ok(errors.some((e) => e.includes('enum') || e.includes('values') || e.includes('z')));
});
test('REJECTED: enum type with non-string default → error', () => {
const errors = validateConfigSliceEntry(CAP_ID, KEY, { type: 'enum', default: 42, description: 'ok' });
assert.ok(errors.length > 0, 'Expected rejection for enum with non-string default');
});
test('REJECTED: empty description string → error', () => {
const errors = validateConfigSliceEntry(CAP_ID, KEY, { type: 'boolean', default: true, description: '' });
assert.ok(errors.length > 0, 'Expected rejection for empty description');
assert.ok(errors.some((e) => e.includes('description')));
});
test('REJECTED: non-string description (number) → error', () => {
const errors = validateConfigSliceEntry(CAP_ID, KEY, { type: 'boolean', default: true, description: 42 });
assert.ok(errors.length > 0, 'Expected rejection for non-string description');
assert.ok(errors.some((e) => e.includes('description')));
});
test('REJECTED: missing description → error', () => {
const errors = validateConfigSliceEntry(CAP_ID, KEY, { type: 'boolean', default: true });
assert.ok(errors.length > 0, 'Expected rejection for missing description');
assert.ok(errors.some((e) => e.includes('description')));
});
test('REJECTED: null slice → error', () => {
const errors = validateConfigSliceEntry(CAP_ID, KEY, null);
assert.ok(errors.length > 0, 'Expected rejection for null slice');
});
test('REJECTED: array slice → error', () => {
const errors = validateConfigSliceEntry(CAP_ID, KEY, []);
assert.ok(errors.length > 0, 'Expected rejection for array slice');
});
// FIX 5a: enum-without-values and default-not-in-values
test('REJECTED: enum with empty values array → error (FIX 5a)', () => {
const errors = validateConfigSliceEntry(CAP_ID, KEY, { type: 'enum', default: 'x', values: [], description: 'ok' });
assert.ok(errors.length > 0, 'Expected rejection for enum with empty values, got: ' + JSON.stringify(errors));
assert.ok(errors.some((e) => e.includes('values') || e.includes('enum')));
});
test('REJECTED: enum with non-string values array entries → error (FIX 5a)', () => {
const errors = validateConfigSliceEntry(CAP_ID, KEY, { type: 'enum', default: 'x', values: ['a', 42], description: 'ok' });
assert.ok(errors.length > 0, 'Expected rejection for enum with non-string values, got: ' + JSON.stringify(errors));
assert.ok(errors.some((e) => e.includes('values') || e.includes('string')));
});
test('REJECTED: enum default not in values → error (FIX 5a)', () => {
const errors = validateConfigSliceEntry(CAP_ID, KEY, { type: 'enum', default: 'z', values: ['a', 'b'], description: 'ok' });
assert.ok(errors.length > 0, 'Expected rejection for enum default not in values, got: ' + JSON.stringify(errors));
assert.ok(errors.some((e) => e.includes('z') || e.includes('values') || e.includes('default')));
});
// FIX 6a: NaN and non-finite number defaults
test('REJECTED: NaN number default → error (FIX 6a)', () => {
const errors = validateConfigSliceEntry(CAP_ID, KEY, { type: 'number', default: NaN, description: 'ok' });
assert.ok(errors.length > 0, 'Expected rejection for NaN default, got: ' + JSON.stringify(errors));
assert.ok(errors.some((e) => e.includes('finite') || e.includes('NaN') || e.includes('number')));
});
test('REJECTED: Infinity number default → error (FIX 6a)', () => {
const errors = validateConfigSliceEntry(CAP_ID, KEY, { type: 'number', default: Infinity, description: 'ok' });
assert.ok(errors.length > 0, 'Expected rejection for Infinity default, got: ' + JSON.stringify(errors));
assert.ok(errors.some((e) => e.includes('finite') || e.includes('number')));
});
test('REJECTED: -Infinity number default → error (FIX 6a)', () => {
const errors = validateConfigSliceEntry(CAP_ID, KEY, { type: 'number', default: -Infinity, description: 'ok' });
assert.ok(errors.length > 0, 'Expected rejection for -Infinity default, got: ' + JSON.stringify(errors));
});
test('buildRegistry throws on malformed config slice in capability', () => {
// A capability with a config slice that has a missing default — buildRegistry must throw
const cap = {
...UI_CAP,
config: {
...UI_CAP.config,
'workflow.bad_key': { type: 'boolean', description: 'missing default' },
},
};
const capMap = new Map([['ui', cap]]);
assert.throws(
() => buildRegistry(capMap),
(err) => {
assert.ok(err instanceof Error, 'Must throw an Error');
assert.ok(
err.message.includes('configSchema') || err.message.includes('default') || err.message.includes('validation'),
'Error must mention configSchema validation, got: ' + err.message,
);
return true;
},
);
});
});
// ─── 16. ADR-857 phase 4a: capabilityClusters + profileMembership ─────────────
// Minimal valid feature capability for synthetic tests
function makeSyntheticCap(id, tier, skills) {
return {
id,
role: 'feature',
title: id,
description: 'Synthetic cap for testing',
tier,
requires: [],
skills: [...skills],
agents: [],
hooks: [],
config: {},
steps: [],
contributions: [],
gates: [],
};
}
describe('ADR-857 phase 4a: capabilityClusters shape', () => {
test('ui capabilityClusters → [ui-phase, ui-review]', () => {
const capMap = new Map([['ui', UI_CAP]]);
const clusters = deriveCapabilityClusters(capMap);
assert.ok(clusters.ui, 'capabilityClusters.ui should exist');
// Skills are sorted for determinism
assert.deepEqual(
clusters.ui,
['ui-phase', 'ui-review'],
'ui cluster should be [ui-phase, ui-review], got: ' + JSON.stringify(clusters.ui),
);
});
test('capabilityClusters skips runtime capabilities (no skills)', () => {
const runtimeCap = {
id: 'cursor', role: 'runtime', title: 'Cursor', description: 'Cursor runtime',
tier: 'standard', requires: [],
runtime: {
configHome: '~/.cursor', configFormat: 'settings-json',
artifactLayout: [], commandStyle: 'slash', hooksSurface: 'rules',
sandboxTier: 'none', supportTier: 2,
},
};
const capMap = new Map([['cursor', runtimeCap]]);
const clusters = deriveCapabilityClusters(capMap);
assert.ok(!clusters.cursor, 'runtime cap should not appear in capabilityClusters');
});
test('capabilityClusters skills are sorted for determinism', () => {
const cap = makeSyntheticCap('test-cap', 'standard', ['z-skill', 'a-skill', 'm-skill']);
const capMap = new Map([['test-cap', cap]]);
const clusters = deriveCapabilityClusters(capMap);
assert.deepEqual(
clusters['test-cap'],
['a-skill', 'm-skill', 'z-skill'],
'Skills should be sorted alphabetically, got: ' + JSON.stringify(clusters['test-cap']),
);
});
test('buildRegistry includes capabilityClusters with correct ui value', () => {
const capDir = makeTempCapDir({ ui: UI_CAP });
const { capMap } = loadAndValidate(new Set(), capDir);
const registry = buildRegistry(capMap);
assert.ok(registry.capabilityClusters, 'registry.capabilityClusters should exist');
assert.deepEqual(
registry.capabilityClusters.ui,
['ui-phase', 'ui-review'],
'registry.capabilityClusters.ui should be [ui-phase, ui-review]',
);
});
test('serializeRegistry emits capabilityClusters block in generated .cjs', () => {
const capDir = makeTempCapDir({ ui: UI_CAP });
const { capMap } = loadAndValidate(new Set(), capDir);
const registry = buildRegistry(capMap);
const content = serializeRegistry(registry, capMap);
assert.ok(content.includes('const capabilityClusters'), 'Generated file must contain "const capabilityClusters"');
assert.ok(content.includes('"ui-phase"'), 'Generated file must contain "ui-phase" in capabilityClusters');
assert.ok(content.includes('capabilityClusters,'), 'module.exports must include capabilityClusters');
});
test('committed capability-registry.cjs has capabilityClusters with ui=[ui-phase,ui-review]', () => {
const registry = require('../gsd-core/bin/lib/capability-registry.cjs');
assert.ok(registry.capabilityClusters, 'capability-registry.cjs must export capabilityClusters');
assert.deepEqual(
registry.capabilityClusters.ui,
['ui-phase', 'ui-review'],
'committed capabilityClusters.ui should be [ui-phase, ui-review]',
);
});
});
describe('ADR-857 phase 4a: capabilityClusters HARD consistency gate', () => {
test('synthetic cap whose capId matches a CLUSTERS name but with different skills throws', () => {
// The 'ui' name exists in CLUSTERS with ['ui-phase', 'ui-review'].
// A synthetic 'ui' cap with only ['ui-phase'] (missing 'ui-review') must throw.
const wrongUiCap = makeSyntheticCap('ui', 'standard', ['ui-phase']); // missing ui-review
const capMap = new Map([['ui', wrongUiCap]]);
const clusters = deriveCapabilityClusters(capMap);
const profiles = deriveProfileMembership(capMap);
assert.throws(
() => runConsistencyGate(clusters, profiles, capMap),
(err) => {
assert.ok(err instanceof Error, 'Must throw an Error');
assert.ok(
err.message.includes('ui'),
'Error must name the capId, got: ' + err.message,
);
assert.ok(
err.message.includes('ui-review') || err.message.includes('derived set') || err.message.includes('hand-authored'),
'Error must describe the mismatch, got: ' + err.message,
);
return true;
},
);
});
test('cap with capId that has NO matching CLUSTERS entry is accepted (new cluster — fine)', () => {
// A new capability 'payments' that has no CLUSTERS entry must NOT throw
const newCap = makeSyntheticCap('payments', 'standard', ['pay-phase', 'pay-review']);
const capMap = new Map([['payments', newCap]]);
const clusters = deriveCapabilityClusters(capMap);
const profiles = deriveProfileMembership(capMap);
// Must not throw
assert.doesNotThrow(
() => runConsistencyGate(clusters, profiles, capMap),
'A cap with no matching CLUSTERS entry should not throw (new cluster is fine)',
);
});
test('HARD gate: extra skill in derived set (more than hand-authored) also throws', () => {
// 'ui' cap with an extra skill triggers the mismatch
const extraUiCap = makeSyntheticCap('ui', 'standard', ['ui-phase', 'ui-review', 'ui-extra']);
const capMap = new Map([['ui', extraUiCap]]);
const clusters = deriveCapabilityClusters(capMap);
const profiles = deriveProfileMembership(capMap);
assert.throws(
() => runConsistencyGate(clusters, profiles, capMap),
(err) => {
assert.ok(err instanceof Error);
assert.ok(err.message.includes('ui'), 'Error must name the capId');
return true;
},
);
});
});
describe('ADR-857 phase 4a: profileMembership derivation', () => {
test('tier core → profiles [core, standard, full]', () => {
const cap = makeSyntheticCap('core-cap', 'core', ['core-skill']);
const capMap = new Map([['core-cap', cap]]);
const profiles = deriveProfileMembership(capMap);
assert.ok(profiles['core-cap'], 'profileMembership should have core-cap');
assert.strictEqual(profiles['core-cap'].tier, 'core');
assert.deepEqual(
profiles['core-cap'].profiles,
['core', 'standard', 'full'],
'core tier should produce [core, standard, full], got: ' + JSON.stringify(profiles['core-cap'].profiles),
);
});
test('tier standard → profiles [standard, full]', () => {
const cap = makeSyntheticCap('std-cap', 'standard', ['std-skill']);
const capMap = new Map([['std-cap', cap]]);
const profiles = deriveProfileMembership(capMap);
assert.ok(profiles['std-cap'], 'profileMembership should have std-cap');
assert.strictEqual(profiles['std-cap'].tier, 'standard');
assert.deepEqual(
profiles['std-cap'].profiles,
['standard', 'full'],
'standard tier should produce [standard, full], got: ' + JSON.stringify(profiles['std-cap'].profiles),
);
});
test('tier full → profiles [full]', () => {
const cap = makeSyntheticCap('full-cap', 'full', ['full-skill']);
const capMap = new Map([['full-cap', cap]]);
const profiles = deriveProfileMembership(capMap);
assert.ok(profiles['full-cap'], 'profileMembership should have full-cap');
assert.strictEqual(profiles['full-cap'].tier, 'full');
assert.deepEqual(
profiles['full-cap'].profiles,
['full'],
'full tier should produce [full], got: ' + JSON.stringify(profiles['full-cap'].profiles),
);
});
test('PROFILE_RANK is imported (not hardcoded): all three tiers covered', () => {
// Verify PROFILE_RANK is the canonical ['core', 'standard', 'full'] from install-profiles.cjs
assert.deepEqual(
PROFILE_RANK,
['core', 'standard', 'full'],
'PROFILE_RANK must be [core, standard, full] from install-profiles.cjs, got: ' + JSON.stringify(PROFILE_RANK),
);
});
test('ui cap (tier full after reconciliation) profileMembership is [full]', () => {
// ADR-857 phase 4c: ui tier changed from standard → full
const capMap = new Map([['ui', UI_CAP]]);
const profiles = deriveProfileMembership(capMap);
assert.deepEqual(
profiles.ui.profiles,
['full'],
'ui (tier full) should have profiles [full] after reconciliation',
);
});
test('buildRegistry includes profileMembership with correct ui value', () => {
const capDir = makeTempCapDir({ ui: UI_CAP });
const { capMap } = loadAndValidate(new Set(), capDir);
const registry = buildRegistry(capMap);
assert.ok(registry.profileMembership, 'registry.profileMembership should exist');
// After ADR-857 phase 4c reconciliation: ui is tier:full → profiles: ['full'] only
assert.deepEqual(
registry.profileMembership.ui,
{ tier: 'full', profiles: ['full'] },
'profileMembership.ui should be { tier: full, profiles: [full] } after reconciliation',
);
});
test('serializeRegistry emits profileMembership block in generated .cjs', () => {
const capDir = makeTempCapDir({ ui: UI_CAP });
const { capMap } = loadAndValidate(new Set(), capDir);
const registry = buildRegistry(capMap);
const content = serializeRegistry(registry, capMap);
assert.ok(content.includes('const profileMembership'), 'Generated file must contain "const profileMembership"');
// After ADR-857 phase 4c reconciliation: ui is tier:full → profileMembership contains "full"
assert.ok(content.includes('"full"'), 'Generated file must contain "full" in profileMembership');
assert.ok(content.includes('profileMembership,'), 'module.exports must include profileMembership');
});
test('committed capability-registry.cjs has profileMembership with correct ui value', () => {
const registry = require('../gsd-core/bin/lib/capability-registry.cjs');
assert.ok(registry.profileMembership, 'capability-registry.cjs must export profileMembership');
// After ADR-857 phase 4c reconciliation: ui is tier:full → profiles: ['full'] only
assert.deepEqual(
registry.profileMembership.ui,
{ tier: 'full', profiles: ['full'] },
'committed profileMembership.ui should be { tier: full, profiles: [full] } after reconciliation',
);
});
});
describe('ADR-857 phase 4a: pending-reconciliation warnings (SOFT gate)', () => {
test('ui (tier full) generates ZERO pending-reconciliation warnings (ADR-857 phase 4c reconciliation)', () => {
// After reconciliation: ui is tier:full. The full profile is '*' (every skill).
// The consistency gate must NOT fire for full-tier capabilities — their skills are
// always present in the full profile by definition.
const capMap = new Map([['ui', UI_CAP]]);
const clusters = deriveCapabilityClusters(capMap);
const profiles = deriveProfileMembership(capMap);
const warnings = runConsistencyGate(clusters, profiles, capMap);
const uiPhaseWarn = warnings.find((w) => w.includes('ui-phase'));
const uiReviewWarn = warnings.find((w) => w.includes('ui-review'));
assert.ok(
!uiPhaseWarn,
'No pending-reconciliation warning expected for ui-phase (tier:full), got: ' + JSON.stringify(warnings),
);
assert.ok(
!uiReviewWarn,
'No pending-reconciliation warning expected for ui-review (tier:full), got: ' + JSON.stringify(warnings),
);
});
test('ui reconciled: profileMembership.ui.profiles is ["full"] after tier:full reconciliation', () => {
// After reconciliation: ui is tier:full → profileMembership.ui.profiles = ['full'] only.
// ui-phase/ui-review are correctly absent from core/standard (they're full-only features).
// No pending-reconciliation warning fires because full='*' always satisfies the gate.
const capMap = new Map([['ui', UI_CAP]]);
const profiles = deriveProfileMembership(capMap);
assert.deepStrictEqual(
profiles.ui.profiles,
['full'],
'After tier:full reconciliation, ui profileMembership should be ["full"] only',
);
assert.strictEqual(
profiles.ui.tier,
'full',
'ui tier should be "full" after reconciliation',
);
// Confirm ui-phase is NOT in standard profile — that's expected and correct for full-tier skills.
const { resolveProfile: rp } = require('../gsd-core/bin/lib/install-profiles.cjs');
const resolved = rp({ modes: ['standard'], manifest: new Map() });
assert.ok(
resolved.skills !== '*',
'standard profile should not be full',
);
assert.ok(
!resolved.skills.has('ui-phase'),
'ui-phase should NOT be in hand-authored standard profile (correctly full-only after reconciliation)',
);
assert.ok(
!resolved.skills.has('ui-review'),
'ui-review should NOT be in hand-authored standard profile (correctly full-only after reconciliation)',
);
});
test('SOFT gate does NOT throw — only returns warnings', () => {
// Even with reconciliation gaps, runConsistencyGate must NOT throw
const capMap = new Map([['ui', UI_CAP]]);
const clusters = deriveCapabilityClusters(capMap);
const profiles = deriveProfileMembership(capMap);
let warnings;
assert.doesNotThrow(
() => { warnings = runConsistencyGate(clusters, profiles, capMap); },
'SOFT gate must not throw — only collect warnings',
);
assert.ok(Array.isArray(warnings), 'runConsistencyGate must return an array');
});
test('buildRegistry._reconciliationWarnings is empty for reconciled ui (tier:full)', () => {
// After reconciliation: ui is tier:full → no pending-reconciliation warnings.
const capDir = makeTempCapDir({ ui: UI_CAP });
const { capMap } = loadAndValidate(new Set(), capDir);
const registry = buildRegistry(capMap);
assert.ok(
Array.isArray(registry._reconciliationWarnings),
'registry._reconciliationWarnings should be an array',
);
const uiWarnings = registry._reconciliationWarnings.filter(
(w) => w.includes('ui-phase') || w.includes('ui-review')
);
assert.deepStrictEqual(
uiWarnings,
[],
'No reconciliation warnings expected for reconciled ui capability, got: ' + JSON.stringify(uiWarnings),
);
});
test('reconciliation warnings are NOT in serialized registry output (determinism gate)', () => {
// Warnings must appear ONLY on stderr, not in the generated .cjs file
const capDir = makeTempCapDir({ ui: UI_CAP });
const { capMap } = loadAndValidate(new Set(), capDir);
const registry = buildRegistry(capMap);
const content = serializeRegistry(registry, capMap);
assert.ok(
!content.includes('pending-reconciliation'),
'Serialized registry must NOT contain "pending-reconciliation" text (warnings are stderr-only)',
);
assert.ok(
!content.includes('_reconciliationWarnings'),
'Serialized registry must NOT contain _reconciliationWarnings key',
);
});
test('a cap whose skill IS already in the standard profile emits no reconciliation warning', () => {
// 'plan-phase' IS in the hand-authored standard profile. A synthetic cap
// with tier=standard and skill=plan-phase should NOT generate a warning.
const cap = makeSyntheticCap('planner-cap', 'standard', ['plan-phase']);
const capMap = new Map([['planner-cap', cap]]);
const clusters = deriveCapabilityClusters(capMap);
const profiles = deriveProfileMembership(capMap);
const warnings = runConsistencyGate(clusters, profiles, capMap);
const planPhaseWarnings = warnings.filter((w) => w.includes('plan-phase'));
assert.deepEqual(
planPhaseWarnings, [],
'No reconciliation warning expected for plan-phase (already in standard profile), got: ' + JSON.stringify(planPhaseWarnings),
);
});
test('a core-tier cap with skills already in core profile emits no reconciliation warning', () => {
// 'new-project' IS in the hand-authored core profile.
const cap = makeSyntheticCap('np-cap', 'core', ['new-project']);
const capMap = new Map([['np-cap', cap]]);
const clusters = deriveCapabilityClusters(capMap);
const profiles = deriveProfileMembership(capMap);
const warnings = runConsistencyGate(clusters, profiles, capMap);
const npWarnings = warnings.filter((w) => w.includes('new-project'));
assert.deepEqual(
npWarnings, [],
'No reconciliation warning expected for new-project (already in core profile), got: ' + JSON.stringify(npWarnings),
);
});
});
describe('ADR-857 phase 4a: requires-closure tier-monotone (synthetic)', () => {
test('tier-monotone: a required capability must be same-or-lower tier', () => {
// Cap A at 'core' requiring cap B at 'standard' violates tier-monotone.
// validateCrossCapability already tests this; here we verify the rule via
// a profileMembership structural check: if A is core → B must have rank ≤ core.
const capA = makeSyntheticCap('tier-a', 'core', ['a-skill']);
capA.requires = ['tier-b'];
const capB = makeSyntheticCap('tier-b', 'standard', ['b-skill']);
const capMap = new Map([['tier-a', capA], ['tier-b', capB]]);
// validateCrossCapability enforces the rule
const { validateCrossCapability: vcc } = require('../scripts/gen-capability-registry.cjs');
const errors = vcc(capMap, new Set());
assert.ok(
errors.some((e) => e.includes('tier-monotone')),
'Expected tier-monotone error, got: ' + JSON.stringify(errors),
);
});
test('tier-monotone: same-tier requires is accepted', () => {
const capA = makeSyntheticCap('mono-a', 'standard', ['ma-skill']);
capA.requires = ['mono-b'];
const capB = makeSyntheticCap('mono-b', 'standard', ['mb-skill']);
const capMap = new Map([['mono-a', capA], ['mono-b', capB]]);
const { validateCrossCapability: vcc } = require('../scripts/gen-capability-registry.cjs');
const errors = vcc(capMap, new Set());
const monotoneErrors = errors.filter((e) => e.includes('tier-monotone'));
assert.deepEqual(monotoneErrors, [], 'Same-tier requires should be accepted, got: ' + JSON.stringify(monotoneErrors));
});
test('tier-monotone: higher-tier requiring lower-tier is accepted (full requires core)', () => {
const capA = makeSyntheticCap('full-a', 'full', ['fa-skill']);
capA.requires = ['core-b'];
const capB = makeSyntheticCap('core-b', 'core', ['cb-skill']);
const capMap = new Map([['full-a', capA], ['core-b', capB]]);
const { validateCrossCapability: vcc } = require('../scripts/gen-capability-registry.cjs');
const errors = vcc(capMap, new Set());
const monotoneErrors = errors.filter((e) => e.includes('tier-monotone'));
assert.deepEqual(
monotoneErrors, [],
'full requiring core should be accepted (higher tier can require lower tier), got: ' + JSON.stringify(monotoneErrors),
);
});
});
describe('ADR-857 phase 4a: --check determinism after --write', () => {
test('serializeRegistry produces identical output for two calls (determinism)', () => {
// Regression guard: --check would fail if output is non-deterministic
const capDir = makeTempCapDir({ ui: UI_CAP });
const { capMap } = loadAndValidate(new Set(), capDir);
const registry = buildRegistry(capMap);
const content1 = serializeRegistry(registry, capMap);
const content2 = serializeRegistry(registry, capMap);
assert.strictEqual(content1, content2, 'serializeRegistry output must be deterministic');
});
});
// ─── 17. FIX 1: SOFT gate uses real manifest for requires-closure expansion ────
describe('FIX 1: SOFT gate uses closure-resolved manifest (not empty)', () => {
test('plan-phase is transitively in standard — no reconciliation warning', () => {
// plan-phase is in PROFILES.standard directly; resolved (with real manifest) = in standard.
// A standard-tier cap with skill=plan-phase must NOT generate a reconciliation warning.
const cap = makeSyntheticCap('plan-cap', 'standard', ['plan-phase']);
const capMap = new Map([['plan-cap', cap]]);
const clusters = deriveCapabilityClusters(capMap);
const profiles = deriveProfileMembership(capMap);
const warnings = runConsistencyGate(clusters, profiles, capMap);
const planWarnings = warnings.filter((w) => w.includes('plan-phase'));
assert.deepEqual(
planWarnings, [],
'plan-phase is in standard profile — no warning expected, got: ' + JSON.stringify(planWarnings),
);
});
test('FIX 1: skill only transitively in standard (requires-closure) emits no warning', () => {
// 'code-review' is brought into standard via requires-closure expansion (not in raw base).
// FIX 1 ensures the real manifest is used, so no false-positive warning is emitted.
const cap = makeSyntheticCap('cr-cap', 'standard', ['code-review']);
const capMap = new Map([['cr-cap', cap]]);
const clusters = deriveCapabilityClusters(capMap);
const profiles = deriveProfileMembership(capMap);
const warnings = runConsistencyGate(clusters, profiles, capMap);
const crWarnings = warnings.filter((w) => w.includes('code-review'));
assert.deepEqual(
crWarnings, [],
'code-review is transitively in standard via requires-closure — no warning expected, got: ' + JSON.stringify(crWarnings),
);
});
});
// ─── 18. FIX 2: globally-sorted capId emission ───────────────────────────────
describe('FIX 2: globally-sorted capId emission (determinism with mixed feature+runtime)', () => {
test('feature cap "analytics" and feature cap "ui" are globally sorted in serialized output', () => {
// "analytics" < "ui" alphabetically — must appear first in both derived views
const analyticsCap = makeSyntheticCap('analytics', 'standard', ['analytics-skill']);
const capDir = makeTempCapDir({ analytics: analyticsCap, ui: UI_CAP });
const { capMap } = loadAndValidate(new Set(), capDir);
const registry = buildRegistry(capMap);
const content = serializeRegistry(registry, capMap);
// Find the positions of "analytics" and "ui" in the capabilityClusters block
const clustersStart = content.indexOf('const capabilityClusters');
const clustersEnd = content.indexOf('const profileMembership');
const clustersBlock = content.slice(clustersStart, clustersEnd);
const analyticsPos = clustersBlock.indexOf('"analytics"');
const uiPos = clustersBlock.indexOf('"ui"');
assert.ok(
analyticsPos < uiPos,
'analytics must appear before ui in capabilityClusters (global alphabetical sort)',
);
// Same check for profileMembership
const profileStart = content.indexOf('const profileMembership');
const profileEnd = content.indexOf('const _requiresGraph');
const profileBlock = content.slice(profileStart, profileEnd);
const analyticsProfilePos = profileBlock.indexOf('"analytics"');
const uiProfilePos = profileBlock.indexOf('"ui"');
assert.ok(
analyticsProfilePos < uiProfilePos,
'analytics must appear before ui in profileMembership (global alphabetical sort)',
);
});
test('serialized output is stable across two calls (determinism)', () => {
const analyticsCap = makeSyntheticCap('analytics', 'standard', ['analytics-skill']);
const capDir = makeTempCapDir({ analytics: analyticsCap, ui: UI_CAP });
const { capMap } = loadAndValidate(new Set(), capDir);
const registry = buildRegistry(capMap);
const s1 = serializeRegistry(registry, capMap);
const s2 = serializeRegistry(registry, capMap);
assert.strictEqual(s1, s2, 'Two serializeRegistry calls must produce identical output');
});
});
// ─── 19. FIX 3: consistent role scoping across both derived views ─────────────
describe('FIX 3: consistent scope — capabilities that own skills', () => {
test('feature cap with empty skills does not appear in capabilityClusters', () => {
// A feature cap with an empty skills array must NOT appear in capabilityClusters
const emptySkillsCap = {
id: 'empty-skills', role: 'feature', title: 'Empty', description: 'No skills',
tier: 'standard', requires: [],
skills: [], agents: [], hooks: [], config: {}, steps: [], contributions: [], gates: [],
};
const capMap = new Map([['empty-skills', emptySkillsCap]]);
const clusters = deriveCapabilityClusters(capMap);
assert.ok(
!Object.prototype.hasOwnProperty.call(clusters, 'empty-skills'),
'Cap with empty skills array must not appear in capabilityClusters',
);
});
test('feature cap with empty skills does not appear in profileMembership', () => {
// FIX 3: profileMembership must also exclude caps with no skills (consistent scope)
const emptySkillsCap = {
id: 'empty-skills', role: 'feature', title: 'Empty', description: 'No skills',
tier: 'standard', requires: [],
skills: [], agents: [], hooks: [], config: {}, steps: [], contributions: [], gates: [],
};
const capMap = new Map([['empty-skills', emptySkillsCap]]);
const profiles = deriveProfileMembership(capMap);
assert.ok(
!Object.prototype.hasOwnProperty.call(profiles, 'empty-skills'),
'Cap with empty skills array must not appear in profileMembership (FIX 3: consistent scope)',
);
});
});
// ─── 20. FIX 4: de-duplicated reconciliation warnings ────────────────────────
describe('FIX 4: de-duplicated reconciliation warnings (one per skill, not per profile)', () => {
test('core-tier cap with skill missing from both core and standard emits ONE warning', () => {
// ui-phase is not in the hand-authored core or standard profiles.
// A core-tier cap with ui-phase must emit exactly 1 warning (listing both profiles).
const cap = makeSyntheticCap('core-ui-cap', 'core', ['ui-phase']);
const capMap = new Map([['core-ui-cap', cap]]);
const clusters = deriveCapabilityClusters(capMap);
const profiles = deriveProfileMembership(capMap);
const warnings = runConsistencyGate(clusters, profiles, capMap);
const uiPhaseWarnings = warnings.filter((w) => w.includes('ui-phase'));
assert.strictEqual(
uiPhaseWarnings.length, 1,
'Expected exactly 1 warning for ui-phase (FIX 4: one per skill, not per profile), got: ' + JSON.stringify(uiPhaseWarnings),
);
// Warning must list both missing profiles
assert.ok(
uiPhaseWarnings[0].includes('core') && uiPhaseWarnings[0].includes('standard'),
'Warning must list both missing profiles (core and standard), got: ' + uiPhaseWarnings[0],
);
});
test('standard-tier cap with skill missing from standard emits ONE warning with <standard>', () => {
const cap = makeSyntheticCap('std-ui-cap', 'standard', ['ui-phase']);
const capMap = new Map([['std-ui-cap', cap]]);
const clusters = deriveCapabilityClusters(capMap);
const profiles = deriveProfileMembership(capMap);
const warnings = runConsistencyGate(clusters, profiles, capMap);
assert.strictEqual(warnings.length, 1, 'Expected exactly 1 warning, got: ' + JSON.stringify(warnings));
assert.ok(
warnings[0].includes('profile(s): <standard>'),
'Warning must use "profile(s): <standard>" format, got: ' + warnings[0],
);
});
});
// ─── 21. FIX 5: tierIdx -1 throws loudly ─────────────────────────────────────
describe('FIX 5: tierIdx === -1 throws loudly (VALID_TIERS/PROFILE_RANK drift guard)', () => {
test('normal usage (standard/core/full) does not throw in deriveProfileMembership', () => {
const cap = makeSyntheticCap('drift-test', 'standard', ['s1']);
const capMap = new Map([['drift-test', cap]]);
assert.doesNotThrow(
() => deriveProfileMembership(capMap),
'deriveProfileMembership must not throw for valid tiers',
);
});
});
// ─── 22. FIX 6: UI true-negative doesNotThrow ─────────────────────────────────
describe('FIX 6: runConsistencyGate does NOT throw for real UI capability (true-negative)', () => {
test('buildRegistry with real UI cap does not throw (HARD gate true-negative)', () => {
// The real UI cap has skills = [ui-phase, ui-review] which matches CLUSTERS.ui exactly.
// The HARD gate must NOT throw.
const capDir = makeTempCapDir({ ui: UI_CAP });
const { capMap } = loadAndValidate(new Set(), capDir);
assert.doesNotThrow(
() => buildRegistry(capMap),
'buildRegistry must not throw for the real UI capability (CLUSTERS match expected)',
);
});
test('runConsistencyGate does NOT throw for real UI cap (cluster match true-negative)', () => {
// Explicit doesNotThrow covering runConsistencyGate directly
const capMap = new Map([['ui', UI_CAP]]);
const clusters = deriveCapabilityClusters(capMap);
const profiles = deriveProfileMembership(capMap);
assert.doesNotThrow(
() => runConsistencyGate(clusters, profiles, capMap),
'runConsistencyGate must not throw for UI cap (CLUSTERS.ui matches ui.skills)',
);
});
});
// ─── 23. ADR-959: commands field + commandFamilies index ──────────────────────
/**
* Build a minimal feature capability for ADR-959 command tests.
* skills/agents/etc. kept minimal-valid so validateCapability passes.
*/
function makeCommandCap(id, commands) {
return {
id,
role: 'feature',
version: '1.0.0',
title: 'Test cap ' + id,
description: 'Synthetic capability for ADR-959 command tests.',
tier: 'full',
requires: [],
runtimeCompat: { supported: ['*'], unsupported: [] },
skills: [],
agents: [],
hooks: [],
config: {},
steps: [],
contributions: [],
gates: [],
commands,
};
}
describe('ADR-959: validateCommandEntry — valid entry', () => {
test('valid minimal entry (no subcommands) passes', () => {
const errors = validateCommandEntry('my-cap', { family: 'foo', module: 'foo.cjs', router: 'routeFoo' }, 'commands[0]');
assert.deepEqual(errors, []);
});
test('valid entry with subcommands passes', () => {
const errors = validateCommandEntry('my-cap', {
family: 'bar',
module: 'bar-router.cjs',
router: 'routeBar',
subcommands: ['query', 'status'],
}, 'commands[0]');
assert.deepEqual(errors, []);
});
});
describe('ADR-959: validateCommandEntry — adversarial rejects', () => {
test('missing family → error', () => {
const errors = validateCommandEntry('my-cap', { module: 'foo.cjs', router: 'routeFoo' }, 'commands[0]');
assert.ok(errors.some((e) => e.includes('family')), 'Expected family error, got: ' + JSON.stringify(errors));
});
test('empty family → error', () => {
const errors = validateCommandEntry('my-cap', { family: '', module: 'foo.cjs', router: 'routeFoo' }, 'commands[0]');
assert.ok(errors.some((e) => e.includes('family')), 'Expected family error, got: ' + JSON.stringify(errors));
});
test('missing module → error', () => {
const errors = validateCommandEntry('my-cap', { family: 'foo', router: 'routeFoo' }, 'commands[0]');
assert.ok(errors.some((e) => e.includes('module')), 'Expected module error, got: ' + JSON.stringify(errors));
});
test('missing router → error', () => {
const errors = validateCommandEntry('my-cap', { family: 'foo', module: 'foo.cjs' }, 'commands[0]');
assert.ok(errors.some((e) => e.includes('router')), 'Expected router error, got: ' + JSON.stringify(errors));
});
test('non-string router → error', () => {
const errors = validateCommandEntry('my-cap', { family: 'foo', module: 'foo.cjs', router: 42 }, 'commands[0]');
assert.ok(errors.some((e) => e.includes('router')), 'Expected router error, got: ' + JSON.stringify(errors));
});
test('traversal module "../evil.cjs" → error', () => {
const errors = validateCommandEntry('my-cap', { family: 'foo', module: '../evil.cjs', router: 'r' }, 'commands[0]');
assert.ok(errors.some((e) => e.includes('module')), 'Expected module traversal error, got: ' + JSON.stringify(errors));
});
test('absolute module "/abs/path.cjs" → error', () => {
const errors = validateCommandEntry('my-cap', { family: 'foo', module: '/abs/path.cjs', router: 'r' }, 'commands[0]');
assert.ok(errors.some((e) => e.includes('module')), 'Expected module absolute error, got: ' + JSON.stringify(errors));
});
test('module with "/" separator "lib/foo.cjs" → error', () => {
const errors = validateCommandEntry('my-cap', { family: 'foo', module: 'lib/foo.cjs', router: 'r' }, 'commands[0]');
assert.ok(errors.some((e) => e.includes('module')), 'Expected module separator error, got: ' + JSON.stringify(errors));
});
test('subcommands non-array → error', () => {
const errors = validateCommandEntry('my-cap', {
family: 'foo', module: 'foo.cjs', router: 'r', subcommands: 'not-array',
}, 'commands[0]');
assert.ok(errors.some((e) => e.includes('subcommands')), 'Expected subcommands error, got: ' + JSON.stringify(errors));
});
test('subcommands with non-string entry → error', () => {
const errors = validateCommandEntry('my-cap', {
family: 'foo', module: 'foo.cjs', router: 'r', subcommands: ['ok', 42],
}, 'commands[0]');
assert.ok(errors.some((e) => e.includes('subcommands')), 'Expected subcommands[1] error, got: ' + JSON.stringify(errors));
});
});
describe('ADR-959: validateCrossCapability — duplicate family ownership', () => {
test('duplicate family across two capabilities → error', () => {
const capA = makeCommandCap('cap-a', [{ family: 'shared', module: 'a.cjs', router: 'rA' }]);
const capB = makeCommandCap('cap-b', [{ family: 'shared', module: 'b.cjs', router: 'rB' }]);
const capMap = new Map([['cap-a', capA], ['cap-b', capB]]);
const errors = validateCrossCapability(capMap, new Set());
assert.ok(
errors.some((e) => e.includes('shared') && e.includes('cap-a') && e.includes('cap-b')),
'Expected duplicate family error mentioning both caps, got: ' + JSON.stringify(errors),
);
});
test('unique families in two capabilities → no error', () => {
const capA = makeCommandCap('cap-a', [{ family: 'alpha', module: 'alpha.cjs', router: 'rA' }]);
const capB = makeCommandCap('cap-b', [{ family: 'beta', module: 'beta.cjs', router: 'rB' }]);
const capMap = new Map([['cap-a', capA], ['cap-b', capB]]);
const errors = validateCrossCapability(capMap, new Set());
assert.ok(
!errors.some((e) => e.includes('owned by both')),
'Expected no duplicate-ownership error, got: ' + JSON.stringify(errors),
);
});
});
describe('ADR-959: buildRegistry — commandFamilies index shape', () => {
test('cap with valid commands entry produces commandFamilies entry', () => {
const cap = makeCommandCap('test-cmd', [
{ family: 'myfamily', module: 'myfamily.cjs', router: 'routeMyFamily' },
]);
const capMap = new Map([['test-cmd', cap]]);
const registry = buildRegistry(capMap);
assert.ok(registry.commandFamilies, 'commandFamilies must be present');
const entry = registry.commandFamilies['myfamily'];
assert.ok(entry, 'commandFamilies["myfamily"] must exist');
assert.strictEqual(entry.capId, 'test-cmd');
assert.strictEqual(entry.module, 'myfamily.cjs');
assert.strictEqual(entry.router, 'routeMyFamily');
});
test('cap without commands → commandFamilies is empty', () => {
const capDir = makeTempCapDir({ ui: UI_CAP });
const { capMap } = loadAndValidate(new Set(), capDir);
const registry = buildRegistry(capMap);
assert.ok(registry.commandFamilies, 'commandFamilies must be present');
assert.deepEqual(Object.keys(registry.commandFamilies), [], 'commandFamilies must be empty for real registry');
});
test('commandFamilies keys are sorted in serialized output (determinism)', () => {
// Two caps with commands in z→a order; expect a→z in the commandFamilies section
const capA = makeCommandCap('cap-a', [{ family: 'zebra', module: 'z.cjs', router: 'rZ' }]);
const capB = makeCommandCap('cap-b', [{ family: 'alpha', module: 'a.cjs', router: 'rA' }]);
const capMap = new Map([['cap-a', capA], ['cap-b', capB]]);
const registry = buildRegistry(capMap);
const serialized = serializeRegistry(registry, capMap);
// Find the commandFamilies section specifically (not the full capabilities JSON)
const cfStart = serialized.indexOf('const commandFamilies = ');
assert.ok(cfStart >= 0, 'commandFamilies section must be present');
const cfEnd = serialized.indexOf('\n};', cfStart) + 3; // closing }; of const assignment
const cfSection = serialized.slice(cfStart, cfEnd);
const alphaIdx = cfSection.indexOf('"alpha"');
const zebraIdx = cfSection.indexOf('"zebra"');
assert.ok(alphaIdx >= 0, '"alpha" must appear in commandFamilies section');
assert.ok(zebraIdx >= 0, '"zebra" must appear in commandFamilies section');
assert.ok(alphaIdx < zebraIdx, 'commandFamilies section must list "alpha" before "zebra" (sorted)');
});
});
describe('ADR-959: validateCapability — commands field on feature role', () => {
test('valid commands entry on feature cap passes validateCapability', () => {
const cap = makeCommandCap('cmd-cap', [
{ family: 'testfamily', module: 'testfamily.cjs', router: 'routeTestFamily' },
]);
const errors = validateCapability(cap, 'cmd-cap');
assert.deepEqual(errors, [], 'Expected no errors: ' + JSON.stringify(errors));
});
test('commands: null on feature cap → error', () => {
const cap = makeCommandCap('cmd-cap', null);
const errors = validateCapability(cap, 'cmd-cap');
assert.ok(errors.some((e) => e.includes('commands')), 'Expected commands error, got: ' + JSON.stringify(errors));
});
});
// ─── 24. ADR-1016 phase 5a: runtime capability descriptors ───────────────────
const {
validateConfigHome,
validateArtifactLayout,
VALID_CONFIG_HOME_KINDS,
VALID_COMMAND_STYLES,
VALID_HOOKS_SURFACES,
VALID_HOOK_EVENTS,
VALID_SANDBOX_TIERS,
VALID_ARTIFACT_KIND_NAMES,
VALID_ARTIFACT_NESTINGS,
} = require('../scripts/gen-capability-registry.cjs');
// VALID_EXTENSION_EVENTS is imported from the validator directly (not from
// gen-capability-registry) to avoid changing gen-capability-registry.cjs — that
// file is an installed artifact captured by golden-install-parity (#1943).
const VALID_EXTENSION_EVENTS = capValidatorModule.VALID_EXTENSION_EVENTS;
// Runtime ids are derived from the built registry (the single source of truth)
// so this file stays fluid when a runtime descriptor is added or removed.
const RUNTIME_IDS = Object.keys(buildRegistry(loadAndValidate(new Set()).capMap).runtimes);
// Helper: build a minimal valid runtime capability object for fixture-based tests
function makeRuntimeCap(overrides) {
return {
id: 'test-rt',
role: 'runtime',
version: '1.0.0',
title: 'Test Runtime',
description: 'A synthetic runtime capability for testing.',
tier: 'core',
requires: [],
runtime: {
configHome: { kind: 'dot-home', name: '.test-rt', env: ['TEST_RT_DIR'] },
localConfigDir: '.test-rt',
configFormat: 'settings-json',
artifactLayout: { global: [], local: [] },
commandStyle: 'slash-hyphen',
hooksSurface: 'settings-json',
hookEvents: 'claude',
sandboxTier: 'none',
supportTier: 1,
installSurface: 'settings-json',
writesSharedSettings: true,
permissionWriter: null,
extendedHookEvents: [],
hostIntegration: {
embeddingMode: 'imperative',
commandSurface: 'slash-file',
dispatch: { namedDispatch: true, nested: true, maxDepth: -1, background: true, subagentToolkit: 'full', backgroundDispatch: false },
modelMode: 'passive',
hookBus: 'host',
stateIO: 'filesystem',
transport: 'mcp',
runtime: 'node',
},
...((overrides && overrides.runtime) ? overrides.runtime : {}),
},
...overrides,
};
}
// ── 24a. Every role:runtime capability appears in the runtimes index ─────────
// Count-agnostic: the built registry's runtime set must exactly equal the set
// of capability folders whose descriptor declares role:"runtime". Adding a
// runtime descriptor extends coverage with zero edits here.
describe('ADR-1016 phase 5a: every role:runtime capability is in the registry index', () => {
let registry;
test('loadAndValidate + buildRegistry indexes exactly the role:runtime capabilities on disk', () => {
const { capMap, errors } = loadAndValidate(new Set());
const hardErrors = errors.filter((e) => !e.includes('pending-migration'));
assert.deepEqual(hardErrors, [], 'Expected no hard errors: ' + JSON.stringify(hardErrors));
registry = buildRegistry(capMap);
// Derive the expected runtime id set from the loaded capability map — not a
// hand-pinned count. This is the contract: every role:runtime descriptor on
// disk becomes a runtimes-index entry, no more, no less.
const expectedRuntimeIds = [...capMap.entries()]
.filter(([, cap]) => cap && cap.role === 'runtime')
.map(([id]) => id)
.sort();
const runtimeKeys = Object.keys(registry.runtimes).sort();
assert.ok(expectedRuntimeIds.length > 0, 'expected at least one role:runtime capability on disk');
assert.deepEqual(runtimeKeys, expectedRuntimeIds,
'runtimes index must exactly equal the role:runtime capabilities on disk. got: ' + runtimeKeys.join(', '));
for (const id of RUNTIME_IDS) {
assert.ok(
Object.prototype.hasOwnProperty.call(registry.runtimes, id),
'runtimes index must contain "' + id + '"',
);
}
});
test('every runtimes entry has role: "runtime"', () => {
const { capMap } = loadAndValidate(new Set());
registry = buildRegistry(capMap);
for (const id of RUNTIME_IDS) {
assert.strictEqual(
registry.runtimes[id] && registry.runtimes[id].role, 'runtime',
'runtimes["' + id + '"].role must be "runtime"',
);
}
});
test('every runtimes entry has all 6 axes present in runtime object', () => {
const { capMap } = loadAndValidate(new Set());
registry = buildRegistry(capMap);
const requiredAxes = ['configHome', 'configFormat', 'artifactLayout', 'commandStyle', 'hooksSurface', 'sandboxTier'];
for (const id of RUNTIME_IDS) {
const rt = registry.runtimes[id] && registry.runtimes[id].runtime;
assert.ok(rt, 'runtimes["' + id + '"].runtime must exist');
for (const axis of requiredAxes) {
assert.ok(
Object.prototype.hasOwnProperty.call(rt, axis),
'runtimes["' + id + '"].runtime.' + axis + ' must be present',
);
}
// supportTier also required
assert.ok(
rt.supportTier === 1 || rt.supportTier === 2,
'runtimes["' + id + '"].runtime.supportTier must be 1 or 2 (got: ' + rt.supportTier + ')',
);
}
});
});
// ── 24b. Sample axis value assertions ────────────────────────────────────────
describe('ADR-1016 phase 5a: sample axis value assertions', () => {
test('codex: commandStyle === shell-var and sandboxTier === codex-agent-sandbox', () => {
const { capMap } = loadAndValidate(new Set());
const registry = buildRegistry(capMap);
const rt = registry.runtimes['codex'].runtime;
assert.strictEqual(rt.commandStyle, 'shell-var', 'codex.commandStyle must be "shell-var"');
assert.strictEqual(rt.sandboxTier, 'codex-agent-sandbox', 'codex.sandboxTier must be "codex-agent-sandbox"');
});
test('codex: configHome.kind === dot-home, name === .codex', () => {
const { capMap } = loadAndValidate(new Set());
const registry = buildRegistry(capMap);
const rt = registry.runtimes['codex'].runtime;
assert.strictEqual(rt.configHome.kind, 'dot-home', 'codex.configHome.kind must be "dot-home"');
assert.strictEqual(rt.configHome.name, '.codex', 'codex.configHome.name must be ".codex"');
assert.ok(Array.isArray(rt.configHome.env) && rt.configHome.env.includes('CODEX_HOME'),
'codex.configHome.env must include "CODEX_HOME"');
});
test('claude: commandStyle === slash-hyphen, sandboxTier === none', () => {
const { capMap } = loadAndValidate(new Set());
const registry = buildRegistry(capMap);
const rt = registry.runtimes['claude'].runtime;
assert.strictEqual(rt.commandStyle, 'slash-hyphen', 'claude.commandStyle must be "slash-hyphen"');
assert.strictEqual(rt.sandboxTier, 'none', 'claude.sandboxTier must be "none"');
});
test('claude: configHome.kind === dot-home', () => {
const { capMap } = loadAndValidate(new Set());
const registry = buildRegistry(capMap);
const rt = registry.runtimes['claude'].runtime;
assert.strictEqual(rt.configHome.kind, 'dot-home', 'claude.configHome.kind must be "dot-home"');
});
test('antigravity: configHome.kind === dot-home-nested with parent .gemini', () => {
const { capMap } = loadAndValidate(new Set());
const registry = buildRegistry(capMap);
const rt = registry.runtimes['antigravity'].runtime;
assert.strictEqual(rt.configHome.kind, 'dot-home-nested', 'antigravity.configHome.kind must be "dot-home-nested"');
assert.strictEqual(rt.configHome.parent, '.gemini', 'antigravity.configHome.parent must be ".gemini"');
assert.ok(Array.isArray(rt.configHome.probe), 'antigravity.configHome.probe must be an array');
assert.ok(rt.configHome.probe.length > 0, 'antigravity.configHome.probe must be non-empty');
});
test('kilo: configHome.skillsHome is present', () => {
const { capMap } = loadAndValidate(new Set());
const registry = buildRegistry(capMap);
const rt = registry.runtimes['kilo'].runtime;
assert.ok(rt.configHome.skillsHome, 'kilo.configHome.skillsHome must be present');
assert.ok(typeof rt.configHome.skillsHome.kind === 'string', 'kilo.configHome.skillsHome.kind must be a string');
});
test('windsurf: configHome.kind === dot-home-nested with parent .codeium', () => {
const { capMap } = loadAndValidate(new Set());
const registry = buildRegistry(capMap);
const rt = registry.runtimes['windsurf'].runtime;
assert.strictEqual(rt.configHome.kind, 'dot-home-nested', 'windsurf.configHome.kind must be "dot-home-nested"');
assert.strictEqual(rt.configHome.parent, '.codeium', 'windsurf.configHome.parent must be ".codeium"');
});
test('kimi: configHome.kind === generic-agents-root', () => {
const { capMap } = loadAndValidate(new Set());
const registry = buildRegistry(capMap);
const rt = registry.runtimes['kimi'].runtime;
assert.strictEqual(rt.configHome.kind, 'generic-agents-root', 'kimi.configHome.kind must be "generic-agents-root"');
});
test('antigravity: hookEvents === gemini', () => {
const { capMap } = loadAndValidate(new Set());
const registry = buildRegistry(capMap);
const rt = registry.runtimes['antigravity'].runtime;
assert.strictEqual(rt.hookEvents, 'gemini', 'antigravity.hookEvents must be "gemini"');
});
test('opencode: hooksSurface === none, no hookEvents (registers zero lifecycle hooks)', () => {
const { capMap } = loadAndValidate(new Set());
const registry = buildRegistry(capMap);
const rt = registry.runtimes['opencode'].runtime;
assert.strictEqual(rt.hooksSurface, 'none', 'opencode.hooksSurface must be "none" (no managed lifecycle hooks)');
assert.ok(
!Object.prototype.hasOwnProperty.call(rt, 'hookEvents'),
'opencode.runtime must NOT have hookEvents (no lifecycle hook registration)',
);
});
test('opencode: extensionEvents === opencode (the extension-system event dialect — #1943)', () => {
const { capMap } = loadAndValidate(new Set());
const registry = buildRegistry(capMap);
const rt = registry.runtimes['opencode'].runtime;
assert.strictEqual(rt.extensionEvents, 'opencode',
'opencode declares the extension-system event subset via extensionEvents (NOT hookEvents)');
});
test('kilo: hooksSurface === none, no hookEvents (registers zero lifecycle hooks)', () => {
const { capMap } = loadAndValidate(new Set());
const registry = buildRegistry(capMap);
const rt = registry.runtimes['kilo'].runtime;
assert.strictEqual(rt.hooksSurface, 'none', 'kilo.hooksSurface must be "none" (no managed lifecycle hooks)');
assert.ok(
!Object.prototype.hasOwnProperty.call(rt, 'hookEvents'),
'kilo.runtime must NOT have hookEvents (no lifecycle hook registration)',
);
});
test('kimi: configHome.probeExists === "skills" (probe selects first candidate with skills/ dir)', () => {
const { capMap } = loadAndValidate(new Set());
const registry = buildRegistry(capMap);
const rt = registry.runtimes['kimi'].runtime;
assert.strictEqual(
rt.configHome.probeExists, 'skills',
'kimi.configHome.probeExists must be "skills" (selects first probe candidate where <candidate>/skills exists)',
);
});
test('copilot/trae/windsurf/cline/opencode/kilo: hooksSurface none or copilot-inline/cline-rules, no hookEvents', () => {
const { capMap } = loadAndValidate(new Set());
const registry = buildRegistry(capMap);
// opencode and kilo register ZERO lifecycle hooks → hooksSurface none, no hookEvents
// #2095: kimi moved OUT of this group — it now has hooksSurface:'kimi-hooks-toml'
// and hookEvents:'claude' (see the dedicated kimi hooksSurface test below).
const noEventsRuntimes = ['trae', 'windsurf', 'opencode', 'kilo'];
for (const id of noEventsRuntimes) {
const rt = registry.runtimes[id].runtime;
assert.ok(
!Object.prototype.hasOwnProperty.call(rt, 'hookEvents'),
id + '.runtime must NOT have hookEvents (no hook events for this runtime)',
);
}
// cline has cline-rules surface but no hookEvents
const clineRt = registry.runtimes['cline'].runtime;
assert.strictEqual(clineRt.hooksSurface, 'cline-rules', 'cline.hooksSurface must be "cline-rules"');
assert.ok(
!Object.prototype.hasOwnProperty.call(clineRt, 'hookEvents'),
'cline.runtime must NOT have hookEvents',
);
// copilot has copilot-inline surface but no hookEvents
const copilotRt = registry.runtimes['copilot'].runtime;
assert.strictEqual(copilotRt.hooksSurface, 'copilot-inline', 'copilot.hooksSurface must be "copilot-inline"');
assert.ok(
!Object.prototype.hasOwnProperty.call(copilotRt, 'hookEvents'),
'copilot.runtime must NOT have hookEvents',
);
});
test('kimi: hooksSurface === kimi-hooks-toml, hookEvents === claude, extendedHookEvents wired (#2095)', () => {
const { capMap } = loadAndValidate(new Set());
const registry = buildRegistry(capMap);
const kimiRt = registry.runtimes['kimi'].runtime;
assert.strictEqual(kimiRt.hooksSurface, 'kimi-hooks-toml', 'kimi.hooksSurface must be "kimi-hooks-toml"');
assert.strictEqual(kimiRt.hookEvents, 'claude', 'kimi.hookEvents must be "claude" (Kimi\'s 13 lifecycle events include exact-name equivalents for the Claude dialect)');
assert.deepStrictEqual(
[...kimiRt.extendedHookEvents].sort(),
['PreCompact', 'Stop', 'SubagentStart', 'SubagentStop'].sort(),
'kimi.extendedHookEvents must wire SubagentStop/Stop/PreCompact/SubagentStart',
);
// installSurface stays profile-marker-only — the native config.toml
// [[hooks]] write is independent of the artifact-install surface (#2095).
assert.strictEqual(kimiRt.installSurface, 'profile-marker-only', 'kimi.installSurface must remain "profile-marker-only"');
});
test('codex: hooksSurface === codex-hooks-json, cursor: hooksSurface === cursor-hooks-json', () => {
const { capMap } = loadAndValidate(new Set());
const registry = buildRegistry(capMap);
assert.strictEqual(registry.runtimes['codex'].runtime.hooksSurface, 'codex-hooks-json',
'codex.hooksSurface must be "codex-hooks-json"');
assert.strictEqual(registry.runtimes['cursor'].runtime.hooksSurface, 'cursor-hooks-json',
'cursor.hooksSurface must be "cursor-hooks-json"');
});
test('tier-1 runtimes: claude, codex, antigravity have supportTier === 1', () => {
const { capMap } = loadAndValidate(new Set());
const registry = buildRegistry(capMap);
for (const id of ['claude', 'codex', 'antigravity']) {
assert.strictEqual(
registry.runtimes[id].runtime.supportTier, 1,
id + '.runtime.supportTier must be 1 (tier-1 support)',
);
}
});
test('tier-2 runtimes: cursor through windsurf have supportTier === 2', () => {
const { capMap } = loadAndValidate(new Set());
const registry = buildRegistry(capMap);
const tier2 = ['cursor', 'opencode', 'kilo', 'copilot', 'augment', 'trae', 'qwen', 'hermes', 'codebuddy', 'cline', 'kimi', 'windsurf'];
for (const id of tier2) {
assert.strictEqual(
registry.runtimes[id].runtime.supportTier, 2,
id + '.runtime.supportTier must be 2 (tier-2 support)',
);
}
});
});
// ── 24c. Closed-vocab REJECTION tests ────────────────────────────────────────
describe('ADR-1016 phase 5a: closed-vocab rejection — tightened validateRuntimeBody', () => {
test('bad configHome.kind → validation error', () => {
const cap = makeRuntimeCap({ id: 'test-rt', runtime: { configHome: { kind: 'custom-home', name: '.test', env: [] } } });
const errors = validateCapability(cap, 'test-rt');
assert.ok(
errors.some((e) => e.includes('configHome') && e.includes('kind')),
'Expected configHome.kind error, got: ' + JSON.stringify(errors),
);
});
test('configHome is a string (old shape) → validation error', () => {
const cap = makeRuntimeCap({ id: 'test-rt', runtime: { configHome: '~/.test-rt' } });
const errors = validateCapability(cap, 'test-rt');
assert.ok(
errors.some((e) => e.includes('configHome')),
'Expected configHome object error (string not accepted), got: ' + JSON.stringify(errors),
);
});
test('dot-home-nested without parent → validation error', () => {
const cap = makeRuntimeCap({ id: 'test-rt', runtime: { configHome: { kind: 'dot-home-nested', name: 'foo', env: [] } } });
const errors = validateCapability(cap, 'test-rt');
assert.ok(
errors.some((e) => e.includes('parent') && e.includes('dot-home-nested')),
'Expected parent required for dot-home-nested, got: ' + JSON.stringify(errors),
);
});
test('bad commandStyle → validation error', () => {
const cap = makeRuntimeCap({ id: 'test-rt', runtime: { commandStyle: 'slash-colon' } });
const errors = validateCapability(cap, 'test-rt');
assert.ok(
errors.some((e) => e.includes('commandStyle')),
'Expected commandStyle error, got: ' + JSON.stringify(errors),
);
});
test('bad hooksSurface → validation error', () => {
const cap = makeRuntimeCap({ id: 'test-rt', runtime: { hooksSurface: 'custom-hooks' } });
const errors = validateCapability(cap, 'test-rt');
assert.ok(
errors.some((e) => e.includes('hooksSurface')),
'Expected hooksSurface error, got: ' + JSON.stringify(errors),
);
});
test('bad hookEvents → validation error', () => {
const cap = makeRuntimeCap({ id: 'test-rt', runtime: { hookEvents: 'my-dialect' } });
const errors = validateCapability(cap, 'test-rt');
assert.ok(
errors.some((e) => e.includes('hookEvents')),
'Expected hookEvents error, got: ' + JSON.stringify(errors),
);
});
test('bad sandboxTier → validation error', () => {
const cap = makeRuntimeCap({ id: 'test-rt', runtime: { sandboxTier: 'workspace-sandbox' } });
const errors = validateCapability(cap, 'test-rt');
assert.ok(
errors.some((e) => e.includes('sandboxTier')),
'Expected sandboxTier error, got: ' + JSON.stringify(errors),
);
});
test('artifactLayout is an array (old shape) → validation error', () => {
const cap = makeRuntimeCap({ id: 'test-rt', runtime: { artifactLayout: [] } });
const errors = validateCapability(cap, 'test-rt');
assert.ok(
errors.some((e) => e.includes('artifactLayout')),
'Expected artifactLayout shape error (old array not accepted), got: ' + JSON.stringify(errors),
);
});
test('bad ArtifactKind.kind in artifactLayout → validation error', () => {
const cap = makeRuntimeCap({
id: 'test-rt',
runtime: {
artifactLayout: {
global: [{ kind: 'magic-kind', destSubpath: 'x', prefix: 'g-', nesting: 'flat', recursive: false, converter: null }],
local: [],
},
},
});
const errors = validateCapability(cap, 'test-rt');
assert.ok(
errors.some((e) => e.includes('kind')),
'Expected ArtifactKind.kind error, got: ' + JSON.stringify(errors),
);
});
test('bad ArtifactKind.nesting → validation error', () => {
const cap = makeRuntimeCap({
id: 'test-rt',
runtime: {
artifactLayout: {
global: [{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-', nesting: 'deep', recursive: false, converter: null }],
local: [],
},
},
});
const errors = validateCapability(cap, 'test-rt');
assert.ok(
errors.some((e) => e.includes('nesting')),
'Expected nesting error, got: ' + JSON.stringify(errors),
);
});
test('valid runtime descriptor passes validateCapability with no errors', () => {
const cap = makeRuntimeCap({ id: 'test-rt' });
const errors = validateCapability(cap, 'test-rt');
assert.deepEqual(errors, [], 'Expected no errors for valid runtime cap: ' + JSON.stringify(errors));
});
});
// ── 24d. validateConfigHome + validateArtifactLayout unit tests ───────────────
describe('ADR-1016 phase 5a: validateConfigHome unit tests', () => {
test('valid dot-home with env → no errors', () => {
const errors = validateConfigHome('test', { kind: 'dot-home', name: '.test', env: ['TEST_DIR'] });
assert.deepEqual(errors, []);
});
test('valid dot-home-nested with parent → no errors', () => {
const errors = validateConfigHome('test', { kind: 'dot-home-nested', name: 'foo', parent: '.bar', env: [] });
assert.deepEqual(errors, []);
});
test('valid xdg → no errors', () => {
const errors = validateConfigHome('test', { kind: 'xdg', name: 'opencode', env: ['OPENCODE_CONFIG_DIR', 'XDG_CONFIG_HOME'] });
assert.deepEqual(errors, []);
});
test('valid generic-agents-root with probe → no errors', () => {
const errors = validateConfigHome('test', { kind: 'generic-agents-root', name: 'agents', env: ['KIMI_CONFIG_DIR'], probe: ['~/.config/agents'] });
assert.deepEqual(errors, []);
});
test('null configHome → error', () => {
const errors = validateConfigHome('test', null);
assert.ok(errors.length > 0 && errors.some((e) => e.includes('configHome')));
});
test('string configHome → error', () => {
const errors = validateConfigHome('test', '~/.test');
assert.ok(errors.length > 0 && errors.some((e) => e.includes('configHome')));
});
test('unknown kind → error mentioning the valid set', () => {
const errors = validateConfigHome('test', { kind: 'unknown', name: '.x', env: [] });
assert.ok(errors.some((e) => e.includes('kind') && e.includes('dot-home')),
'Error should list valid kinds, got: ' + JSON.stringify(errors));
});
test('dot-home-nested missing parent → error', () => {
const errors = validateConfigHome('test', { kind: 'dot-home-nested', name: 'x', env: [] });
assert.ok(errors.some((e) => e.includes('parent')));
});
test('skillsHome with valid kind → no errors', () => {
const errors = validateConfigHome('test', {
kind: 'xdg', name: 'kilo', env: [],
skillsHome: { kind: 'dot-home', name: '.kilo', env: [] },
});
assert.deepEqual(errors, []);
});
test('skillsHome with bad kind → error', () => {
const errors = validateConfigHome('test', {
kind: 'xdg', name: 'kilo', env: [],
skillsHome: { kind: 'exotic', name: '.kilo', env: [] },
});
assert.ok(errors.some((e) => e.includes('skillsHome') && e.includes('kind')));
});
});
describe('ADR-1016 phase 5a: validateArtifactLayout unit tests', () => {
test('valid empty global/local → no errors', () => {
const errors = validateArtifactLayout('test', { global: [], local: [] });
assert.deepEqual(errors, []);
});
test('valid skills entry in global → no errors', () => {
const errors = validateArtifactLayout('test', {
global: [{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-', nesting: 'flat', recursive: false, converter: null }],
local: [],
});
assert.deepEqual(errors, []);
});
test('array (old shape) → error', () => {
const errors = validateArtifactLayout('test', []);
assert.ok(errors.some((e) => e.includes('artifactLayout')));
});
test('missing global → error', () => {
const errors = validateArtifactLayout('test', { local: [] });
assert.ok(errors.some((e) => e.includes('global')));
});
test('missing local → error', () => {
const errors = validateArtifactLayout('test', { global: [] });
assert.ok(errors.some((e) => e.includes('local')));
});
test('bad kind in global[0] → error', () => {
const errors = validateArtifactLayout('test', {
global: [{ kind: 'bad-kind', destSubpath: 'x', prefix: '', nesting: 'flat', recursive: false, converter: null }],
local: [],
});
assert.ok(errors.some((e) => e.includes('kind')));
});
test('bad nesting in global[0] → error', () => {
const errors = validateArtifactLayout('test', {
global: [{ kind: 'skills', destSubpath: 'x', prefix: '', nesting: 'trilateral', recursive: false, converter: null }],
local: [],
});
assert.ok(errors.some((e) => e.includes('nesting')));
});
test('kimi-agents kind → no errors', () => {
const errors = validateArtifactLayout('test', {
global: [{ kind: 'kimi-agents', destSubpath: 'agents', prefix: 'gsd', nesting: 'flat', recursive: false, converter: null }],
local: [],
});
assert.deepEqual(errors, []);
});
test('#2777: home override is accepted globally and rejected locally', () => {
const entry = {
kind: 'skills',
destSubpath: 'skills',
prefix: 'gsd-',
nesting: 'flat',
recursive: false,
converter: null,
home: '.agents',
};
assert.deepEqual(validateArtifactLayout('test', { global: [entry], local: [] }), []);
const errors = validateArtifactLayout('test', { global: [], local: [entry] });
assert.ok(
errors.some((error) => error.includes('artifactLayout.local[0].home') && error.includes('project-scoped')),
'Expected local home override rejection, got: ' + JSON.stringify(errors),
);
});
});
// ── 24d-extra. FIX 3: tightened validateRuntimeBody / validateConfigHome ──────
describe('FIX 3: tightened runtime validator — configHome.env required', () => {
test('configHome missing env → validation error', () => {
// env is now required; omitting it must produce an error
const cap = makeRuntimeCap({
id: 'test-rt',
runtime: { configHome: { kind: 'dot-home', name: '.test-rt' } }, // no env
});
const errors = validateCapability(cap, 'test-rt');
assert.ok(
errors.some((e) => e.includes('env') && (e.includes('required') || e.includes('array'))),
'Expected configHome.env required error, got: ' + JSON.stringify(errors),
);
});
test('configHome with env: [] (empty array) is accepted', () => {
const cap = makeRuntimeCap({
id: 'test-rt',
runtime: { configHome: { kind: 'dot-home', name: '.test-rt', env: [] } },
});
const errors = validateCapability(cap, 'test-rt');
const envErrors = errors.filter((e) => e.includes('env'));
assert.deepEqual(envErrors, [], 'Empty env array should be accepted, got: ' + JSON.stringify(envErrors));
});
test('configHome with env: null → validation error (not an array)', () => {
const cap = makeRuntimeCap({
id: 'test-rt',
runtime: { configHome: { kind: 'dot-home', name: '.test-rt', env: null } },
});
const errors = validateCapability(cap, 'test-rt');
assert.ok(
errors.some((e) => e.includes('env')),
'Expected env error for null, got: ' + JSON.stringify(errors),
);
});
});
describe('FIX 3: tightened runtime validator — skillsHome recursive validation', () => {
test('skillsHome with bad kind → validation error via full recursive check', () => {
const cap = makeRuntimeCap({
id: 'test-rt',
runtime: {
configHome: {
kind: 'xdg', name: 'test', env: [],
skillsHome: { kind: 'exotic-kind', name: '.test', env: [] },
},
},
});
const errors = validateCapability(cap, 'test-rt');
assert.ok(
errors.some((e) => e.includes('skillsHome') && e.includes('kind')),
'Expected skillsHome.kind error for exotic-kind, got: ' + JSON.stringify(errors),
);
});
test('skillsHome missing env → validation error (env required in recursive check)', () => {
const cap = makeRuntimeCap({
id: 'test-rt',
runtime: {
configHome: {
kind: 'xdg', name: 'test', env: [],
skillsHome: { kind: 'dot-home', name: '.test' }, // no env
},
},
});
const errors = validateCapability(cap, 'test-rt');
assert.ok(
errors.some((e) => e.includes('skillsHome') && e.includes('env')),
'Expected skillsHome.env required error, got: ' + JSON.stringify(errors),
);
});
test('kilo descriptor: skillsHome passes full validation', () => {
// kilo has skillsHome: { kind: "dot-home", name: ".kilo", env: [] } — must pass
const { capMap, errors } = loadAndValidate(new Set());
const hardErrors = errors.filter((e) => !e.includes('pending-migration'));
assert.deepEqual(hardErrors, [], 'No hard errors expected for kilo, got: ' + JSON.stringify(hardErrors));
const kiloRt = capMap.get('kilo');
assert.ok(kiloRt, 'kilo must be in capMap');
assert.ok(kiloRt.runtime.configHome.skillsHome, 'kilo.configHome.skillsHome must be present');
assert.strictEqual(kiloRt.runtime.configHome.skillsHome.kind, 'dot-home');
assert.strictEqual(kiloRt.runtime.configHome.skillsHome.name, '.kilo');
});
});
describe('FIX 3: tightened runtime validator — ArtifactKind field type checks', () => {
test('ArtifactKind with recursive: "yes" (non-boolean) → validation error', () => {
const cap = makeRuntimeCap({
id: 'test-rt',
runtime: {
artifactLayout: {
global: [{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-', nesting: 'flat', recursive: 'yes', converter: null }],
local: [],
},
},
});
const errors = validateCapability(cap, 'test-rt');
assert.ok(
errors.some((e) => e.includes('recursive') && e.includes('boolean')),
'Expected recursive non-boolean error, got: ' + JSON.stringify(errors),
);
});
test('ArtifactKind with recursive: true (boolean) → no error', () => {
const cap = makeRuntimeCap({
id: 'test-rt',
runtime: {
artifactLayout: {
global: [{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-', nesting: 'flat', recursive: true, converter: null }],
local: [],
},
},
});
const errors = validateCapability(cap, 'test-rt');
const recursiveErrors = errors.filter((e) => e.includes('recursive'));
assert.deepEqual(recursiveErrors, [], 'recursive: true must be accepted, got: ' + JSON.stringify(recursiveErrors));
});
test('ArtifactKind with prefix: 42 (non-string) → validation error', () => {
const cap = makeRuntimeCap({
id: 'test-rt',
runtime: {
artifactLayout: {
global: [{ kind: 'skills', destSubpath: 'skills', prefix: 42, nesting: 'flat', recursive: false, converter: null }],
local: [],
},
},
});
const errors = validateCapability(cap, 'test-rt');
assert.ok(
errors.some((e) => e.includes('prefix') && e.includes('string')),
'Expected prefix non-string error, got: ' + JSON.stringify(errors),
);
});
test('ArtifactKind with converter: 123 (non-string, non-null) → validation error', () => {
const cap = makeRuntimeCap({
id: 'test-rt',
runtime: {
artifactLayout: {
global: [{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-', nesting: 'flat', recursive: false, converter: 123 }],
local: [],
},
},
});
const errors = validateCapability(cap, 'test-rt');
assert.ok(
errors.some((e) => e.includes('converter') && (e.includes('string') || e.includes('null'))),
'Expected converter type error, got: ' + JSON.stringify(errors),
);
});
test('ArtifactKind with converter: null → no error', () => {
const cap = makeRuntimeCap({
id: 'test-rt',
runtime: {
artifactLayout: {
global: [{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-', nesting: 'flat', recursive: false, converter: null }],
local: [],
},
},
});
const errors = validateCapability(cap, 'test-rt');
const converterErrors = errors.filter((e) => e.includes('converter'));
assert.deepEqual(converterErrors, [], 'converter: null must be accepted, got: ' + JSON.stringify(converterErrors));
});
test('ArtifactKind with converter: "convertClaudeCommandToKiloSkill" (string) → no error', () => {
const cap = makeRuntimeCap({
id: 'test-rt',
runtime: {
artifactLayout: {
global: [{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-', nesting: 'flat', recursive: true, converter: 'convertClaudeCommandToKiloSkill' }],
local: [],
},
},
});
const errors = validateCapability(cap, 'test-rt');
const converterErrors = errors.filter((e) => e.includes('converter'));
assert.deepEqual(converterErrors, [], 'converter: string must be accepted, got: ' + JSON.stringify(converterErrors));
});
});
describe('FIX 3: tightened runtime validator — probeExists optional string', () => {
test('probeExists: "skills" is accepted', () => {
const errors = validateConfigHome('test', {
kind: 'generic-agents-root', name: 'agents', env: ['KIMI_CONFIG_DIR'],
probe: ['~/.config/agents', '~/.agents'],
probeExists: 'skills',
});
assert.deepEqual(errors, [], 'probeExists: "skills" must be accepted, got: ' + JSON.stringify(errors));
});
test('probeExists: "" (empty string) → error', () => {
const errors = validateConfigHome('test', {
kind: 'generic-agents-root', name: 'agents', env: [],
probeExists: '',
});
assert.ok(
errors.some((e) => e.includes('probeExists')),
'Expected probeExists empty string error, got: ' + JSON.stringify(errors),
);
});
test('probeExists: 42 (non-string) → error', () => {
const errors = validateConfigHome('test', {
kind: 'generic-agents-root', name: 'agents', env: [],
probeExists: 42,
});
assert.ok(
errors.some((e) => e.includes('probeExists')),
'Expected probeExists non-string error, got: ' + JSON.stringify(errors),
);
});
test('probeExists absent → no error (optional)', () => {
const errors = validateConfigHome('test', {
kind: 'generic-agents-root', name: 'agents', env: ['KIMI_CONFIG_DIR'],
probe: ['~/.config/agents'],
});
const probeExistsErrors = errors.filter((e) => e.includes('probeExists'));
assert.deepEqual(probeExistsErrors, [], 'probeExists is optional — no error when absent, got: ' + JSON.stringify(probeExistsErrors));
});
});
// ── 24e. Closed-vocab set exports are correct ─────────────────────────────────
describe('ADR-1016 phase 5a: closed-vocab set exports', () => {
test('VALID_CONFIG_HOME_KINDS has exactly the 5 expected values', () => {
assert.ok(VALID_CONFIG_HOME_KINDS instanceof Set);
// 'none' added #2103 — a runtime with NO file-projected config directory
// at all (e.g. vscode — Marketplace/VSIX-distributed).
for (const v of ['dot-home', 'dot-home-nested', 'xdg', 'generic-agents-root', 'none']) {
assert.ok(VALID_CONFIG_HOME_KINDS.has(v), 'VALID_CONFIG_HOME_KINDS must contain "' + v + '"');
}
assert.strictEqual(VALID_CONFIG_HOME_KINDS.size, 5, 'VALID_CONFIG_HOME_KINDS must have exactly 5 members');
});
test('VALID_COMMAND_STYLES has exactly 2 values', () => {
assert.ok(VALID_COMMAND_STYLES instanceof Set);
assert.ok(VALID_COMMAND_STYLES.has('slash-hyphen'));
assert.ok(VALID_COMMAND_STYLES.has('shell-var'));
assert.strictEqual(VALID_COMMAND_STYLES.size, 2);
});
test('VALID_HOOKS_SURFACES has exactly 8 values', () => {
assert.ok(VALID_HOOKS_SURFACES instanceof Set);
for (const v of ['settings-json', 'codex-hooks-json', 'cursor-hooks-json', 'copilot-inline', 'cline-rules', 'kimi-hooks-toml', 'windsurf-hooks-json', 'none']) {
assert.ok(VALID_HOOKS_SURFACES.has(v), 'VALID_HOOKS_SURFACES must contain "' + v + '"');
}
assert.strictEqual(VALID_HOOKS_SURFACES.size, 8);
});
test('VALID_HOOK_EVENTS has exactly 2 managed-hook dialects (claude/gemini)', () => {
assert.ok(VALID_HOOK_EVENTS instanceof Set);
for (const v of ['claude', 'gemini']) {
assert.ok(VALID_HOOK_EVENTS.has(v), 'VALID_HOOK_EVENTS must contain "' + v + '"');
}
assert.strictEqual(VALID_HOOK_EVENTS.size, 2);
assert.ok(!VALID_HOOK_EVENTS.has('opencode-subset'),
'opencode-subset is NOT a hookEvents value — it is the extensionEvents vocabulary (#1943)');
});
test('VALID_EXTENSION_EVENTS has the extension-system dialects (opencode/pi/hermes/kilo/none — #1943/#2091/#2093)', () => {
assert.ok(VALID_EXTENSION_EVENTS instanceof Set);
for (const v of ['opencode', 'pi', 'hermes', 'kilo', 'none']) {
assert.ok(VALID_EXTENSION_EVENTS.has(v), 'VALID_EXTENSION_EVENTS must contain "' + v + '"');
}
assert.strictEqual(VALID_EXTENSION_EVENTS.size, 5);
});
test('VALID_SANDBOX_TIERS has exactly 2 values', () => {
assert.ok(VALID_SANDBOX_TIERS instanceof Set);
assert.ok(VALID_SANDBOX_TIERS.has('none'));
assert.ok(VALID_SANDBOX_TIERS.has('codex-agent-sandbox'));
assert.strictEqual(VALID_SANDBOX_TIERS.size, 2);
});
test('VALID_ARTIFACT_KIND_NAMES has exactly 4 values', () => {
assert.ok(VALID_ARTIFACT_KIND_NAMES instanceof Set);
for (const v of ['commands', 'agents', 'skills', 'kimi-agents']) {
assert.ok(VALID_ARTIFACT_KIND_NAMES.has(v), 'VALID_ARTIFACT_KIND_NAMES must contain "' + v + '"');
}
assert.strictEqual(VALID_ARTIFACT_KIND_NAMES.size, 4);
});
test('VALID_ARTIFACT_NESTINGS has exactly 2 values', () => {
assert.ok(VALID_ARTIFACT_NESTINGS instanceof Set);
assert.ok(VALID_ARTIFACT_NESTINGS.has('flat'));
assert.ok(VALID_ARTIFACT_NESTINGS.has('nested'));
assert.strictEqual(VALID_ARTIFACT_NESTINGS.size, 2);
});
});
// ─── 25. ADR-857 phase 5e: closed ConverterName enum (Part B) ─────────────────
describe('ADR-857 phase 5e: VALID_CONVERTER_NAMES closed enum', () => {
// #2875 Part 2 (the agents-bypass closure): 3 converters added —
// convertClaudeAgentToHermesAgent (data-driven Hermes branding converter,
// reads hostBehaviors.brandingRewrites) and convertClaudeToKiloFrontmatter /
// convertClaudeToOpencodeFrontmatter (kilo/opencode's agents-kind
// converters — shared by name with those runtimes' commands-kind entries,
// options-bag signature `(content, {isAgent, modelOverride})`). All three
// are genuinely new agent converters (not renamed/leftover), so the agent
// count grows from 11 to 14; the 16 command/skill/workflow converters are
// unchanged.
test('VALID_CONVERTER_NAMES has exactly 30 entries (16 command/skill/workflow + 14 agent converters)', () => {
assert.ok(VALID_CONVERTER_NAMES instanceof Set, 'VALID_CONVERTER_NAMES must be a Set');
assert.strictEqual(VALID_CONVERTER_NAMES.size, 30, 'VALID_CONVERTER_NAMES must have exactly 30 entries, got: ' + VALID_CONVERTER_NAMES.size);
});
test('VALID_CONVERTER_NAMES contains all expected converter names', () => {
const expected = [
// command/skill converters (pre-existing)
'convertClaudeCommandToAntigravitySkill',
'convertClaudeCommandToAugmentSkill',
'convertClaudeCommandToClineSkill',
'convertClaudeCommandToClaudeSkill',
'convertClaudeCommandToCodebuddyCommand',
'convertClaudeCommandToCodebuddySkill',
'convertClaudeCommandToCodexSkill',
'convertClaudeCommandToCopilotSkill',
'convertClaudeCommandToCursorSkill',
'convertClaudeCommandToKiloSkill',
'convertClaudeCommandToKimiSkill',
'convertClaudeCommandToOpencodeSkill',
'convertClaudeCommandToTraeSkill',
'convertClaudeCommandToWindsurfSkill',
'convertClaudeCommandToWindsurfWorkflow',
// agent converters (#1173 — descriptor-driven agent conversion wiring)
'convertClaudeAgentToCopilotAgent',
'convertClaudeAgentToAntigravityAgent',
'convertClaudeAgentToCursorAgent',
'convertClaudeAgentToWindsurfAgent',
'convertClaudeAgentToAugmentAgent',
'convertClaudeAgentToTraeAgent',
'convertClaudeAgentToCodebuddyAgent',
'convertClaudeAgentToClineAgent',
'convertClaudeAgentToCodexAgent',
// ADR-1239 / #2092 Phase B Upgrade 1 — native .qwen/agents/*.md subagent projection.
'convertClaudeAgentToQwenAgent',
// #3384 — ZCode agent converter (strips mcp__* grants at install time).
'convertClaudeAgentToZcodeAgent',
// #2875 Part 2 (the agents-bypass closure) — data-driven Hermes branding
// converter, and the kilo/opencode agent converters (shared name with
// those runtimes' commands-kind entries).
'convertClaudeAgentToHermesAgent',
'convertClaudeToKiloFrontmatter',
'convertClaudeToOpencodeFrontmatter',
];
for (const name of expected) {
assert.ok(VALID_CONVERTER_NAMES.has(name), 'VALID_CONVERTER_NAMES must contain "' + name + '"');
}
});
});
describe('ADR-857 phase 5e: validateArtifactKindEntry — ConverterName enum (FAIL-FIRST regression)', () => {
// Helper to build a minimal valid ArtifactKind entry
function makeArtifactEntry(overrides) {
return {
kind: 'skills',
destSubpath: 'skills',
nesting: 'flat',
prefix: 'gsd-',
recursive: false,
converter: null,
...overrides,
};
}
// FAIL-FIRST: unknown converter name must be rejected
test('REJECTED: converter "convertClaudeCommandToUnknownRuntime" is not a known ConverterName', () => {
const entry = makeArtifactEntry({ converter: 'convertClaudeCommandToUnknownRuntime' });
const errors = validateArtifactKindEntry('test-cap', entry, 'artifactLayout.global[0]');
assert.ok(errors.length > 0, 'Expected rejection for unknown converter name, got: ' + JSON.stringify(errors));
assert.ok(
errors.some((e) => e.includes('convertClaudeCommandToUnknownRuntime') && e.includes('not a known ConverterName')),
'Error must name the bad converter and say "not a known ConverterName", got: ' + JSON.stringify(errors),
);
});
// Valid known name must be accepted
test('ACCEPTED: converter "convertClaudeCommandToKiloSkill" is a known ConverterName', () => {
const entry = makeArtifactEntry({ converter: 'convertClaudeCommandToKiloSkill' });
const errors = validateArtifactKindEntry('test-cap', entry, 'artifactLayout.global[0]');
const converterErrors = errors.filter((e) => e.includes('converter'));
assert.deepEqual(converterErrors, [], 'Known converter name must be accepted, got: ' + JSON.stringify(converterErrors));
});
// null converter is always accepted (means "no conversion")
test('ACCEPTED: converter: null is always accepted', () => {
const entry = makeArtifactEntry({ converter: null });
const errors = validateArtifactKindEntry('test-cap', entry, 'artifactLayout.global[0]');
const converterErrors = errors.filter((e) => e.includes('converter'));
assert.deepEqual(converterErrors, [], 'converter: null must always be accepted, got: ' + JSON.stringify(converterErrors));
});
// Parity: every role:runtime descriptor's converters must be in the valid set (or null).
// Count-agnostic: iterates the real capability map rather than a hand-pinned runtime list.
test('every real runtime descriptor has converters in VALID_CONVERTER_NAMES or null', () => {
const { capMap, errors } = loadAndValidate(new Set());
const hardErrors = errors.filter((e) => !e.includes('pending-migration'));
assert.deepEqual(hardErrors, [], 'Expected no hard errors from real capabilities, got: ' + JSON.stringify(hardErrors));
const runtimeIds = [...capMap.entries()]
.filter(([, cap]) => cap && cap.role === 'runtime')
.map(([id]) => id);
assert.ok(runtimeIds.length > 0, 'expected at least one role:runtime capability');
for (const id of runtimeIds) {
const cap = capMap.get(id);
assert.ok(cap, 'capMap must contain "' + id + '"');
const r = cap.runtime;
const allEntries = [
...(r.artifactLayout && Array.isArray(r.artifactLayout.global) ? r.artifactLayout.global : []),
...(r.artifactLayout && Array.isArray(r.artifactLayout.local) ? r.artifactLayout.local : []),
];
for (let i = 0; i < allEntries.length; i++) {
const entry = allEntries[i];
if (entry.converter !== null) {
assert.ok(
VALID_CONVERTER_NAMES.has(entry.converter),
id + ' artifactLayout[' + i + '].converter "' + entry.converter +
'" is not in VALID_CONVERTER_NAMES',
);
}
}
}
});
// validateCapability end-to-end: unknown converter propagates through the full chain
test('validateCapability REJECTS a runtime cap with unknown converter in artifactLayout', () => {
const cap = {
id: 'test-rt',
role: 'runtime',
title: 'Test Runtime',
description: 'Test runtime with unknown converter.',
tier: 'core',
requires: [],
runtime: {
configHome: { kind: 'dot-home', name: '.test-rt', env: [] },
configFormat: 'settings-json',
artifactLayout: {
global: [{
kind: 'skills',
destSubpath: 'skills',
nesting: 'flat',
prefix: 'gsd-',
recursive: false,
converter: 'convertClaudeCommandToUnknownRuntime',
}],
local: [],
},
commandStyle: 'slash-hyphen',
hooksSurface: 'settings-json',
sandboxTier: 'none',
supportTier: 1,
},
};
const errors = validateCapability(cap, 'test-rt');
assert.ok(errors.length > 0, 'Expected validation errors for unknown converter, got: ' + JSON.stringify(errors));
assert.ok(
errors.some((e) => e.includes('convertClaudeCommandToUnknownRuntime') && e.includes('not a known ConverterName')),
'Error must mention the unknown converter name, got: ' + JSON.stringify(errors),
);
});
});
// ─── 26. ADR-857 phase 5e: configFormat ↔ installSurface parity gate (Part A) ─
describe('ADR-857 phase 5e: configFormat ↔ installSurface parity gate', () => {
// Helper: build a minimal runtime capMap for parity tests.
// installSurface must be supplied for any runtime that should be checked by the gate;
// omit it (undefined) to simulate a runtime with no installSurface (gate skips it).
function makeRuntimeCapMap(runtimeId, configFormat, installSurface) {
const runtime = {
configHome: { kind: 'dot-home', name: '.' + runtimeId, env: [] },
configFormat,
artifactLayout: { global: [], local: [] },
commandStyle: 'slash-hyphen',
hooksSurface: 'none',
sandboxTier: 'none',
supportTier: 1,
};
if (installSurface !== undefined) {
runtime.installSurface = installSurface;
}
const cap = {
id: runtimeId,
role: 'runtime',
title: 'Test ' + runtimeId,
description: 'Synthetic runtime for parity gate testing.',
tier: 'core',
requires: [],
runtime,
};
return new Map([[runtimeId, cap]]);
}
// FAIL-FIRST: parity mismatch must throw
test('THROWS: claude with wrong configFormat "toml" (installSurface=settings-json → expected settings-json)', () => {
// claude has installSurface=settings-json → expected configFormat=settings-json
// Giving it configFormat=toml must trigger the HARD gate
const capMap = makeRuntimeCapMap('claude', 'toml', 'settings-json');
assert.throws(
() => runConfigFormatParityGate(capMap),
(err) => {
assert.ok(err instanceof Error, 'Must throw an Error');
assert.ok(
err.message.includes('claude') && err.message.includes('parity gate FAILED'),
'Error must name the runtime and say "parity gate FAILED", got: ' + err.message,
);
assert.ok(
err.message.includes('settings-json') && err.message.includes('toml'),
'Error must name both the expected and actual configFormat, got: ' + err.message,
);
return true;
},
);
});
test('THROWS: codex with wrong configFormat "settings-json" (installSurface=codex-toml → expected toml)', () => {
const capMap = makeRuntimeCapMap('codex', 'settings-json', 'codex-toml');
assert.throws(
() => runConfigFormatParityGate(capMap),
(err) => {
assert.ok(err instanceof Error);
assert.ok(err.message.includes('codex') && err.message.includes('parity gate FAILED'));
return true;
},
);
});
// All 16 real runtime descriptors must pass the parity gate (true-negative)
test('all 16 real runtime descriptors pass the configFormat parity gate (DOES NOT THROW)', () => {
const { capMap, errors } = loadAndValidate(new Set());
const hardErrors = errors.filter((e) => !e.includes('pending-migration'));
assert.deepEqual(hardErrors, [], 'No hard errors expected: ' + JSON.stringify(hardErrors));
// runConfigFormatParityGate must not throw for the real registry
assert.doesNotThrow(
() => runConfigFormatParityGate(capMap),
'runConfigFormatParityGate must not throw for the real 16 runtime descriptors',
);
});
// buildRegistry must not throw for the real registry (end-to-end integration)
test('buildRegistry with real 16 runtime descriptors does not throw (parity gate integrated)', () => {
const { capMap } = loadAndValidate(new Set());
assert.doesNotThrow(
() => buildRegistry(capMap),
'buildRegistry must not throw for the real registry (parity gate must pass)',
);
});
// INSTALL_SURFACE_TO_CONFIG_FORMAT export check
test('INSTALL_SURFACE_TO_CONFIG_FORMAT covers all 7 installSurface values with correct mappings', () => {
assert.ok(INSTALL_SURFACE_TO_CONFIG_FORMAT instanceof Map, 'Must be a Map');
assert.strictEqual(INSTALL_SURFACE_TO_CONFIG_FORMAT.size, 7, 'Must cover 7 installSurface values');
assert.strictEqual(INSTALL_SURFACE_TO_CONFIG_FORMAT.get('settings-json'), 'settings-json');
assert.strictEqual(INSTALL_SURFACE_TO_CONFIG_FORMAT.get('codex-toml'), 'toml');
assert.strictEqual(INSTALL_SURFACE_TO_CONFIG_FORMAT.get('copilot-instructions'), 'markdown');
assert.strictEqual(INSTALL_SURFACE_TO_CONFIG_FORMAT.get('cline-rules'), 'markdown-dir');
assert.strictEqual(INSTALL_SURFACE_TO_CONFIG_FORMAT.get('cursor-hooks-json'), 'none');
assert.strictEqual(INSTALL_SURFACE_TO_CONFIG_FORMAT.get('profile-marker-only'), 'none');
// 'none' added #2103 — a runtime with no CLI install surface at all (e.g.
// vscode) has no config-file format to write either.
assert.strictEqual(INSTALL_SURFACE_TO_CONFIG_FORMAT.get('none'), 'none');
});
// Feature capabilities (role:feature) are silently ignored by the gate
test('feature capabilities (role:feature) are ignored by the parity gate — does not throw', () => {
// Use the real UI cap (role:feature, has no installSurface) — gate must pass silently
const capMap = new Map([['ui', UI_CAP]]);
assert.doesNotThrow(
() => runConfigFormatParityGate(capMap),
'Feature capabilities must be ignored by the configFormat parity gate',
);
});
// Runtimes with no installSurface in their descriptor are excluded from the gate.
// The gate reads installSurface from cap.runtime.installSurface (the descriptor level);
// if it is absent (typeof !== 'string'), the runtime is soft-skipped.
// NOTE: the gate no longer uses the adapter registry — it reads purely from the descriptor.
test('runtime with no installSurface in descriptor (e.g. hypothetical "grok") is excluded from parity gate — does not throw', () => {
// 'grok' has no installSurface → gate must soft-skip (typeof r.installSurface !== 'string')
const grokCap = {
id: 'grok',
role: 'runtime',
title: 'Grok',
description: 'Hypothetical grok runtime',
tier: 'core',
requires: [],
runtime: {
configHome: { kind: 'dot-home', name: '.grok', env: [] },
configFormat: 'settings-json', // any value — gate should not check this (no installSurface)
artifactLayout: { global: [], local: [] },
commandStyle: 'slash-hyphen',
hooksSurface: 'none',
sandboxTier: 'none',
supportTier: 2,
// intentionally no installSurface — gate must skip this entry
},
};
const capMap = new Map([['grok', grokCap]]);
assert.doesNotThrow(
() => runConfigFormatParityGate(capMap),
'Runtimes with no installSurface in their descriptor must be excluded from the parity gate',
);
});
});
// ─── 27. ADR-857 phase 5f: cross-field consistency gate rejection tests ────────
describe('ADR-857 phase 5f: cross-field consistency gate rejection tests (DEFECT.GENERATIVE-FIX)', () => {
// Helper: build a minimal VALID runtime cap for cross-field rejection tests.
// Override any field via the overrides object.
function makeValidRuntimeCap(overrides) {
const base = {
id: 'test-runtime',
role: 'runtime',
title: 'Test runtime',
description: 'Synthetic runtime for cross-field gate rejection testing.',
tier: 'core',
requires: [],
runtime: {
configHome: { kind: 'dot-home', name: '.test-runtime', env: [] },
localConfigDir: '.test-runtime',
configFormat: 'settings-json',
artifactLayout: { global: [], local: [] },
commandStyle: 'slash-hyphen',
hooksSurface: 'settings-json',
hookEvents: 'claude',
sandboxTier: 'none',
supportTier: 1,
installSurface: 'settings-json',
writesSharedSettings: true,
permissionWriter: null,
extendedHookEvents: [],
hostIntegration: {
embeddingMode: 'imperative',
commandSurface: 'slash-file',
dispatch: { namedDispatch: true, nested: true, maxDepth: -1, background: true, subagentToolkit: 'full', backgroundDispatch: false },
modelMode: 'passive',
hookBus: 'host',
stateIO: 'filesystem',
transport: 'mcp',
runtime: 'node',
},
},
};
if (overrides && typeof overrides === 'object') {
for (const [k, v] of Object.entries(overrides)) {
if (k === 'runtime' && typeof v === 'object') {
Object.assign(base.runtime, v);
} else {
base[k] = v;
}
}
}
return base;
}
test('REJECTS: installSurface not in VALID_INSTALL_SURFACES → throws validation error', () => {
const cap = makeValidRuntimeCap({ runtime: { installSurface: 'bogus-surface' } });
const errors = validateRuntimeBody(cap);
assert.ok(
errors.some((e) => e.includes('installSurface') && e.includes('bogus-surface')),
'Expected error about invalid installSurface, got: ' + JSON.stringify(errors),
);
});
test('REJECTS: permissionWriter not null and not in {opencode,kilo} → throws validation error', () => {
const cap = makeValidRuntimeCap({ runtime: { permissionWriter: 'notarealwriter' } });
const errors = validateRuntimeBody(cap);
assert.ok(
errors.some((e) => e.includes('permissionWriter') && e.includes('notarealwriter')),
'Expected error about invalid permissionWriter, got: ' + JSON.stringify(errors),
);
});
test('REJECTS: extendedHookEvents containing a bogus event ("SubagentStopTypo") → throws validation error', () => {
const cap = makeValidRuntimeCap({ runtime: { extendedHookEvents: ['SubagentStopTypo'] } });
const errors = validateRuntimeBody(cap);
assert.ok(
errors.some((e) => e.includes('extendedHookEvents') && e.includes('SubagentStopTypo')),
'Expected error about invalid extendedHookEvents entry, got: ' + JSON.stringify(errors),
);
});
test('REJECTS: writesSharedSettings not a boolean → throws validation error', () => {
const cap = makeValidRuntimeCap({ runtime: { writesSharedSettings: 'yes' } });
const errors = validateRuntimeBody(cap);
assert.ok(
errors.some((e) => e.includes('writesSharedSettings') && e.includes('"yes"')),
'Expected error about writesSharedSettings not boolean, got: ' + JSON.stringify(errors),
);
});
test('GATE A REJECTS: profile-marker-only + hooksSurface="settings-json" → validation error', () => {
// profile-marker-only installSurface only allows hooksSurface='none'
const cap = makeValidRuntimeCap({
runtime: {
installSurface: 'profile-marker-only',
hooksSurface: 'settings-json',
configFormat: 'none', // correct for profile-marker-only
},
});
const errors = validateRuntimeBody(cap);
assert.ok(
errors.some((e) => e.includes('hooksSurface') && e.includes('profile-marker-only')),
'Expected GATE A error for profile-marker-only + hooksSurface=settings-json, got: ' + JSON.stringify(errors),
);
});
test('GATE B REJECTS: hookEvents="claude" + extendedHookEvents=["BeforeAgent"] → validation error', () => {
// BeforeAgent is a Gemini agent-event — requires hookEvents='gemini', not 'claude'
const cap = makeValidRuntimeCap({
runtime: {
hookEvents: 'claude',
extendedHookEvents: ['BeforeAgent'],
},
});
const errors = validateRuntimeBody(cap);
assert.ok(
errors.some((e) => e.includes('BeforeAgent') && e.includes('"gemini"')),
'Expected GATE B error for hookEvents=claude + extendedHookEvents=[BeforeAgent], got: ' + JSON.stringify(errors),
);
});
// #2103: configHome.kind==='none' carve-out (VS Code — Marketplace/VSIX, no
// file-projected config directory). Adversarial review flagged AC4's
// "accept vscode AND reject a faked configHome" requirement as untested —
// the real vscode descriptor validating clean (tests/capability-registry.test.cjs's
// "ADR-1016 phase 5a" suite, capability-validator-parity tests, etc.) only
// proves the ACCEPT half. These three tests are the REJECT half: they prove
// the two new branches in validateRuntimeBody/validateConfigHome actually
// fire, quoting their EXACT error strings so a future edit that silently
// weakens either branch fails loudly here.
describe('#2103 configHome.kind==="none" carve-out (vscode) — accept/reject pair', () => {
test('ACCEPT: configHome.kind:"none" + localConfigDir:null + installSurface:"none" + hooksSurface:"none" validates with ZERO errors', () => {
const cap = makeValidRuntimeCap({
runtime: {
configHome: { kind: 'none', name: 'test-none-rt', env: [] },
localConfigDir: null,
configFormat: 'none',
installSurface: 'none',
hooksSurface: 'none',
},
});
const errors = validateRuntimeBody(cap);
assert.deepEqual(errors, [], 'a genuinely kind:"none" descriptor (vscode-shaped) must validate clean, got: ' + JSON.stringify(errors));
});
test('REJECT #1: localConfigDir non-null while configHome.kind==="none" → exact validateRuntimeBody error', () => {
const cap = makeValidRuntimeCap({
runtime: {
configHome: { kind: 'none', name: 'test-none-rt', env: [] },
localConfigDir: '.vscode', // faked — a kind:'none' descriptor must not also claim a local dir
configFormat: 'none',
installSurface: 'none',
hooksSurface: 'none',
},
});
const errors = validateRuntimeBody(cap);
assert.deepEqual(
errors,
['runtime.localConfigDir must be null or absent when configHome.kind is "none"; got: ".vscode"'],
'expected the exact localConfigDir-vs-kind:"none" rejection, got: ' + JSON.stringify(errors),
);
});
test('REJECT #2: configHome.name present-but-empty while configHome.kind==="none" → exact validateConfigHome error', () => {
const cap = makeValidRuntimeCap({
runtime: {
configHome: { kind: 'none', name: '', env: [] }, // faked — present name must still be non-empty
localConfigDir: null,
configFormat: 'none',
installSurface: 'none',
hooksSurface: 'none',
},
});
const errors = validateRuntimeBody(cap);
assert.deepEqual(
errors,
['capability "test-runtime" runtime.configHome.name must be a non-empty string if present when kind is "none"'],
'expected the exact configHome.name-vs-kind:"none" rejection, got: ' + JSON.stringify(errors),
);
});
});
// Verify the new constants are well-formed
test('INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES covers all 7 installSurface values', () => {
assert.ok(INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES instanceof Map, 'Must be a Map');
assert.strictEqual(INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES.size, 7, 'Must cover 7 installSurface values');
for (const installSurface of VALID_INSTALL_SURFACES) {
assert.ok(
INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES.has(installSurface),
'INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES must include installSurface "' + installSurface + '"',
);
}
});
test('VALID_EXTENDED_HOOK_EVENTS covers all 8 known extended events', () => {
assert.ok(VALID_EXTENDED_HOOK_EVENTS instanceof Set, 'Must be a Set');
assert.strictEqual(VALID_EXTENDED_HOOK_EVENTS.size, 8, 'Must cover 8 extended hook events');
// SubagentStart added #2092 Phase B Upgrade 2 (qwen-only today).
for (const ev of ['SubagentStop', 'Stop', 'PreCompact', 'FileChanged', 'BeforeAgent', 'AfterAgent', 'BeforeModel', 'SubagentStart']) {
assert.ok(VALID_EXTENDED_HOOK_EVENTS.has(ev), 'Must include event "' + ev + '"');
}
});
test('VALID_PERMISSION_WRITERS covers exactly {opencode, kilo, antigravity}', () => {
assert.ok(VALID_PERMISSION_WRITERS instanceof Set, 'Must be a Set');
assert.strictEqual(VALID_PERMISSION_WRITERS.size, 3, 'Must cover 3 permission writers');
assert.ok(VALID_PERMISSION_WRITERS.has('opencode'), 'Must include opencode');
assert.ok(VALID_PERMISSION_WRITERS.has('kilo'), 'Must include kilo');
assert.ok(VALID_PERMISSION_WRITERS.has('antigravity'), 'Must include antigravity (#2096)');
});
// Confirm the valid base fixture does NOT produce errors (sanity)
test('valid runtime fixture produces no validation errors', () => {
const cap = makeValidRuntimeCap({});
const errors = validateRuntimeBody(cap);
assert.deepEqual(errors, [], 'Valid fixture must produce no errors, got: ' + JSON.stringify(errors));
});
});
// ─── Change A: loadCentralConfigKeys ENOENT vs parse-error distinction ────────
describe('loadCentralConfigKeys — ENOENT vs parse-error (Issue #1124)', () => {
test('ENOENT: nonexistent path returns empty Set without throwing or writing stderr', () => {
const stderrWrites = [];
const origWrite = process.stderr.write.bind(process.stderr);
process.stderr.write = (msg, ...rest) => { stderrWrites.push(msg); return origWrite(msg, ...rest); };
let result;
try {
const nonexistent = path.join(os.tmpdir(), 'cfgkeys-nonexistent-' + Date.now() + '.json');
result = loadCentralConfigKeys(nonexistent);
} finally {
process.stderr.write = origWrite;
}
assert.ok(result instanceof Set, 'should return a Set');
assert.strictEqual(result.size, 0, 'Set should be empty for ENOENT');
const warnings = stderrWrites.filter((m) => typeof m === 'string' && m.length > 0);
assert.deepEqual(warnings, [], 'No stderr output expected for ENOENT, got: ' + JSON.stringify(warnings));
});
test('malformed JSON: throws and writes stderr containing the file path', () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cfgkeys-'));
const badFile = path.join(tmpDir, 'bad-schema.json');
fs.writeFileSync(badFile, '<<<<<<< HEAD\nnot json\n>>>>>>> main', 'utf8');
const stderrWrites = [];
const origWrite = process.stderr.write.bind(process.stderr);
process.stderr.write = (msg, ...rest) => { stderrWrites.push(msg); return origWrite(msg, ...rest); };
let thrownErr;
try {
loadCentralConfigKeys(badFile);
} catch (err) {
thrownErr = err;
} finally {
process.stderr.write = origWrite;
}
// Clean up
cleanup(tmpDir);
assert.ok(thrownErr !== undefined, 'Expected loadCentralConfigKeys to throw on malformed JSON');
assert.strictEqual(thrownErr.name, 'ExitError', 'thrown error must be an ExitError, got: ' + thrownErr.name);
assert.strictEqual(thrownErr.code, 1, 'ExitError must have code 1, got: ' + thrownErr.code);
const combined = stderrWrites.join('');
assert.ok(
combined.includes(badFile),
'stderr should include the file path, got: ' + combined,
);
});
test('valid file: returns Set containing the declared keys', () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cfgkeys-'));
const goodFile = path.join(tmpDir, 'good-schema.json');
fs.writeFileSync(goodFile, JSON.stringify({ validKeys: ['a', 'b'] }), 'utf8');
let result;
try {
result = loadCentralConfigKeys(goodFile);
} finally {
cleanup(tmpDir);
}
assert.ok(result instanceof Set, 'should return a Set');
assert.ok(result.has('a'), "Set should contain 'a'");
assert.ok(result.has('b'), "Set should contain 'b'");
assert.strictEqual(result.size, 2, 'Set should have exactly 2 entries');
});
test('non-ENOENT read error (path is a directory) throws ExitError and warns', () => {
// fs.readFileSync on a directory throws EISDIR (code !== 'ENOENT'), which must
// hit the non-ENOENT read-error branch: throw ExitError(1) and write stderr.
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cfgkeys-dir-'));
const stderrWrites = [];
const origWrite = process.stderr.write.bind(process.stderr);
process.stderr.write = (msg, ...rest) => { stderrWrites.push(msg); return origWrite(msg, ...rest); };
let thrownErr;
try {
loadCentralConfigKeys(tmpDir);
} catch (err) {
thrownErr = err;
} finally {
process.stderr.write = origWrite;
}
cleanup(tmpDir);
assert.ok(thrownErr !== undefined, 'Expected loadCentralConfigKeys to throw on EISDIR (directory path)');
assert.strictEqual(thrownErr.name, 'ExitError', 'thrown error must be an ExitError, got: ' + thrownErr.name);
assert.strictEqual(thrownErr.code, 1, 'ExitError must have code 1, got: ' + thrownErr.code);
const combined = stderrWrites.join('');
assert.ok(
combined.includes(tmpDir),
'stderr should include the directory path, got: ' + combined,
);
});
});
// ─── Change B: duplicate-producer invariant at gen time (Issue #1123) ─────────
describe('duplicate-producer invariant — same artifact same point (Issue #1123)', () => {
// Minimal base capability clone helper (deep-clone UI_CAP then override key fields)
function makeFeatureCap(overrides) {
return Object.assign(JSON.parse(JSON.stringify(UI_CAP)), overrides);
}
test('REJECTION: two caps each producing DUP.md at plan:pre → throws with artifact/point/ids in message', () => {
// Note: omit 'when' and use config:{} so there are no config-key validation errors.
// Caps must pass per-capability validation to enter capMap and trigger the global check.
const capA = makeFeatureCap({
id: 'dup-a',
skills: ['dup-a-skill'],
agents: ['gsd-dup-a-agent'],
config: {},
steps: [
{
point: 'plan:pre',
ref: { skill: 'dup-a-skill' },
produces: ['DUP.md'],
consumes: ['CONTEXT.md'],
onError: 'skip',
},
],
gates: [],
contributions: [],
});
const capB = makeFeatureCap({
id: 'dup-b',
skills: ['dup-b-skill'],
agents: ['gsd-dup-b-agent'],
config: {},
steps: [
{
point: 'plan:pre',
ref: { skill: 'dup-b-skill' },
produces: ['DUP.md'],
consumes: ['CONTEXT.md'],
onError: 'skip',
},
],
gates: [],
contributions: [],
});
// The duplicate-producer check fires during loadAndValidate (in validateConsumesGlobal)
// or during buildRegistry — test wraps the entire build flow.
const capDir = makeTempCapDir({ 'dup-a': capA, 'dup-b': capB });
let threw = false;
let errorMsg = '';
try {
const { capMap } = loadAndValidate(new Set(), capDir);
buildRegistry(capMap);
} catch (err) {
threw = true;
errorMsg = err.message || String(err);
}
assert.ok(threw, 'Expected build flow to throw for duplicate producers at the same point');
assert.ok(errorMsg.includes('DUP.md'), 'Error message should mention DUP.md, got: ' + errorMsg);
assert.ok(errorMsg.includes('plan:pre'), 'Error message should mention the point, got: ' + errorMsg);
assert.ok(
errorMsg.includes('dup-a') && errorMsg.includes('dup-b'),
'Error message should mention both cap ids, got: ' + errorMsg,
);
});
test('PASSING: same artifact at DIFFERENT points does not trigger duplicate-producer error', () => {
// cap-diff-a produces SAME.md at plan:pre; cap-diff-b produces SAME.md at execute:post
// Different pointIdx → must not throw the duplicate-producer error.
// NOTE: both points must be wired (have render-hooks call sites in host workflows)
// to pass the validateHooksWired gen-time guard added in #1196.
const capDiffA = makeFeatureCap({
id: 'diff-a',
skills: ['diff-a-skill'],
agents: ['gsd-diff-a-agent'],
config: {},
steps: [
{
point: 'plan:pre',
ref: { skill: 'diff-a-skill' },
produces: ['SAME.md'],
consumes: ['CONTEXT.md'],
onError: 'skip',
},
],
gates: [],
contributions: [],
});
const capDiffB = makeFeatureCap({
id: 'diff-b',
skills: ['diff-b-skill'],
agents: ['gsd-diff-b-agent'],
config: {},
steps: [
{
point: 'execute:post',
ref: { skill: 'diff-b-skill' },
produces: ['SAME.md'],
consumes: [],
onError: 'skip',
},
],
gates: [],
contributions: [],
});
const capDir = makeTempCapDir({ 'diff-a': capDiffA, 'diff-b': capDiffB });
const { capMap, errors } = loadAndValidate(new Set(), capDir);
assert.deepEqual(errors, [], 'Expected no validation errors for different-point fixtures, got: ' + JSON.stringify(errors));
// buildRegistry must NOT throw — if the duplicate-producer bug regressed it would throw here
assert.doesNotThrow(
() => buildRegistry(capMap),
'Should NOT throw duplicate-producer error for same artifact at DIFFERENT points',
);
});
});
// ─── #1196 — discuss loop wiring + wired-point guard ─────────────────────────
describe('#1196 — discuss loop wiring + wired-point guard', () => {
// ─── Defect 1 — discuss is now wireable ─────────────────────────────────────
describe('Defect 1: discuss is a wireable loop host', () => {
test('HOST_LOOP_FILES includes discuss-phase.md', () => {
assert.ok(
Array.isArray(HOST_LOOP_FILES),
'HOST_LOOP_FILES must be an array',
);
assert.ok(
HOST_LOOP_FILES.includes('gsd-core/workflows/discuss-phase.md'),
`HOST_LOOP_FILES must include 'gsd-core/workflows/discuss-phase.md'. Got: ${JSON.stringify(HOST_LOOP_FILES)}`,
);
});
test('getWiredLoopPoints(ROOT) contains discuss:pre', () => {
const wired = getWiredLoopPoints(ROOT);
assert.ok(
wired instanceof Set,
'getWiredLoopPoints must return a Set',
);
assert.ok(
wired.has('discuss:pre'),
`getWiredLoopPoints(ROOT) must contain 'discuss:pre'. Got: ${JSON.stringify([...wired])}`,
);
});
test('getWiredLoopPoints(ROOT) contains discuss:post', () => {
const wired = getWiredLoopPoints(ROOT);
assert.ok(
wired.has('discuss:post'),
`getWiredLoopPoints(ROOT) must contain 'discuss:post'. Got: ${JSON.stringify([...wired])}`,
);
});
});
// ─── Defect 2 — gen-time wired guard ────────────────────────────────────────
// Shared #3606 fixtures: every hook kind, and a Map<point, Set<kind>> builder
// for validateHooksWired's wiredKinds argument.
const ALL = ['contribution', 'step', 'gate'];
const kindsMap = (spec) => new Map(Object.entries(spec).map(([pt, ks]) => [pt, new Set(ks)]));
describe('Defect 2: validateHooksWired gen-time wired guard', () => {
/** Minimal capability fixture with one hook at a given point */
function makeCapWithStep(point) {
return {
id: 'test-cap',
role: 'feature',
steps: [{ point, ref: { skill: 'my-skill' }, produces: [], consumes: [], onError: 'skip' }],
contributions: [],
gates: [],
config: {},
};
}
function makeCapWithContribution(point) {
return {
id: 'test-cap',
role: 'feature',
steps: [],
contributions: [{ point, into: 'orchestrator', fragment: { inline: 'hi' }, produces: [], consumes: [] }],
gates: [],
config: {},
};
}
function makeCapWithGate(point) {
return {
id: 'test-cap',
role: 'feature',
steps: [],
contributions: [],
gates: [{ point, check: { query: 'test-query' }, blocking: false, onError: 'skip' }],
config: {},
};
}
test('returns non-empty error array mentioning "not wired" when step point is not in wiredSet', () => {
const cap = makeCapWithStep('discuss:pre');
const wiredSet = kindsMap({ 'plan:pre': ALL, 'plan:post': ALL }); // discuss:pre absent
const errs = validateHooksWired(cap, wiredSet);
assert.ok(Array.isArray(errs), 'must return an array');
assert.ok(errs.length > 0, 'must return errors when point is unwired');
const joined = errs.join(' ');
assert.match(joined, /not wired/i, 'error must mention "not wired"');
assert.match(joined, /discuss:pre/, 'error must name the point');
assert.match(joined, /test-cap/, 'error must name the capability id');
});
test('returns non-empty error array when contribution point is not in wiredSet', () => {
const cap = makeCapWithContribution('discuss:pre');
const wiredSet = kindsMap({ 'plan:pre': ALL }); // discuss:pre absent
const errs = validateHooksWired(cap, wiredSet);
assert.ok(errs.length > 0, 'must return errors for unwired contribution point');
assert.match(errs.join(' '), /not wired/i);
});
test('returns non-empty error array when gate point is not in wiredSet', () => {
const cap = makeCapWithGate('discuss:pre');
const wiredSet = kindsMap({ 'plan:pre': ALL }); // discuss:pre absent
const errs = validateHooksWired(cap, wiredSet);
assert.ok(errs.length > 0, 'must return errors for unwired gate point');
assert.match(errs.join(' '), /not wired/i);
});
test('returns empty array when all declared points are in wiredSet', () => {
const cap = makeCapWithStep('plan:pre');
const wiredSet = kindsMap({ 'plan:pre': ALL, 'plan:post': ALL, 'execute:post': ALL });
const errs = validateHooksWired(cap, wiredSet);
assert.deepEqual(errs, [], 'must return empty array when all points are wired and kind-covered');
});
test('boundary: cap declaring discuss:pre is rejected against wiredSet lacking it', () => {
const cap = makeCapWithStep('discuss:pre');
const smallSet = kindsMap({ 'plan:pre': ALL, 'plan:post': ALL });
const errs = validateHooksWired(cap, smallSet);
assert.ok(errs.length > 0, 'must reject discuss:pre against a set that lacks it');
});
test('boundary: cap declaring discuss:pre is accepted against real getWiredKinds(ROOT) post-fix', () => {
const cap = makeCapWithStep('discuss:pre');
const { getWiredKinds } = require('../scripts/gen-loop-host-contract.cjs');
const realWired = getWiredKinds(ROOT);
const errs = validateHooksWired(cap, realWired);
assert.deepEqual(
errs, [],
`discuss:pre must be wired and kind-covered after the fix. Errors: ${errs.join('; ')}`,
);
});
test('boundary: cap declaring discuss:post is accepted against real getWiredKinds(ROOT) post-fix', () => {
const cap = makeCapWithContribution('discuss:post');
const { getWiredKinds } = require('../scripts/gen-loop-host-contract.cjs');
const realWired = getWiredKinds(ROOT);
const errs = validateHooksWired(cap, realWired);
assert.deepEqual(
errs, [],
`discuss:post must be wired and kind-covered after the fix. Errors: ${errs.join('; ')}`,
);
});
test('boundary: cap declaring an execute:wave:pre step is accepted against real getWiredKinds(ROOT) (#4148)', () => {
const cap = makeCapWithStep('execute:wave:pre');
const { getWiredKinds } = require('../scripts/gen-loop-host-contract.cjs');
const errs = validateHooksWired(cap, getWiredKinds(ROOT));
assert.deepEqual(
errs, [],
`execute:wave:pre must dispatch step hooks before executor spawning. Errors: ${errs.join('; ')}`,
);
const workflow = fs.readFileSync(path.join(ROOT, 'gsd-core', 'workflows', 'execute-phase.md'), 'utf8');
const wavePre = workflow.indexOf('WAVE_PRE_HOOKS_JSON=$(gsd_run loop render-hooks execute:wave:pre --raw)');
const stepDispatch = workflow.indexOf('**Step dispatch:**', wavePre);
const executorSpawn = workflow.indexOf('3. **Spawn executor agents:**', wavePre);
assert.ok(
wavePre !== -1 && stepDispatch > wavePre && executorSpawn > stepDispatch,
'wave-pre step dispatch must occur after hook rendering and before executor spawning',
);
const stepContract = workflow.slice(stepDispatch, executorSpawn);
assert.match(stepContract, /kind == "step"/, 'wave-pre must select step hooks');
assert.match(stepContract, /loop-hook-dispatch/, 'wave-pre must use the shared dispatch contract');
assert.match(stepContract, /Validate `ref\.command`/, 'wave-pre must validate third-party commands');
assert.match(
stepContract,
/never blocks or redirects executor spawning/,
'wave-pre step failures must remain advisory',
);
});
// ─── #3866: the verify lane must be open to every hook kind ────────────────
//
// verify-work.md's verify_pre_hooks step historically dispatched only
// `kind == "gate"`, so getWiredKinds reported verify:pre → {gate} and any
// capability wanting to DO something before UAT (rather than block it) was
// rejected at registry-build time. The rows below are the machine-observable
// contract: what a capability may declare at verify:pre.
test('boundary: cap declaring a verify:pre step is accepted against real getWiredKinds(ROOT) (#3866)', () => {
const cap = makeCapWithStep('verify:pre');
const { getWiredKinds } = require('../scripts/gen-loop-host-contract.cjs');
const errs = validateHooksWired(cap, getWiredKinds(ROOT));
assert.deepEqual(
errs, [],
'verify:pre must dispatch step hooks — a capability can contribute to what UAT covers, ' +
`not only refuse to let it start. Errors: ${errs.join('; ')}`,
);
});
test('boundary: cap declaring a verify:pre contribution is accepted against real getWiredKinds(ROOT) (#3866)', () => {
const cap = makeCapWithContribution('verify:pre');
const { getWiredKinds } = require('../scripts/gen-loop-host-contract.cjs');
const errs = validateHooksWired(cap, getWiredKinds(ROOT));
assert.deepEqual(
errs, [],
`verify:pre must dispatch contribution hooks. Errors: ${errs.join('; ')}`,
);
});
test('regression: cap declaring a verify:pre gate stays accepted after the step/contribution arms land (#3866)', () => {
// The pre-existing gate arm is the one behavior verify:pre already had.
// Adding arms above it must not orphan or narrow it.
const cap = makeCapWithGate('verify:pre');
const { getWiredKinds } = require('../scripts/gen-loop-host-contract.cjs');
const errs = validateHooksWired(cap, getWiredKinds(ROOT));
assert.deepEqual(
errs, [],
`the verify:pre gate arm must survive the new kind arms. Errors: ${errs.join('; ')}`,
);
});
test('verify:pre dispatch text covers exactly the three hook kinds, no more (#3866)', () => {
// Asserts the VALUE, not `.has(...)`: a scanner that over-credits (or a
// future fourth token creeping into HOOK_KINDS) fails here too.
const { getWiredKinds, HOOK_KINDS } = require('../scripts/gen-loop-host-contract.cjs');
const covered = getWiredKinds(ROOT).get('verify:pre');
assert.ok(covered, 'verify:pre must have a render-hooks call site in the host loop');
assert.deepEqual(
[...covered].sort(), [...HOOK_KINDS].sort(),
`verify:pre must cover every hook kind; got ${JSON.stringify([...covered].sort())}`,
);
});
test('invalid points (not in VALID_LOOP_POINTS) are not flagged as "unwired" (already caught by schema validator)', () => {
const cap = {
id: 'test-cap',
role: 'feature',
steps: [{ point: 'not:a:real:point', ref: { skill: 'x' }, produces: [], consumes: [], onError: 'skip' }],
contributions: [],
gates: [],
config: {},
};
const wiredSet = kindsMap({ 'plan:pre': ALL }); // the invalid point is not here either
const errs = validateHooksWired(cap, wiredSet);
// Should NOT flag it — invalid points are the schema validator's job
const notWiredErrors = errs.filter((e) => /not wired/i.test(e));
assert.deepEqual(
notWiredErrors, [],
'validateHooksWired must not flag invalid points as "not wired" (those are caught by schema validation)',
);
});
});
// ─── Defect 3: hook-kind coverage (#3606) ────────────────────────────────────
//
// A point having a call site proves hooks are RENDERED, not DISPATCHED. A
// consumer that iterates only `kind == "gate"` (or narrows `kind == "step"`
// to one ref.skill) silently drops every other registered kind — mempalace's
// step hooks were wired, active, and never run for five days across
// plan:post / execute:wave:post / verify:post / execute:post consumers.
// The guard must validate, per registered kind, that the call site's
// dispatch text covers that kind.
describe('Defect 3: validateHooksWired hook-kind coverage (#3606)', () => {
test('a step hook at a point whose site covers only gate fails with a kind-coverage error', () => {
const cap = {
id: 'test-cap',
role: 'feature',
steps: [{ point: 'plan:post', ref: { skill: 'my-skill' }, produces: [], consumes: [], onError: 'skip' }],
contributions: [],
gates: [],
config: {},
};
const wired = kindsMap({ 'plan:post': ['gate'] }); // hand-rolled gate-only consumer
const errs = validateHooksWired(cap, wired);
assert.ok(errs.length > 0, 'a step registered at a gate-only point must fail validation');
const joined = errs.join(' ');
assert.match(joined, /plan:post/, 'error must name the point');
assert.match(joined, /test-cap/, 'error must name the capability id');
assert.match(joined, /step/, 'error must name the uncovered kind');
assert.doesNotMatch(joined, /not wired/i, 'the point IS wired — this is the coverage error, not the wiring error');
});
test('a contribution hook at a point whose site covers only gate fails', () => {
const cap = {
id: 'test-cap',
role: 'feature',
steps: [],
contributions: [{ point: 'execute:wave:post', into: 'orchestrator', fragment: { inline: 'hi' }, produces: [], consumes: [] }],
gates: [],
config: {},
};
const wired = kindsMap({ 'execute:wave:post': ['gate'] });
const errs = validateHooksWired(cap, wired);
assert.ok(errs.length > 0, 'a contribution registered at a gate-only point must fail validation');
assert.match(errs.join(' '), /contribution/);
});
test('a point covered for exactly the registered kinds passes (boundary: no over-reach)', () => {
const cap = {
id: 'test-cap',
role: 'feature',
steps: [{ point: 'ship:post', ref: { skill: 'my-skill' }, produces: [], consumes: [], onError: 'skip' }],
contributions: [],
gates: [],
config: {},
};
const wired = kindsMap({ 'ship:post': ['step'] }); // ship.md's real coverage
assert.deepEqual(validateHooksWired(cap, wired), [], 'step at a step-covered point must pass');
});
test('unwired-point errors are unchanged when the arg is a kinds Map', () => {
const cap = {
id: 'test-cap',
role: 'feature',
steps: [{ point: 'discuss:pre', ref: { skill: 'x' }, produces: [], consumes: [], onError: 'skip' }],
contributions: [],
gates: [],
config: {},
};
const wired = kindsMap({ 'plan:pre': ALL }); // discuss:pre absent entirely
const errs = validateHooksWired(cap, wired);
assert.ok(errs.length > 0, 'unwired point still fails');
assert.match(errs.join(' '), /not wired/i, 'the existing unwired error text is preserved');
});
test('boundary: invalid points are not kind-flagged (schema validator owns them)', () => {
const cap = {
id: 'test-cap',
role: 'feature',
steps: [{ point: 'not:a:real:point', ref: { skill: 'x' }, produces: [], consumes: [], onError: 'skip' }],
contributions: [],
gates: [],
config: {},
};
const errs = validateHooksWired(cap, kindsMap({}));
assert.deepEqual(errs, [], 'invalid points produce neither wiring nor coverage errors');
});
test('the real tree passes the extended guard for every in-tree registered kind', () => {
// Self-enforcement: the repo's own capabilities and host workflows must
// satisfy the new check — this is the test that forces consumer fixes.
const { getWiredKinds } = require('../scripts/gen-loop-host-contract.cjs');
const wiredKinds = getWiredKinds(ROOT);
const failures = [];
for (const capPath of fs.readdirSync(path.join(ROOT, 'capabilities'))) {
const manifest = path.join(ROOT, 'capabilities', capPath, 'capability.json');
if (!fs.existsSync(manifest)) continue;
let cap;
try { cap = JSON.parse(fs.readFileSync(manifest, 'utf8')); } catch { continue; }
for (const err of validateHooksWired(cap, wiredKinds)) failures.push(`${capPath}: ${err}`);
}
assert.deepEqual(
failures, [],
`in-tree capabilities must be fully kind-covered by host workflows:\n ${failures.join('\n ')}`,
);
});
});
describe('coveredKindsInRegion: dispatch-text kind coverage (#3606)', () => {
test('deferral line without a kind discriminator covers every kind', () => {
const region = 'Apply each entry in `activeHooks` per @gsd-core/references/loop-hook-dispatch.md\n';
const { coveredKindsInRegion } = require('../scripts/gen-loop-host-contract.cjs');
assert.deepEqual([...coveredKindsInRegion(region)].sort(), ['contribution', 'gate', 'step']);
});
test('deferral line naming one kind covers only that kind', () => {
const { coveredKindsInRegion } = require('../scripts/gen-loop-host-contract.cjs');
const region = 'Dispatch `kind == "step"` hooks per @gsd-core/references/loop-hook-dispatch.md.\n';
assert.deepEqual([...coveredKindsInRegion(region)], ['step']);
});
test('a bare §-citation of the reference covers nothing (validation guidance, not dispatch)', () => {
const { coveredKindsInRegion } = require('../scripts/gen-loop-host-contract.cjs');
const region = '⚠ **Validate `check` before shell use** — `loop-hook-dispatch.md` § `gate`.\n**For each active entry where `kind == "gate"`**\n';
assert.deepEqual([...coveredKindsInRegion(region)], ['gate'], 'gate dispatched unconditionally, step/contribution NOT');
});
test('a narrowed kind line (kind == step AND ref.skill ==) does not cover the kind', () => {
const { coveredKindsInRegion } = require('../scripts/gen-loop-host-contract.cjs');
const region = 'Resolve hooks where `kind == "step"` and `ref.skill == "secure-phase"`.\n';
assert.deepEqual([...coveredKindsInRegion(region)], [], 'a one-skill special case proves nothing about the kind generally');
});
test('quote and operator variants are tolerated (=== and single quotes)', () => {
const { coveredKindsInRegion } = require('../scripts/gen-loop-host-contract.cjs');
const region = "for h in hooks: if h.kind === 'contribution' then inject\n";
assert.deepEqual([...coveredKindsInRegion(region)], ['contribution']);
});
test('CRLF input yields the same verdicts as LF', () => {
const { coveredKindsInRegion } = require('../scripts/gen-loop-host-contract.cjs');
const lf = 'per @gsd-core/references/loop-hook-dispatch.md\n`kind == "gate"` unconditional\n';
const crlf = lf.replace(/\n/g, '\r\n');
assert.deepEqual([...coveredKindsInRegion(crlf)].sort(), [...coveredKindsInRegion(lf)].sort());
});
test('scanWiredKinds accumulates per-point unions across multiple call sites', () => {
const { scanWiredKinds } = require('../scripts/gen-loop-host-contract.cjs');
const text = [
'X=$(gsd_run loop render-hooks verify:post --raw)',
'**For each active entry where `kind == "gate"`**',
'Y=$(gsd_run loop render-hooks verify:post --raw)',
'Dispatch `kind == "step"` hooks per @gsd-core/references/loop-hook-dispatch.md.',
'',
].join('\n');
const m = scanWiredKinds(text);
assert.ok(m.has('verify:post'), 'point must be present');
assert.deepEqual(
[...m.get('verify:post')].sort(),
['gate', 'step'],
'two call sites at one point accumulate their coverage',
);
});
test('capId == and into == narrowing void contribution credit (#3606 adversarial finding)', () => {
const { coveredKindsInRegion } = require('../scripts/gen-loop-host-contract.cjs');
const capIdNarrowed = 'Resolve hooks where `kind == "contribution"` and `capId == "security"`.\n';
assert.deepEqual([...coveredKindsInRegion(capIdNarrowed)], [],
'a one-capability hand-roll is not generic contribution coverage');
const intoNarrowed = 'For each entry where `kind == "contribution"` and `into == "planner"`: inject.\n';
assert.deepEqual([...coveredKindsInRegion(intoNarrowed)], [],
'planner-targeted-only injection leaves orchestrator-targeted contributions dispatched by no one');
});
test('a negated kind mention describes an absence, not a dispatch', () => {
const { coveredKindsInRegion } = require('../scripts/gen-loop-host-contract.cjs');
const region = 'Branch 1 — no active `ship:post` step hooks (`activeHooks` has no entry with `kind == "step"`): Skip.\n';
assert.deepEqual([...coveredKindsInRegion(region)], [],
'"has no entry with kind == step" must not count as step coverage');
});
test('a deferral sentence keeps its credit when the NEXT sentence specializes (segment split)', () => {
const { coveredKindsInRegion } = require('../scripts/gen-loop-host-contract.cjs');
const region = 'Dispatch `kind == "step"` hooks per @gsd-core/references/loop-hook-dispatch.md. `ref.skill == "code-review"`:\n';
assert.deepEqual([...coveredKindsInRegion(region)], ['step'],
'the generic deferral and the one-hook specialization are separate sentences');
});
test('a deferral path with a period (loop-hook-dispatch.md) is not split into segments', () => {
const { coveredKindsInRegion } = require('../scripts/gen-loop-host-contract.cjs');
const region = 'Apply each entry per @~/.claude/gsd-core/references/loop-hook-dispatch.md\n';
assert.deepEqual([...coveredKindsInRegion(region)].sort(), ['contribution', 'gate', 'step'],
'the .md inside the deferral path is not a sentence boundary');
});
test('a site whose consumers are all narrowed yields the zero-coverage error, not "not wired"', () => {
const cap = {
id: 'test-cap',
role: 'feature',
steps: [{ point: 'verify:post', ref: { skill: 'x' }, produces: [], consumes: [], onError: 'skip' }],
contributions: [],
gates: [],
config: {},
};
const wired = new Map([['verify:post', new Set()]]); // sites exist, all narrowed
const errs = validateHooksWired(cap, wired);
assert.ok(errs.length > 0, 'all-narrowed consumers must fail');
const joined = errs.join(' ');
assert.match(joined, /covers NO hook kind/i, 'error must name the zero-coverage diagnosis');
assert.doesNotMatch(joined, /not wired/i, 'the site EXISTS — misdiagnosing it as unwired points at the wrong remedy');
});
test('HOOK_KINDS vocabulary parity: scanner kinds match the validator group->kind mapping', () => {
const { HOOK_KINDS } = require('../scripts/gen-loop-host-contract.cjs');
const { HOOK_GROUP_KINDS } = require('../gsd-core/bin/lib/capability-validator.cjs');
assert.deepEqual(
[...HOOK_KINDS].sort(),
Object.values(HOOK_GROUP_KINDS).sort(),
'the scanner vocabulary must stay in lock-step with validateHooksWired\u2019s exported group->kind mapping — a rename on either side must fail here',
);
});
});
// ─── Anti-pattern parity guards ──────────────────────────────────────────────
describe('Anti-pattern parity: host-file set has a single source of truth', () => {
test('every STEP_WORKFLOWS entry file exists on disk and contains a gsd:loop-host marker', () => {
for (const { file, step } of STEP_WORKFLOWS) {
const absPath = path.join(ROOT, 'gsd-core', 'workflows', file);
assert.ok(
fs.existsSync(absPath),
`STEP_WORKFLOWS entry ${file} (step: ${step}) does not exist on disk at ${absPath}`,
);
const content = fs.readFileSync(absPath, 'utf8');
assert.match(
content,
/<!--\s*gsd:loop-host/,
`${file} (step: ${step}) is listed in STEP_WORKFLOWS but lacks a gsd:loop-host marker`,
);
}
});
test('HOST_LOOP_FILES matches STEP_WORKFLOWS rows and auxiliary hosts', () => {
const expectedFromStepWorkflows = STEP_WORKFLOWS.flatMap(({ file, auxiliaryHosts = [] }) => [
'gsd-core/workflows/' + file,
...auxiliaryHosts.map((host) => 'gsd-core/workflows/' + host.file),
]);
assert.deepEqual(
HOST_LOOP_FILES,
expectedFromStepWorkflows,
'HOST_LOOP_FILES must be derived from STEP_WORKFLOWS rows and their auxiliary hosts',
);
});
test('every workflow file carrying a gsd:loop-host marker is present in STEP_WORKFLOWS', () => {
const workflowsDir = path.join(ROOT, 'gsd-core', 'workflows');
// Find all .md files in workflows dir (non-recursive — top-level only, subdirs are mode files)
const allMdFiles = fs.readdirSync(workflowsDir)
.filter((f) => f.endsWith('.md'))
.sort();
const stepWorkflowFiles = new Set(STEP_WORKFLOWS.map((w) => w.file));
const missingFromStepWorkflows = [];
for (const mdFile of allMdFiles) {
const absPath = path.join(workflowsDir, mdFile);
const content = fs.readFileSync(absPath, 'utf8');
if (/<!--\s*gsd:loop-host/.test(content) && !stepWorkflowFiles.has(mdFile)) {
missingFromStepWorkflows.push(mdFile);
}
}
assert.deepEqual(
missingFromStepWorkflows, [],
`These workflow files have a gsd:loop-host marker but are absent from STEP_WORKFLOWS: ` +
`${missingFromStepWorkflows.join(', ')}. Add them to STEP_WORKFLOWS in scripts/gen-loop-host-contract.cjs.`,
);
});
test('POINT_ORDER (capability-registry) === flattened LOOP_HOST_CONTRACT points — schema/contract drift guard', () => {
// Flatten all contract points in order
const contractPoints = [];
for (const entry of LOOP_HOST_CONTRACT) {
contractPoints.push(...entry.points);
}
assert.deepEqual(
POINT_ORDER,
contractPoints,
'POINT_ORDER in gen-capability-registry must equal the flattened LOOP_HOST_CONTRACT points in order. ' +
`POINT_ORDER: ${JSON.stringify(POINT_ORDER)}, contract flatten: ${JSON.stringify(contractPoints)}`,
);
});
test('CANONICAL_POINTS (gen-loop-host-contract) matches POINT_ORDER (gen-capability-registry)', () => {
assert.deepEqual(
[...CANONICAL_POINTS],
POINT_ORDER,
'CANONICAL_POINTS in gen-loop-host-contract must equal POINT_ORDER in gen-capability-registry',
);
});
});
// ─── Property test ────────────────────────────────────────────────────────────
describe('Property: scanWiredPoints is a correct extractor', () => {
test('fc: scanWiredPoints(text) returns exactly the set of points whose call sites appear in text', () => {
// The canonical 12 points from CANONICAL_POINTS
const allPoints = [...CANONICAL_POINTS];
fc.assert(
fc.property(
fc.subarray(allPoints, { minLength: 0, maxLength: allPoints.length }),
(subset) => {
// Build a synthetic text containing one call site per point in the subset
const lines = subset.map((p) => `HOOKS_JSON=$(gsd_run loop render-hooks ${p} --raw)`);
// Add some noise to exercise robustness
const noise = ['# comment', 'echo hello', `gsd_run loop some-other-command`, ''];
const text = [...lines, ...noise].join('\n');
const result = scanWiredPoints(text);
// result must be a Set
if (!(result instanceof Set)) return false;
// result must contain exactly the subset points
const resultArr = [...result].sort();
const subsetArr = [...subset].sort();
if (resultArr.length !== subsetArr.length) return false;
for (let i = 0; i < subsetArr.length; i++) {
if (resultArr[i] !== subsetArr[i]) return false;
}
return true;
},
),
{ numRuns: 200 },
);
});
test('NIT-02: scanWiredPoints does not match an incomplete occurrence (no point token after render-hooks)', () => {
// A line with `loop render-hooks` but no following point token must not match
const incompleteText = 'HOOKS_JSON=$(gsd_run loop render-hooks\n)';
const result = scanWiredPoints(incompleteText);
assert.strictEqual(
result.size,
0,
'scanWiredPoints must return an empty Set when the render-hooks call has no point token. ' +
`Got: ${JSON.stringify([...result])}`,
);
});
});
});
// ─── activationKey validation (issue #1304 Phase 1) ─────────────────────────
describe('activationKey validation', () => {
// Minimal valid feature capability fixture for activationKey tests.
// Uses UI_CAP as a base so all required fields are satisfied.
function makeCapWithActivationKey(activationKey) {
const cap = { ...UI_CAP, activationKey };
if (activationKey === undefined) delete cap.activationKey;
return cap;
}
// (a) valid activationKey referencing a key declared in the cap's own config slice
test('(a) valid activationKey referencing own config key: no errors, emitted in registry', () => {
// UI_CAP declares 'workflow.ui_phase' (boolean) in its config — use that as activationKey
const cap = makeCapWithActivationKey('workflow.ui_phase');
const errors = validateCapability(cap, 'ui');
assert.deepEqual(
errors,
[],
'Expected no validation errors for activationKey that matches own config key, got: ' +
JSON.stringify(errors),
);
// Confirm activationKey is emitted in the built registry
const capDir = makeTempCapDir({ ui: cap });
const { capMap, errors: loadErrors } = loadAndValidate(new Set(), capDir);
assert.deepEqual(loadErrors, [], 'Expected no load errors: ' + JSON.stringify(loadErrors));
const registry = buildRegistry(capMap);
assert.strictEqual(
registry.capabilities.ui.activationKey,
'workflow.ui_phase',
'registry.capabilities.ui.activationKey must equal the declared activationKey',
);
});
// (b) activationKey referencing an UNKNOWN config key → a specific error naming the cap + key
test('(b) activationKey referencing unknown config key: specific error emitted', () => {
const cap = makeCapWithActivationKey('no-such-key.enabled');
const errors = validateCapability(cap, 'ui');
assert.ok(
errors.length > 0,
'Expected at least one error when activationKey references an unknown config key',
);
const joined = errors.join('\n');
assert.ok(
joined.includes('no-such-key.enabled'),
'Error must name the bad activationKey, got: ' + JSON.stringify(errors),
);
assert.ok(
joined.includes('ui') || joined.includes('(unknown)'),
'Error must name the capability id, got: ' + JSON.stringify(errors),
);
assert.ok(
joined.includes('config'),
'Error must reference the config slice, got: ' + JSON.stringify(errors),
);
});
// (c) activationKey absent → valid (back-compat)
test('(c) activationKey absent: valid (back-compat — no errors)', () => {
const cap = makeCapWithActivationKey(undefined);
assert.ok(
!Object.prototype.hasOwnProperty.call(cap, 'activationKey'),
'Fixture must not have activationKey when undefined is passed',
);
const errors = validateCapability(cap, 'ui');
assert.deepEqual(
errors,
[],
'Expected no validation errors when activationKey is absent, got: ' + JSON.stringify(errors),
);
});
// (d) activationKey present but empty string → error
test('(d) activationKey empty string: error', () => {
const cap = makeCapWithActivationKey('');
const errors = validateCapability(cap, 'ui');
assert.ok(
errors.length > 0,
'Expected at least one error when activationKey is an empty string',
);
assert.ok(
errors.some((e) => e.includes('activationKey') && e.includes('non-empty')),
'Error must mention activationKey and non-empty, got: ' + JSON.stringify(errors),
);
});
// (d-extra) activationKey non-string (number) → error
test('(d-extra) activationKey non-string (number): error', () => {
const cap = { ...UI_CAP, activationKey: 42 };
const errors = validateCapability(cap, 'ui');
assert.ok(
errors.length > 0,
'Expected at least one error when activationKey is a number',
);
assert.ok(
errors.some((e) => e.includes('activationKey') && e.includes('non-empty')),
'Error must mention activationKey and non-empty string requirement, got: ' + JSON.stringify(errors),
);
});
// (e) reserved-name guard: activationKey === '__proto__' → reserved-name error, not not-declared error
test('(e) activationKey "__proto__": reserved-name error (not not-declared error)', () => {
const cap = makeCapWithActivationKey('__proto__');
const errors = validateCapability(cap, 'ui');
assert.ok(
errors.length > 0,
'Expected at least one error when activationKey is "__proto__"',
);
assert.ok(
errors.some((e) => e.includes('__proto__') && e.includes('reserved')),
'Error must mention "__proto__" and "reserved", got: ' + JSON.stringify(errors),
);
// Must NOT emit the not-declared error (the guard runs before hasOwnProperty.call)
assert.ok(
!errors.some((e) => e.includes('is not declared in this capability\'s config slice')),
'Reserved-name guard must fire before the not-declared check; got: ' + JSON.stringify(errors),
);
});
// (f) reserved-name guard: activationKey === 'constructor' → reserved-name error
test('(f) activationKey "constructor": reserved-name error', () => {
const cap = makeCapWithActivationKey('constructor');
const errors = validateCapability(cap, 'ui');
assert.ok(
errors.length > 0,
'Expected at least one error when activationKey is "constructor"',
);
assert.ok(
errors.some((e) => e.includes('constructor') && e.includes('reserved')),
'Error must mention "constructor" and "reserved", got: ' + JSON.stringify(errors),
);
assert.ok(
!errors.some((e) => e.includes('is not declared in this capability\'s config slice')),
'Reserved-name guard must fire before the not-declared check; got: ' + JSON.stringify(errors),
);
});
// (g) reserved-name guard: activationKey === 'prototype' → reserved-name error
test('(g) activationKey "prototype": reserved-name error', () => {
const cap = makeCapWithActivationKey('prototype');
const errors = validateCapability(cap, 'ui');
assert.ok(
errors.length > 0,
'Expected at least one error when activationKey is "prototype"',
);
assert.ok(
errors.some((e) => e.includes('prototype') && e.includes('reserved')),
'Error must mention "prototype" and "reserved", got: ' + JSON.stringify(errors),
);
assert.ok(
!errors.some((e) => e.includes('is not declared in this capability\'s config slice')),
'Reserved-name guard must fire before the not-declared check; got: ' + JSON.stringify(errors),
);
});
// (h) regression guard: activationKey === null → non-empty-string error (typeof null === 'object' footgun)
test('(h) activationKey null: non-empty-string error (typeof null footgun regression guard)', () => {
const cap = { ...UI_CAP, activationKey: null };
const errors = validateCapability(cap, 'ui');
assert.ok(
errors.length > 0,
'Expected at least one error when activationKey is null',
);
assert.ok(
errors.some((e) => e.includes('activationKey') && e.includes('non-empty')),
'Error must mention activationKey and non-empty string requirement (typeof null === "object" must not bypass the check), got: ' + JSON.stringify(errors),
);
// Must NOT emit the reserved-name error
assert.ok(
!errors.some((e) => e.includes('reserved')),
'null must not trigger the reserved-name guard, got: ' + JSON.stringify(errors),
);
});
// Registry integration: activationKey absent → field absent in registry entry (omit semantics)
test('activationKey absent: field omitted from registry capabilities entry', () => {
const cap = makeCapWithActivationKey(undefined);
const capDir = makeTempCapDir({ ui: cap });
const { capMap, errors } = loadAndValidate(new Set(), capDir);
assert.deepEqual(errors, [], 'Expected no load errors: ' + JSON.stringify(errors));
const registry = buildRegistry(capMap);
assert.ok(
!Object.prototype.hasOwnProperty.call(registry.capabilities.ui, 'activationKey'),
'activationKey must be absent from registry.capabilities.ui when not declared',
);
});
// Verify graphify capability.json declares correct activationKey
test('graphify capability.json declares activationKey matching its own config key', () => {
const graphifyCap = JSON.parse(
require('node:fs').readFileSync(
require('node:path').join(ROOT, 'capabilities', 'graphify', 'capability.json'),
'utf8',
),
);
assert.strictEqual(
graphifyCap.activationKey,
'graphify.enabled',
'graphify capability.json must declare activationKey: "graphify.enabled"',
);
assert.ok(
Object.prototype.hasOwnProperty.call(graphifyCap.config, 'graphify.enabled'),
'graphify capability.json config must contain key "graphify.enabled"',
);
});
// Verify intel capability.json declares correct activationKey
test('intel capability.json declares activationKey matching its own config key', () => {
const intelCap = JSON.parse(
require('node:fs').readFileSync(
require('node:path').join(ROOT, 'capabilities', 'intel', 'capability.json'),
'utf8',
),
);
assert.strictEqual(
intelCap.activationKey,
'intel.enabled',
'intel capability.json must declare activationKey: "intel.enabled"',
);
assert.ok(
Object.prototype.hasOwnProperty.call(intelCap.config, 'intel.enabled'),
'intel capability.json config must contain key "intel.enabled"',
);
});
// (i) role:runtime capability with activationKey → feature-only field error
test('(i) role:runtime with activationKey: feature-only field error', () => {
const cap = {
id: 'cursor', role: 'runtime', title: 'Cursor', description: 'Cursor IDE runtime',
tier: 'standard', requires: [],
activationKey: 'some.key',
runtime: {
configHome: { kind: 'dot-home', name: '.cursor', env: ['CURSOR_CONFIG_DIR'] },
configFormat: 'settings-json',
artifactLayout: { global: [], local: [] },
commandStyle: 'slash-hyphen',
hooksSurface: 'cursor-hooks-json',
hookEvents: 'claude',
sandboxTier: 'none',
supportTier: 2,
installSurface: 'cursor-hooks-json',
writesSharedSettings: false,
permissionWriter: null,
extendedHookEvents: [],
hostIntegration: {
embeddingMode: 'declarative',
commandSurface: 'slash-file',
dispatch: { namedDispatch: true, nested: true, maxDepth: 2, background: false, subagentToolkit: 'full', backgroundDispatch: false },
modelMode: 'passive',
hookBus: 'host',
stateIO: 'filesystem',
transport: 'mcp',
runtime: 'node',
},
},
};
const errors = validateCapability(cap, 'cursor');
assert.ok(
errors.length > 0,
'Expected at least one error when role:runtime declares activationKey',
);
assert.ok(
errors.some((e) => e.includes('activationKey') && e.includes('feature-only')),
'Error must mention activationKey and feature-only, got: ' + JSON.stringify(errors),
);
});
});
// ─── ADR-1244 D2: validator extraction generative parity ──────────────────────
//
// The validator now lives in gsd-core/bin/lib/capability-validator.cjs and is
// re-exported by the generator. These assertions guarantee the build-time
// generator and the runtime overlay share ONE validator implementation — no
// divergent copy can drift between them, because the generator re-exports the
// very same object references.
describe('ADR-1244 D2: validator extraction generative parity', () => {
const CORE = [
'validateCapability', 'validateCrossCapability', 'validateVersionEnvelope',
'validateConsumesGlobal', 'validateAgainstContract', 'validateConfigSliceEntry',
'validateRuntimeBody', 'classifyCrossErrors',
];
test('the runtime validator module exposes the full validator surface', () => {
for (const sym of [...CORE, 'SEMVER_RE', 'SEMVER_RANGE_RE', 'POINT_ORDER', 'VALID_LOOP_POINTS', 'VALID_TIERS']) {
assert.ok(sym in capValidatorModule, `validator module must export ${sym}`);
}
assert.strictEqual(typeof capValidatorModule.validateCapability, 'function');
assert.ok(capValidatorModule.SEMVER_RE instanceof RegExp);
});
test('every generator-re-exported validator symbol is the SAME object as the validator module (no drift)', () => {
const shared = Object.keys(capValidatorModule).filter((k) => Object.prototype.hasOwnProperty.call(generatorModule, k));
assert.ok(shared.length >= 20, `expected the generator to re-export the validator surface, got ${shared.length}`);
for (const k of shared) {
assert.strictEqual(
generatorModule[k],
capValidatorModule[k],
`generator export "${k}" must be the SAME reference as the validator module's (drift detected)`,
);
}
});
test('core validators are re-exported identically by the generator', () => {
for (const sym of CORE) {
assert.strictEqual(
generatorModule[sym], capValidatorModule[sym],
`${sym} must be re-exported by the generator as the validator module's reference`,
);
}
});
test('the extracted validator runs standalone (no generator/build-time deps required)', () => {
// Proves the module is genuinely runtime-callable: a clean require + validate
// with no install-profiles/clusters/config-schema machinery present.
const { validateCapability } = capValidatorModule;
const cap = {
id: 'demo', role: 'feature', version: '1.0.0', title: 'Demo', description: 'demo',
tier: 'standard', requires: [], runtimeCompat: { supported: ['*'], unsupported: [] },
skills: [], agents: [], hooks: [], config: {}, steps: [], contributions: [], gates: [],
};
assert.deepEqual(validateCapability(cap, 'demo'), []);
const { version: _v, ...noVersion } = cap;
assert.ok(validateCapability(noVersion, 'demo').some((e) => e.includes('version')));
});
});
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-2530-valid-config-keys.test.cjs — consolidation epic #1969 (B3 #1972)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-2530-valid-config-keys (consolidation epic #1969 B3 #1972)", () => {
'use strict';
/**
* Regression tests for config key bugs:
* #2530 — workflow._auto_chain_active is internal state, must not be in VALID_CONFIG_KEYS
* #2531 — hooks.workflow_guard is used by hook and documented but missing from VALID_CONFIG_KEYS
* #2532 — workflow.ui_review is used in autonomous.md but missing from config validation
* #2533 — workflow.max_discuss_passes is used in discuss-phase.md but missing from VALID_CONFIG_KEYS
* #2535 — sub_repos and plan_checker legacy keys need CONFIG_KEY_SUGGESTIONS migration hints
* #3162 — resolve_model_ids missing from VALID_CONFIG_KEYS; workflow._auto_chain_active must be
* accepted by isValidConfigKey (written by workflows) without being user-visible
* #1747 — buildNewProjectConfig emits four search-provider keys (tavily_search, ref_search,
* perplexity, jina) that research-provider.cts consumes but were missing from
* VALID_CONFIG_KEYS, causing /gsd-settings unknown-key warnings on fresh projects
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs');
const {
VALID_CONFIG_KEYS,
isCentralConfigKey,
isValidConfigKey,
} = require('../gsd-core/bin/lib/config-schema.cjs');
const capabilityRegistry = require('../gsd-core/bin/lib/capability-registry.cjs');
describe('VALID_CONFIG_KEYS correctness', () => {
test('#2530: workflow._auto_chain_active must not be in VALID_CONFIG_KEYS (internal state)', () => {
assert.ok(
!VALID_CONFIG_KEYS.has('workflow._auto_chain_active'),
'workflow._auto_chain_active is internal runtime state and must not be user-settable'
);
});
test('#2531: hooks.workflow_guard must be in VALID_CONFIG_KEYS (used by hook, documented)', () => {
assert.ok(
VALID_CONFIG_KEYS.has('hooks.workflow_guard'),
'hooks.workflow_guard is read by gsd-workflow-guard.js hook and documented in CONFIGURATION.md'
);
});
test('#2532: workflow.ui_review must remain valid but is no longer centrally owned', () => {
assert.strictEqual(
isValidConfigKey('workflow.ui_review'),
true,
'workflow.ui_review is still user-facing config and must validate'
);
assert.strictEqual(
isCentralConfigKey('workflow.ui_review'),
false,
'workflow.ui_review is owned by the UI capability after ADR-857 Phase 6 cutover'
);
});
test('#2533: workflow.max_discuss_passes must be in VALID_CONFIG_KEYS (used in discuss-phase.md)', () => {
assert.ok(
VALID_CONFIG_KEYS.has('workflow.max_discuss_passes'),
'workflow.max_discuss_passes is read in discuss-phase.md via gsd-sdk query config-get'
);
});
test('#3162: resolve_model_ids must be in VALID_CONFIG_KEYS (documented user-facing key)', () => {
assert.ok(
VALID_CONFIG_KEYS.has('resolve_model_ids'),
'resolve_model_ids is documented in CONFIGURATION.md and read by core.cjs/session-runner.ts'
);
});
test('#3162: workflow._auto_chain_active must be accepted by isValidConfigKey (written by workflows)', () => {
assert.strictEqual(
isValidConfigKey('workflow._auto_chain_active'),
true,
'workflow._auto_chain_active is written by plan-phase, execute-phase, discuss-phase, transition workflows via config-set'
);
});
});
describe('#1747: new-project config emits only schema-recognized provider keys', () => {
// buildNewProjectConfig emits seven search-provider availability flags and
// research-provider.cts providerAvailability() consumes all seven, but only
// three were in VALID_CONFIG_KEYS → /gsd-settings warned on the four
// unregistered keys (tavily_search, ref_search, perplexity, jina) for every
// freshly generated .planning/config.json.
// The four keys that were emitted + consumed but missing from the schema.
const MISSING_KEYS = ['tavily_search', 'ref_search', 'perplexity', 'jina'];
// Every config-driven provider flag read by providerAvailability() in
// src/research-provider.cts. context7/websearch are excluded: hardcoded
// `true`, not config-gated, so no config key to register.
const PROVIDER_CONFIG_KEYS = [
'brave_search',
'firecrawl',
'exa_search',
'tavily_search',
'ref_search',
'perplexity',
'jina',
];
test('the four previously-missing provider keys are in VALID_CONFIG_KEYS', () => {
const absent = MISSING_KEYS.filter((k) => !VALID_CONFIG_KEYS.has(k));
assert.deepStrictEqual(
absent,
[],
`These provider keys are emitted by buildNewProjectConfig and consumed by research-provider.cts but missing from VALID_CONFIG_KEYS:\n ${absent.join('\n ')}\n\nAdd them to gsd-core/bin/shared/config-schema.manifest.json (validKeys).`
);
});
test('every config-driven research-provider flag is registered in the schema (drift guard)', () => {
const drifted = PROVIDER_CONFIG_KEYS.filter((k) => !VALID_CONFIG_KEYS.has(k));
assert.deepStrictEqual(
drifted,
[],
`These research-provider config flags are not in VALID_CONFIG_KEYS — a fresh /gsd-new-project config would trigger an unknown-key warning under /gsd-settings:\n ${drifted.join('\n ')}\n\nWhen you add a provider to providerAvailability() in src/research-provider.cts, also register its config key in gsd-core/bin/shared/config-schema.manifest.json.`
);
});
});
describe('ADR-857 Phase 6 capability config ownership', () => {
test('migrated capability config keys are valid through the registry, not central schema residue', () => {
const capabilityKeys = Object.keys(capabilityRegistry.configSchema || {}).sort();
assert.ok(capabilityKeys.length > 0, 'expected generated registry config schema keys');
for (const key of capabilityKeys) {
assert.strictEqual(isValidConfigKey(key), true, `${key} must remain accepted by config validation`);
assert.strictEqual(isCentralConfigKey(key), false, `${key} must be capability-owned, not central`);
assert.strictEqual(VALID_CONFIG_KEYS.has(key), false, `${key} must not remain in central VALID_CONFIG_KEYS`);
}
});
});
describe('CONFIG_KEY_SUGGESTIONS migration hints (#2535)', () => {
let tmpDir;
test('config-set sub_repos emits "Did you mean planning.sub_repos?" suggestion', (t) => {
tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const result = runGsdTools(['config-set', 'sub_repos', '[]'], tmpDir);
assert.ok(!result.success, 'config-set sub_repos should fail');
const combined = result.error + result.output;
assert.ok(
combined.includes('Did you mean') && combined.includes('planning.sub_repos'),
`Expected "Did you mean planning.sub_repos?" in error, got:\nstdout: ${result.output}\nstderr: ${result.error}`
);
});
test('config-set plan_checker emits "Did you mean workflow.plan_check?" suggestion', (t) => {
tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const result = runGsdTools(['config-set', 'plan_checker', 'true'], tmpDir);
assert.ok(!result.success, 'config-set plan_checker should fail');
const combined = result.error + result.output;
assert.ok(
combined.includes('Did you mean') && combined.includes('workflow.plan_check'),
`Expected "Did you mean workflow.plan_check?" in error, got:\nstdout: ${result.output}\nstderr: ${result.error}`
);
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-2851-workflow-bare-gsd-tools.test.cjs — consolidation epic #1969 (B3 #1972)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-2851-workflow-bare-gsd-tools (consolidation epic #1969 B3 #1972)", () => {
/**
* Bug #2851: plan-phase.md §13e calls bare `gsd-tools` — incomplete fix of #2245
*
* Workflow bodies should not call `gsd-tools` as an unguarded bare command.
* Use the resolver snippets for SDK calls, or an explicit local CJS path when
* a command intentionally targets the checked-in legacy script:
*
* node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" <subcommand> [args]
*
* As of #621, the §13e gap-analysis call uses the `gsd_run` launcher (the
* canonical resolvable form) instead of the absolute-$HOME path above.
* Both forms are resolvable; `gsd_run` is now the preferred canonical form.
*
* Some workflow markdown files leaked the bare `gsd-tools <subcommand>` form,
* which fails with `command not found` at runtime.
*
* This test parses every markdown file in gsd-core/workflows/ structurally:
* it tokenizes the content into fenced code blocks, then on each shell-block
* line checks whether `gsd-tools` appears as a bare command (not preceded by
* `node `, not part of the filename `gsd-tools.cjs`, not inside a comment).
*
* Per project rule: this test does NOT use grep/regex .includes() on raw file
* content as the assertion surface. Instead, it splits into code-fenced blocks
* and tokenizes each line — only command-position tokens count as violations.
*/
'use strict';
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const WORKFLOWS_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows');
/**
* Extract shell-fenced code blocks from a markdown file.
* Returns an array of { startLine, lines } where lines are the contents
* between the ```bash / ```sh / ```shell fence markers.
*/
function extractShellBlocks(content) {
const allLines = content.split('\n');
const blocks = [];
let inBlock = false;
let blockLang = null;
let blockStart = 0;
let blockLines = [];
for (let i = 0; i < allLines.length; i++) {
const line = allLines[i];
const fenceOpen = line.match(/^```(\w+)?/);
if (!inBlock && fenceOpen) {
inBlock = true;
blockLang = (fenceOpen[1] || '').toLowerCase();
blockStart = i + 2; // 1-indexed line number of first content line
blockLines = [];
continue;
}
if (inBlock && /^```\s*$/.test(line)) {
if (['bash', 'sh', 'shell', 'zsh', ''].includes(blockLang)) {
blocks.push({ startLine: blockStart, lines: blockLines });
}
inBlock = false;
blockLang = null;
blockLines = [];
continue;
}
if (inBlock) {
blockLines.push(line);
}
}
return blocks;
}
/**
* Check a single shell-block line for a bare `gsd-tools` command-position token.
* Returns true if the line is a violation.
*/
function lineHasBareGsdTools(line) {
// Strip leading whitespace and any prompt prefix ($ , > , # )
let l = line.replace(/^\s*[$>]\s*/, '');
// Skip pure comment lines
if (/^\s*#/.test(l)) return false;
// Strip inline comment (# preceded by whitespace, not inside a string)
// Conservative: only strip if # appears after whitespace and outside quotes —
// we just look for the first ` #` outside of quoted context. For our needs,
// splitting on `^[^"']*?(\s#)` is good enough.
const hashIdx = l.search(/(?:^|[^"'\w])#/);
if (hashIdx > 0) l = l.slice(0, hashIdx);
// Unwrap command-substitution forms so the substituted command is in
// command position. `$(cmd …)` and `` `cmd …` `` both run the inner string
// as a fresh command, so a bare `gsd-tools` inside them is just as broken
// as one at the start of the line. Iterate until stable for nested forms.
let prev;
do {
prev = l;
l = l.replace(/\$\(([^()]*)\)/g, ' $1 ').replace(/`([^`]*)`/g, ' $1 ');
} while (l !== prev);
// Tokenize on whitespace, semicolons, pipes, and && / ||
// Then walk tokens — a violation is a token that starts with `gsd-tools`
// followed by a word boundary (so `gsd-tools.cjs` does NOT match), and the
// preceding token is NOT `node`.
const segments = l.split(/(?:\s*(?:&&|\|\||;|\|)\s*)/);
for (const seg of segments) {
const tokens = seg.trim().split(/\s+/).filter(Boolean);
if (tokens.length === 0) continue;
// Skip env var assignments at the start (FOO=bar gsd-tools …, tmp=1 gsd-tools …).
// POSIX shell variable names are [A-Za-z_][A-Za-z0-9_]*; lowercase is valid.
let cmdIdx = 0;
while (cmdIdx < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[cmdIdx])) {
cmdIdx++;
}
if (cmdIdx >= tokens.length) continue;
const cmd = tokens[cmdIdx];
// Match `gsd-tools` exactly (no extension), as command position.
if (cmd === 'gsd-tools') return true;
}
return false;
}
const AGENTS_DIR = path.join(__dirname, '..', 'agents');
describe('bug #1041: agent files must not call bare gsd-tools (all-runtime resolver)', () => {
test('no agents/gsd-*.md file contains a bare gsd-tools command', () => {
const files = fs.readdirSync(AGENTS_DIR).filter((f) => f.startsWith('gsd-') && f.endsWith('.md'));
assert.ok(files.length > 0, 'expected agent files to exist');
const violations = [];
for (const f of files) {
const full = path.join(AGENTS_DIR, f);
const content = fs.readFileSync(full, 'utf-8');
const blocks = extractShellBlocks(content);
for (const blk of blocks) {
for (let i = 0; i < blk.lines.length; i++) {
if (lineHasBareGsdTools(blk.lines[i])) {
violations.push(`${f}:${blk.startLine + i}: ${blk.lines[i].trim()}`);
}
}
}
}
assert.deepStrictEqual(
violations,
[],
'Bare `gsd-tools` invocations found in agent shell blocks. ' +
'Inject the runtime-launcher preamble (_runtime-launcher.snippet.sh) and use `gsd_run` instead.\n' +
violations.join('\n'),
);
});
});
describe('bug-2851: workflow files must not call bare `gsd-tools` (#2245 sweep regression)', () => {
test('no gsd-core/workflows/*.md file contains a bare gsd-tools command', () => {
const files = fs.readdirSync(WORKFLOWS_DIR).filter((f) => f.endsWith('.md'));
assert.ok(files.length > 0, 'expected workflow files to exist');
const violations = [];
for (const f of files) {
const full = path.join(WORKFLOWS_DIR, f);
const content = fs.readFileSync(full, 'utf-8');
const blocks = extractShellBlocks(content);
for (const blk of blocks) {
for (let i = 0; i < blk.lines.length; i++) {
if (lineHasBareGsdTools(blk.lines[i])) {
violations.push(`${f}:${blk.startLine + i}: ${blk.lines[i].trim()}`);
}
}
}
}
assert.deepStrictEqual(
violations,
[],
'Bare `gsd-tools` invocations found in workflow shell blocks. ' +
'Use a resolver snippet or `node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" <subcommand>` instead.\n' +
violations.join('\n'),
);
});
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 foundPlanPostDispatch = false;
for (const blk of blocks) {
for (const line of blk.lines) {
if (/gsd_run\s+loop\s+render-hooks\s+plan:post\s+--raw/.test(line) && !/^\s*#/.test(line)) {
foundPlanPostDispatch = true;
}
}
}
assert.ok(
foundPlanPostDispatch,
'expected plan-phase.md §13e to dispatch gsd_run loop render-hooks plan:post --raw (gap-analysis moved to capability gate plan:post in ADR-857 migration)',
);
const registry = require('../gsd-core/bin/lib/capability-registry.cjs');
const planPostPoint = (registry.byLoopPoint || {})['plan:post'] || {};
const gates = planPostPoint.gates || [];
const gapAnalysisGate = gates.find((g) => g.capId === 'gap-analysis');
assert.ok(
gapAnalysisGate,
'gap-analysis capability must be registered as a plan:post gate in capability-registry.cjs',
);
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/fix-1628-config-set-validation.test.cjs — consolidation epic #1969 (B3 #1972)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:fix-1628-config-set-validation (consolidation epic #1969 B3 #1972)", () => {
'use strict';
/**
* Regression test suite for bug #1628: config-set validation gaps.
*
* This file consolidates all #1628 config-set validation regression tests:
* 1. Security-key enum guards (workflow.security_block_on, workflow.security_asvs_level)
* 2. JSON-array coercion bypass: every affected string-enum key
* 3. Generic capability-registry validation (enum/boolean/number/string keys)
*
* Covers:
* - workflow.security_block_on must be one of: critical | high | medium | low | none
* - workflow.security_asvs_level must be an integer in {1, 2, 3}
* - JSON-array (["<member>"]) and JSON-object ({"x":1}) values must be REJECTED for
* all string-enum keys (typeof check before enum guard)
* - capability-registry-owned keys: ENUM, BOOLEAN, NUMBER, STRING
*
* Boundary coverage per RULESET.TESTS.boundary-coverage:
* security_asvs_level: 0 (limit-1), 1 (limit), 2, 3 (limit), 4 (limit+1)
* security_block_on: each valid enum member + bogus values
*
* Registry canary: verifies capability registry's .values for workflow.security_block_on
* matches the canonical enum (guards against silent gutting per DEFECT.GENERATIVE-FIX).
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs');
// ─── Registry canary ──────────────────────────────────────────────────────────
// Verify the capability registry's declared .values for workflow.security_block_on
// matches the canonical enum. config.cts sources its allowed set DIRECTLY from the
// registry, so this canary guards against the registry being silently gutted — which
// would cause every config-set call to fail (per DEFECT.GENERATIVE-FIX).
describe('fix-1628: registry canary — capability registry declares the canonical security_block_on enum', () => {
test('registry workflow.security_block_on.values declares the expected canonical enum', () => {
// Load the capability registry as a module (behavioral call, not source grep).
// The registry IS the source of truth: config.cts reads from it at runtime.
// This assertion guards against the registry entry being gutted or values removed.
const { configSchema } = require('../gsd-core/bin/lib/capability-registry.cjs');
const entry = configSchema['workflow.security_block_on'];
assert.ok(entry, 'capability registry must have an entry for workflow.security_block_on');
assert.ok(Array.isArray(entry.values), 'registry entry must have a .values array');
const EXPECTED = ['critical', 'high', 'medium', 'low', 'none'];
assert.deepEqual(
[...entry.values].sort(),
[...EXPECTED].sort(),
`Registry workflow.security_block_on.values must be ${JSON.stringify(EXPECTED)} — update ` +
`the capability registry if the canonical enum changes`
);
});
});
// ─── workflow.security_block_on ───────────────────────────────────────────────
describe('fix-1628: workflow.security_block_on enum validation', () => {
const VALID_VALUES = ['critical', 'high', 'medium', 'low', 'none'];
const INVALID_VALUES = ['bogus', 'High', 'CRITICAL', '', 'all', 'urgent'];
for (const v of VALID_VALUES) {
test(`config-set workflow.security_block_on=${v} is ACCEPTED`, (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const result = runGsdTools(
['config-set', 'workflow.security_block_on', v],
tmpDir
);
assert.ok(
result.success,
[
`config-set workflow.security_block_on=${v} must succeed,`,
'stdout: ' + result.output,
'stderr: ' + result.error,
].join('\n')
);
});
}
for (const v of INVALID_VALUES) {
test(`config-set workflow.security_block_on=${JSON.stringify(v)} is REJECTED`, (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const result = runGsdTools(
['config-set', 'workflow.security_block_on', v],
tmpDir
);
assert.ok(
!result.success,
`config-set workflow.security_block_on=${JSON.stringify(v)} must fail, but it succeeded`
);
const combined = (result.output || '') + (result.error || '');
// Error message must mention the valid values
assert.ok(
combined.includes('critical') && combined.includes('none'),
`Error message must mention valid values (got: ${combined})`
);
});
}
});
// ─── workflow.security_block_on — JSON-parse coercion bypass ─────────────────
// Regression for the String(parsedValue) coercion bug: an array like ["high"]
// coerces to "high" via String(), bypassing the enum check and writing an array
// to a string-enum key. The fix requires typeof parsedValue === 'string'.
describe('fix-1628: workflow.security_block_on rejects JSON-parsed non-string inputs', () => {
const JSON_BYPASS_CASES = [
{ val: '["high"]', label: 'JSON array with valid member' },
{ val: '["bogus"]', label: 'JSON array with invalid member' },
{ val: '{"high":1}', label: 'JSON object' },
];
for (const { val, label } of JSON_BYPASS_CASES) {
test(`config-set workflow.security_block_on=${label} is REJECTED`, (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const result = runGsdTools(
['config-set', 'workflow.security_block_on', val],
tmpDir
);
assert.ok(
!result.success,
`config-set workflow.security_block_on=${label} must fail, but it succeeded`
);
});
}
});
// ─── workflow.security_asvs_level ─────────────────────────────────────────────
describe('fix-1628: workflow.security_asvs_level range validation', () => {
// Boundary: 0 (below limit), 1 (min valid), 2 (mid), 3 (max valid), 4 (above limit)
const ACCEPTED_INTEGERS = [1, 2, 3];
const REJECTED_VALUES = [
{ val: '0', label: '0 (below lower bound)' },
{ val: '4', label: '4 (above upper bound)' },
{ val: '2.5', label: '2.5 (non-integer float)' },
{ val: 'abc', label: '"abc" (non-numeric string)' },
{ val: '-1', label: '-1 (negative)' },
];
for (const n of ACCEPTED_INTEGERS) {
test(`config-set workflow.security_asvs_level=${n} is ACCEPTED`, (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const result = runGsdTools(
['config-set', 'workflow.security_asvs_level', String(n)],
tmpDir
);
assert.ok(
result.success,
[
`config-set workflow.security_asvs_level=${n} must succeed,`,
'stdout: ' + result.output,
'stderr: ' + result.error,
].join('\n')
);
});
}
for (const { val, label } of REJECTED_VALUES) {
test(`config-set workflow.security_asvs_level=${label} is REJECTED`, (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const result = runGsdTools(
['config-set', 'workflow.security_asvs_level', val],
tmpDir
);
assert.ok(
!result.success,
`config-set workflow.security_asvs_level=${label} must fail, but it succeeded`
);
const combined = (result.output || '') + (result.error || '');
assert.ok(
combined.includes('security_asvs_level'),
`Error message must reference the key name (got: ${combined})`
);
});
}
});
// ─── JSON-array coercion bypass — parameterised matrix ───────────────────────
// The root cause: cmdConfigSet JSON-parses any value starting with '[' or '{'
// BEFORE per-key enum guards run. Guards using `.includes(String(parsedValue))`
// are then fooled because `String(["mid-flight"]) === "mid-flight"`, so the
// array bypasses the guard and gets stored in a scalar key.
//
// The fix: `assertEnumValue()` checks `typeof parsedValue === 'string'` FIRST,
// so a parsed array is rejected regardless of its string coercion.
//
// Coverage: every affected string-enum key.
// - `["<member>"]` (JSON array with valid member) → REJECTED
// - `{"x":1}` (JSON object) → REJECTED
// - `<member>` (plain string, valid) → ACCEPTED
// Each row: { key, member } where `member` is a valid enum value for `key`.
// Verified against VALID_* arrays in src/config.cts.
const ENUM_KEYS = [
{ key: 'context', member: 'research' },
{ key: 'workflow.drift_action', member: 'warn' },
{ key: 'workflow.human_verify_mode', member: 'mid-flight' },
{ key: 'workflow.context_guard_mode', member: 'off' },
{ key: 'statusline.context_position', member: 'front' },
{ key: 'statusline.state_format', member: 'compact' },
{ key: 'code_quality.fallow.scope', member: 'phase' },
{ key: 'code_quality.fallow.profile', member: 'standard' },
{ key: 'plan_review.source_grounding_authority', member: 'grep' },
{ key: 'workflow.security_block_on', member: 'high' },
];
for (const { key, member } of ENUM_KEYS) {
describe(`fix-1628 coercion bypass: ${key}`, () => {
// ── JSON array with valid member must be REJECTED ────────────────────────
test(`["${member}"] (JSON array with valid member) is REJECTED`, (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const val = `["${member}"]`;
const result = runGsdTools(['config-set', key, val], tmpDir);
assert.ok(
!result.success,
[
`config-set ${key}=${val} must be REJECTED (JSON-array coercion bypass)`,
'stdout: ' + result.output,
'stderr: ' + result.error,
].join('\n')
);
});
// ── JSON object must be REJECTED ─────────────────────────────────────────
test(`{"x":1} (JSON object) is REJECTED`, (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const val = '{"x":1}';
const result = runGsdTools(['config-set', key, val], tmpDir);
assert.ok(
!result.success,
[
`config-set ${key}=${val} must be REJECTED (JSON-object bypass)`,
'stdout: ' + result.output,
'stderr: ' + result.error,
].join('\n')
);
});
// ── Plain valid string must be ACCEPTED ──────────────────────────────────
test(`"${member}" (plain valid string) is ACCEPTED`, (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const result = runGsdTools(['config-set', key, member], tmpDir);
assert.ok(
result.success,
[
`config-set ${key}=${member} must be ACCEPTED (plain string, valid enum member)`,
'stdout: ' + result.output,
'stderr: ' + result.error,
].join('\n')
);
});
});
}
// ─── ENUM: workflow.code_review_depth ────────────────────────────────────────
describe('fix-1628 capability validation: workflow.code_review_depth (enum)', () => {
const VALID_VALUES = ['quick', 'standard', 'deep'];
for (const v of VALID_VALUES) {
test(`config-set workflow.code_review_depth=${v} is ACCEPTED`, (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const result = runGsdTools(['config-set', 'workflow.code_review_depth', v], tmpDir);
assert.ok(
result.success,
[
`config-set workflow.code_review_depth=${v} must succeed`,
'stdout: ' + result.output,
'stderr: ' + result.error,
].join('\n')
);
});
}
test('config-set workflow.code_review_depth=["standard"] (JSON array bypass) is REJECTED', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const result = runGsdTools(['config-set', 'workflow.code_review_depth', '["standard"]'], tmpDir);
assert.ok(
!result.success,
[
'config-set workflow.code_review_depth=["standard"] must be REJECTED (JSON-array coercion bypass)',
'stdout: ' + result.output,
'stderr: ' + result.error,
].join('\n')
);
});
test('config-set workflow.code_review_depth=garbage is REJECTED', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const result = runGsdTools(['config-set', 'workflow.code_review_depth', 'garbage'], tmpDir);
assert.ok(
!result.success,
[
'config-set workflow.code_review_depth=garbage must be REJECTED (out-of-enum)',
'stdout: ' + result.output,
'stderr: ' + result.error,
].join('\n')
);
});
});
// ─── ENUM: mempalace.memory_mode ─────────────────────────────────────────────
describe('fix-1628 capability validation: mempalace.memory_mode (enum)', () => {
const VALID_VALUES = ['augment', 'kg_backend', 'replace'];
for (const v of VALID_VALUES) {
test(`config-set mempalace.memory_mode=${v} is ACCEPTED`, (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const result = runGsdTools(['config-set', 'mempalace.memory_mode', v], tmpDir);
assert.ok(
result.success,
[
`config-set mempalace.memory_mode=${v} must succeed`,
'stdout: ' + result.output,
'stderr: ' + result.error,
].join('\n')
);
});
}
test('config-set mempalace.memory_mode=["augment"] (JSON array bypass) is REJECTED', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const result = runGsdTools(['config-set', 'mempalace.memory_mode', '["augment"]'], tmpDir);
assert.ok(
!result.success,
[
'config-set mempalace.memory_mode=["augment"] must be REJECTED (JSON-array coercion bypass)',
'stdout: ' + result.output,
'stderr: ' + result.error,
].join('\n')
);
});
test('config-set mempalace.memory_mode=garbage is REJECTED', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const result = runGsdTools(['config-set', 'mempalace.memory_mode', 'garbage'], tmpDir);
assert.ok(
!result.success,
[
'config-set mempalace.memory_mode=garbage must be REJECTED (out-of-enum)',
'stdout: ' + result.output,
'stderr: ' + result.error,
].join('\n')
);
});
});
// ─── ROUTING CONTRACT: mempalace.memory_mode wired behavior (#2007) ───────────
//
// memory_mode is an instruction-only contract: `augment`/`kg_backend`/`replace`
// change how the mempalace recall/capture/curator markdown tells the agent to
// treat the palace vs. GSD native memory. There is no code branch to unit-test,
// so the governed surface IS the instruction text across five parallel surfaces
// plus the capability schema. This block asserts (a) every mode is named with
// distinct, non-forward-declared routing, and (b) the parallel surfaces stay in
// parity so a future edit can't silently revert one to the inert state
// (CLAUDE.md Generative Fix Divergence). Reads .md/.json only — the no-source-grep
// rule targets .cjs/.js/.ts source reads, so no allow-test-rule exemption applies.
describe('#2007 mempalace.memory_mode routing contract (instruction surfaces)', () => {
const MODES = ['augment', 'kg_backend', 'replace'];
// The five parallel instruction surfaces that must express per-mode routing.
const SURFACES = [
'capabilities/mempalace/fragments/recall-discuss.md',
'capabilities/mempalace/fragments/capture-problems.md',
'commands/gsd/mempalace-recall.md',
'commands/gsd/mempalace-capture.md',
'agents/gsd-mempalace-curator.md',
];
// Retired forward-declaration hedges — the presence of any one means a surface
// reverted to the pre-#2007 inert state where the modes did nothing.
const RETIRED_HEDGES = [
'forward-declared',
'behave as `augment`',
'behaves as `augment`',
'not yet functional',
'routing seam not yet',
'Only `augment` is currently wired',
'Only `augment` is wired',
];
for (const rel of SURFACES) {
const body = fs.readFileSync(path.join(ROOT, rel), 'utf8');
test(`${rel} names all three memory modes`, () => {
for (const m of MODES) {
assert.ok(
body.includes('`' + m + '`'),
`${rel} must reference mode \`${m}\``
);
}
});
test(`${rel} carries no retired forward-declaration hedge`, () => {
for (const hedge of RETIRED_HEDGES) {
assert.ok(
!body.includes(hedge),
`${rel} still contains retired hedge "${hedge}" — memory_mode must describe wired per-mode routing, not a forward-declaration`
);
}
});
test(`${rel} expresses both halves of the routing contract (palace-primary + native-fallback)`, () => {
// The wired contract is a duality: kg_backend/replace make the palace
// primary/authoritative, AND every mode keeps native memory as a
// fallback/mirror so the capability stays default-resilient (an unreachable
// palace never loses memory). A surface that expresses only one half is a
// defect — this guards the exact contradiction the #2007 review surfaced.
assert.match(
body,
/primary|authoritative|source of truth/i,
`${rel} must describe kg_backend/replace treating the palace as primary/authoritative`
);
assert.match(
body,
/fallback|mirror|additive|supplement/i,
`${rel} must describe the native-memory fallback/mirror relationship (default-resilience)`
);
assert.ok(
body.includes('.planning/graphs'),
`${rel} must anchor the native memory surface (.planning/graphs/) that the palace augments/falls back to`
);
});
}
test('capability.json memory_mode schema declares the three modes and drops the not-implemented claim', () => {
const cap = JSON.parse(
fs.readFileSync(
path.join(ROOT, 'capabilities/mempalace/capability.json'),
'utf8'
)
);
const mode = cap.config['mempalace.memory_mode'];
assert.deepEqual(
mode.values,
MODES,
'enum values must be exactly [augment, kg_backend, replace]'
);
assert.equal(
mode.default,
'augment',
'default stays augment (default-resilient)'
);
for (const hedge of [
'forward-declared',
'not yet built',
'behave as',
'behaves the same as',
]) {
assert.ok(
!mode.description.includes(hedge),
`schema description must not claim the modes are unimplemented (found "${hedge}")`
);
}
});
});
// ─── BOOLEAN: workflow.tdd_mode ──────────────────────────────────────────────
describe('fix-1628 capability validation: workflow.tdd_mode (boolean)', () => {
test('config-set workflow.tdd_mode=true is ACCEPTED', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const result = runGsdTools(['config-set', 'workflow.tdd_mode', 'true'], tmpDir);
assert.ok(
result.success,
[
'config-set workflow.tdd_mode=true must succeed',
'stdout: ' + result.output,
'stderr: ' + result.error,
].join('\n')
);
});
test('config-set workflow.tdd_mode=false is ACCEPTED', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const result = runGsdTools(['config-set', 'workflow.tdd_mode', 'false'], tmpDir);
assert.ok(
result.success,
[
'config-set workflow.tdd_mode=false must succeed',
'stdout: ' + result.output,
'stderr: ' + result.error,
].join('\n')
);
});
test('config-set workflow.tdd_mode=["true"] (JSON array bypass) is REJECTED', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const result = runGsdTools(['config-set', 'workflow.tdd_mode', '["true"]'], tmpDir);
assert.ok(
!result.success,
[
'config-set workflow.tdd_mode=["true"] must be REJECTED (JSON-array coercion bypass)',
'stdout: ' + result.output,
'stderr: ' + result.error,
].join('\n')
);
});
test('config-set workflow.tdd_mode={"x":1} (JSON object) is REJECTED', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const result = runGsdTools(['config-set', 'workflow.tdd_mode', '{"x":1}'], tmpDir);
assert.ok(
!result.success,
[
'config-set workflow.tdd_mode={"x":1} must be REJECTED (JSON-object bypass)',
'stdout: ' + result.output,
'stderr: ' + result.error,
].join('\n')
);
});
test('config-set workflow.tdd_mode=maybe (non-boolean string) is REJECTED', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const result = runGsdTools(['config-set', 'workflow.tdd_mode', 'maybe'], tmpDir);
assert.ok(
!result.success,
[
'config-set workflow.tdd_mode=maybe must be REJECTED (non-boolean string)',
'stdout: ' + result.output,
'stderr: ' + result.error,
].join('\n')
);
});
test('config-set workflow.tdd_mode=1 (numeric 1 coerces to number, not boolean) is REJECTED', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const result = runGsdTools(['config-set', 'workflow.tdd_mode', '1'], tmpDir);
assert.ok(
!result.success,
[
'config-set workflow.tdd_mode=1 must be REJECTED (number, not boolean)',
'stdout: ' + result.output,
'stderr: ' + result.error,
].join('\n')
);
});
});
// ─── BOOLEAN: graphify.enabled ────────────────────────────────────────────────
describe('fix-1628 capability validation: graphify.enabled (boolean)', () => {
test('config-set graphify.enabled=true is ACCEPTED', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const result = runGsdTools(['config-set', 'graphify.enabled', 'true'], tmpDir);
assert.ok(
result.success,
[
'config-set graphify.enabled=true must succeed',
'stdout: ' + result.output,
'stderr: ' + result.error,
].join('\n')
);
});
test('config-set graphify.enabled=false is ACCEPTED', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const result = runGsdTools(['config-set', 'graphify.enabled', 'false'], tmpDir);
assert.ok(
result.success,
[
'config-set graphify.enabled=false must succeed',
'stdout: ' + result.output,
'stderr: ' + result.error,
].join('\n')
);
});
test('config-set graphify.enabled=["true"] (JSON array bypass) is REJECTED', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const result = runGsdTools(['config-set', 'graphify.enabled', '["true"]'], tmpDir);
assert.ok(
!result.success,
[
'config-set graphify.enabled=["true"] must be REJECTED (JSON-array coercion bypass)',
'stdout: ' + result.output,
'stderr: ' + result.error,
].join('\n')
);
});
test('config-set graphify.enabled={"x":1} (JSON object) is REJECTED', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const result = runGsdTools(['config-set', 'graphify.enabled', '{"x":1}'], tmpDir);
assert.ok(
!result.success,
[
'config-set graphify.enabled={"x":1} must be REJECTED (JSON-object bypass)',
'stdout: ' + result.output,
'stderr: ' + result.error,
].join('\n')
);
});
test('config-set graphify.enabled=maybe (non-boolean string) is REJECTED', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const result = runGsdTools(['config-set', 'graphify.enabled', 'maybe'], tmpDir);
assert.ok(
!result.success,
[
'config-set graphify.enabled=maybe must be REJECTED (non-boolean string)',
'stdout: ' + result.output,
'stderr: ' + result.error,
].join('\n')
);
});
test('config-set graphify.enabled=1 (numeric 1, not boolean) is REJECTED', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const result = runGsdTools(['config-set', 'graphify.enabled', '1'], tmpDir);
assert.ok(
!result.success,
[
'config-set graphify.enabled=1 must be REJECTED (number, not boolean)',
'stdout: ' + result.output,
'stderr: ' + result.error,
].join('\n')
);
});
});
// ─── NUMBER: workflow.drift_threshold ────────────────────────────────────────
describe('fix-1628 capability validation: workflow.drift_threshold (number)', () => {
test('config-set workflow.drift_threshold=5 is ACCEPTED', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const result = runGsdTools(['config-set', 'workflow.drift_threshold', '5'], tmpDir);
assert.ok(
result.success,
[
'config-set workflow.drift_threshold=5 must succeed',
'stdout: ' + result.output,
'stderr: ' + result.error,
].join('\n')
);
});
test('config-set workflow.drift_threshold=["3"] (JSON array bypass) is REJECTED', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const result = runGsdTools(['config-set', 'workflow.drift_threshold', '["3"]'], tmpDir);
assert.ok(
!result.success,
[
'config-set workflow.drift_threshold=["3"] must be REJECTED (JSON-array coercion bypass)',
'stdout: ' + result.output,
'stderr: ' + result.error,
].join('\n')
);
});
});
// ─── STRING: mempalace.wing ───────────────────────────────────────────────────
describe('fix-1628 capability validation: mempalace.wing (string)', () => {
test('config-set mempalace.wing=myWing is ACCEPTED', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const result = runGsdTools(['config-set', 'mempalace.wing', 'myWing'], tmpDir);
assert.ok(
result.success,
[
'config-set mempalace.wing=myWing must succeed',
'stdout: ' + result.output,
'stderr: ' + result.error,
].join('\n')
);
});
test('config-set mempalace.wing=["x"] (JSON array bypass) is REJECTED', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const result = runGsdTools(['config-set', 'mempalace.wing', '["x"]'], tmpDir);
assert.ok(
!result.success,
[
'config-set mempalace.wing=["x"] must be REJECTED (JSON-array coercion bypass)',
'stdout: ' + result.output,
'stderr: ' + result.error,
].join('\n')
);
});
test('config-set mempalace.wing={"a":1} (JSON object) is REJECTED', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const result = runGsdTools(['config-set', 'mempalace.wing', '{"a":1}'], tmpDir);
assert.ok(
!result.success,
[
'config-set mempalace.wing={"a":1} must be REJECTED (JSON-object bypass)',
'stdout: ' + result.output,
'stderr: ' + result.error,
].join('\n')
);
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/enh-1055-config-intent-descriptor-drive.test.cjs — consolidation epic #1969 (B3 #1972)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:enh-1055-config-intent-descriptor-drive (consolidation epic #1969 B3 #1972)", () => {
'use strict';
/**
* ADR-857 phase 5g drive 2: resolveRuntimeConfigIntent is now driven by the
* runtime capability descriptor (capability-registry.cjs) rather than a
* hand-kept REGISTRY const.
*
* This golden-master test pins the observable contract: the return shape and
* values must be identical to the pre-change behavior for all 15 runtimes.
* Purely behavioral — no source-grep.
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const ROOT = path.join(__dirname, '..');
const {
resolveRuntimeConfigIntent,
ALLOWED_CONFIG_RUNTIMES,
} = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'runtime-config-adapter-registry.cjs'));
const enh1055Registry = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'capability-registry.cjs'));
// ---------------------------------------------------------------------------
// Expected table — DERIVED from the capability registry descriptors. The
// contract being pinned is the PROJECTION (descriptor fields → intent), not a
// frozen per-runtime value snapshot. Adding a runtime descriptor extends
// coverage with zero edits here.
// ---------------------------------------------------------------------------
const EXPECTED = Object.keys(enh1055Registry.runtimes).map((id) => {
const r = enh1055Registry.runtimes[id].runtime;
const pw = r.permissionWriter;
return {
runtime: id,
installSurface: r.installSurface,
writesSharedSettings: r.writesSharedSettings,
finishPermissionWriter: pw == null ? null : pw,
};
});
// ---------------------------------------------------------------------------
// Test 1: Projection contract — every registry runtime resolves to its
// descriptor-derived intent (count-agnostic).
// ---------------------------------------------------------------------------
describe('enh-1055 descriptor-drive: resolveRuntimeConfigIntent projection contract', () => {
test('every registry runtime resolves to its descriptor-derived intent', () => {
assert.ok(EXPECTED.length > 0, 'registry must contain at least one runtime');
for (const row of EXPECTED) {
assert.deepStrictEqual(resolveRuntimeConfigIntent(row.runtime), {
runtime: row.runtime,
installSurface: row.installSurface,
writesSharedSettings: row.writesSharedSettings,
finishPermissionWriter: row.finishPermissionWriter,
}, `resolveRuntimeConfigIntent('${row.runtime}') must match the descriptor projection`);
}
});
});
// ---------------------------------------------------------------------------
// Test 3: Unknown runtime throws TypeError
// ---------------------------------------------------------------------------
describe('enh-1055 descriptor-drive: unknown runtime throws TypeError', () => {
test('throws TypeError for "bogus-runtime"', () => {
assert.throws(() => resolveRuntimeConfigIntent('bogus-runtime'), TypeError);
});
test('throws TypeError for empty string', () => {
assert.throws(() => resolveRuntimeConfigIntent(''), TypeError);
});
test('throws TypeError for undefined', () => {
assert.throws(() => resolveRuntimeConfigIntent(undefined), TypeError);
});
test('throws TypeError for "__proto__"', () => {
assert.throws(() => resolveRuntimeConfigIntent('__proto__'), TypeError);
});
});
// ---------------------------------------------------------------------------
// Test 4: ALLOWED_CONFIG_RUNTIMES equals the registry runtimes that declare an
// installSurface (count-agnostic; derived from the same source as production).
// ---------------------------------------------------------------------------
describe('enh-1055 descriptor-drive: ALLOWED_CONFIG_RUNTIMES completeness', () => {
test('equals the registry runtimes that declare an installSurface', () => {
const descriptorAllowed = new Set(
Object.entries(enh1055Registry.runtimes)
// installSurface:'none' (#2103 vscode — extension-distributed, no config
// directory) is NOT a config-adapter runtime: production ALLOWED_CONFIG_RUNTIMES
// excludes it so `allRuntimes === ALLOWED_CONFIG_RUNTIMES` (issue-57) stays true.
.filter(([, cap]) => cap && cap.runtime && typeof cap.runtime.installSurface === 'string' && cap.runtime.installSurface !== 'none')
.map(([id]) => id),
);
assert.deepStrictEqual(new Set(ALLOWED_CONFIG_RUNTIMES), descriptorAllowed);
});
});
// ---------------------------------------------------------------------------
// Test 5: Descriptor drive — the function reads from the descriptor, not
// a hardcoded local constant. This is proven indirectly: the golden master
// passes, meaning capability-registry.cjs (the live descriptor) matches the
// expected table. If the adapter had its own REGISTRY, a descriptor change
// would diverge silently; with drive, it cannot.
// ---------------------------------------------------------------------------
describe('enh-1055 descriptor-drive: finishPermissionWriter passthrough', () => {
test('opencode → "opencode" (descriptor permissionWriter)', () => {
assert.strictEqual(resolveRuntimeConfigIntent('opencode').finishPermissionWriter, 'opencode');
});
test('kilo → "kilo" (descriptor permissionWriter)', () => {
assert.strictEqual(resolveRuntimeConfigIntent('kilo').finishPermissionWriter, 'kilo');
});
test('all other runtimes have finishPermissionWriter === null', () => {
const nullExpected = EXPECTED
.filter(r => r.finishPermissionWriter === null)
.map(r => r.runtime);
for (const runtime of nullExpected) {
assert.strictEqual(
resolveRuntimeConfigIntent(runtime).finishPermissionWriter,
null,
`${runtime} should have finishPermissionWriter null`,
);
}
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/enh-1592-plan-drift-precheck.test.cjs — consolidation epic #1969 (B6 #1975)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:enh-1592-plan-drift-precheck (consolidation epic #1969 B6 #1975)", () => {
'use strict';
// allow-test-rule: source-text-is-the-product see #1592
// The plan-phase.md host-dispatch assertions below read the workflow .md file — its text IS the
// deployed contract the runtime loads (CONTRIBUTING.md exemption category). The registry assertions
// are behavioral: they build the registry from the REAL capabilities/drift declaration via the
// generator, so they fail if the plan:pre gate is ever removed or mutated.
/**
* Enhancement (#1592): plan-time codebase-map freshness pre-check.
*
* The `drift` capability gains a non-blocking `plan:pre` codebase-drift gate so a stale codebase map is
* flagged BEFORE planning, instead of being discovered mid-execution by the existing
* `execute:wave:post` codebase-drift gate. Warn-only at `plan:pre` (no mapper-agent spawn): the
* capability's `drift_action: auto-remap` stays at `execute:wave:post`, so plan time never pays
* speculative mapper-agent cost.
*
* Per maintainer review on #1592 (mod 1a), the plan:pre gate is gated on a DEDICATED
* `workflow.plan_drift_precheck` toggle (default true) rather than reusing `workflow.schema_drift_gate`,
* so autonomous/CI runs can silence the plan-time advisory without disabling the execute-time gates.
* The gate declaration conforms to ADR-857 (`plan:pre` is an enumerated, additive-only loop point).
*
* Issue: #1592 (open-gsd/gsd-core).
*/
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 { loadAndValidate, buildRegistry } = require('../scripts/gen-capability-registry.cjs');
const { cleanup } = require('./helpers.cjs');
const REPO_ROOT = path.join(__dirname, '..');
const DRIFT_CAP = JSON.parse(
fs.readFileSync(path.join(REPO_ROOT, 'capabilities', 'drift', 'capability.json'), 'utf8'),
);
const PLAN_PHASE = fs.readFileSync(
path.join(REPO_ROOT, 'gsd-core', 'workflows', 'plan-phase.md'),
'utf8',
);
// Track every temp dir created so the suite can remove them on teardown — leaked
// mkdtemp dirs have been a flake source here before (per #1592 review).
const tempCapDirs = [];
function makeTempCapDir(capabilities) {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'enh-1592-'));
tempCapDirs.push(tmpDir);
for (const [id, cap] of Object.entries(capabilities)) {
const subDir = path.join(tmpDir, id);
fs.mkdirSync(subDir, { recursive: true });
fs.writeFileSync(path.join(subDir, 'capability.json'), JSON.stringify(cap), 'utf8');
}
return tmpDir;
}
after(() => {
for (const dir of tempCapDirs) {
cleanup(dir);
}
});
function planPreDriftGate() {
const capDir = makeTempCapDir({ drift: DRIFT_CAP });
const { capMap, errors } = loadAndValidate(new Set(), capDir);
assert.deepEqual(errors, [], 'drift capability should validate cleanly: ' + JSON.stringify(errors));
const registry = buildRegistry(capMap);
const planPreGates = registry.byLoopPoint['plan:pre'].gates;
assert.ok(Array.isArray(planPreGates), 'plan:pre.gates should be an array');
return planPreGates.find(
(g) => g.capId === 'drift' && g.check && g.check.query === 'verify.codebase-drift',
);
}
describe('#1592 — drift plan:pre codebase-drift gate (registry, behavioral)', () => {
test('the real drift capability registers a non-blocking plan:pre codebase-drift gate', () => {
const driftGate = planPreDriftGate();
assert.ok(driftGate, 'plan:pre.gates must contain the drift codebase-drift gate');
assert.strictEqual(driftGate.blocking, false, 'plan-time drift gate must be NON-blocking');
assert.strictEqual(driftGate.onError, 'skip', 'must fail-soft (skip) — never halt planning');
});
test('the plan:pre gate is gated on the dedicated plan_drift_precheck toggle (mod 1a)', () => {
const driftGate = planPreDriftGate();
assert.strictEqual(
driftGate.when,
'workflow.plan_drift_precheck',
'plan:pre drift gate must use the dedicated toggle so CI/autonomous runs can silence it ' +
'without disabling the execute-time gates',
);
});
test('the execute:wave:post codebase-drift gate is preserved and keeps its OWN toggle (no regression)', () => {
const capDir = makeTempCapDir({ drift: DRIFT_CAP });
const { capMap } = loadAndValidate(new Set(), capDir);
const registry = buildRegistry(capMap);
const execGates = registry.byLoopPoint['execute:wave:post'].gates;
const stillThere = execGates.find(
(g) => g.capId === 'drift' && g.check && g.check.query === 'verify.codebase-drift',
);
assert.ok(stillThere, 'execute:wave:post codebase-drift gate must remain after adding the plan:pre gate');
assert.strictEqual(stillThere.blocking, false, 'execute codebase-drift gate stays non-blocking');
assert.strictEqual(
stillThere.when,
'workflow.schema_drift_gate',
'the execute-time gate keeps schema_drift_gate — the plan-time toggle is separable from it',
);
});
test('plan_drift_precheck is a separate toggle from schema_drift_gate (silencing is independent)', () => {
const planWhen = planPreDriftGate().when;
assert.notStrictEqual(
planWhen,
'workflow.schema_drift_gate',
'silencing the plan-time advisory must not require disabling the execute-time gates',
);
});
test('plan_drift_precheck is declared as a boolean defaulting to true', () => {
const cfg = DRIFT_CAP.config['workflow.plan_drift_precheck'];
assert.ok(cfg, 'workflow.plan_drift_precheck must be declared in the drift capability config');
assert.strictEqual(cfg.type, 'boolean', 'plan_drift_precheck must be a boolean');
assert.strictEqual(cfg.default, true, 'plan_drift_precheck must default to true (on by default)');
});
test('exactly one new config key is introduced (the dedicated plan_drift_precheck toggle)', () => {
const keys = Object.keys(DRIFT_CAP.config).sort();
assert.deepStrictEqual(
keys,
[
'workflow.context_drift_action',
'workflow.context_drift_precheck',
'workflow.drift_action',
'workflow.drift_threshold',
'workflow.plan_drift_precheck',
'workflow.schema_drift_gate',
],
// #3348 (separately) adds its own plan:pre context-drift gate's two dedicated
// toggles (workflow.context_drift_precheck / workflow.context_drift_action) —
// #1592's own contribution here remains exactly the one plan_drift_precheck key.
'the plan:pre gate adds exactly the dedicated plan_drift_precheck toggle — no other new keys from #1592 ' +
'(workflow.context_drift_precheck / workflow.context_drift_action are #3348\'s separate context-drift gate keys)',
);
});
});
describe('#1592 — plan-phase host dispatches the drift plan:pre gate before planning', () => {
const SECTION = PLAN_PHASE.slice(
PLAN_PHASE.indexOf('5.65. Codebase Map Freshness Pre-Check'),
PLAN_PHASE.indexOf('## 6. Check Existing Plans'),
);
test('§5.65 invokes the verify codebase-drift check', () => {
assert.match(PLAN_PHASE, /5\.65\. Codebase Map Freshness Pre-Check/, 'plan-phase must declare §5.65');
assert.match(PLAN_PHASE, /gsd_run verify codebase-drift/, '§5.65 must invoke `verify codebase-drift`');
});
test('the drift pre-check runs BEFORE the planner spawn (load-bearing ordering)', () => {
const preCheckIdx = PLAN_PHASE.indexOf('5.65. Codebase Map Freshness Pre-Check');
const plannerIdx = PLAN_PHASE.indexOf('## 8. Spawn gsd-planner Agent');
assert.ok(preCheckIdx > 0, '§5.65 must exist');
assert.ok(plannerIdx > 0, '§8 planner spawn must exist');
assert.ok(
preCheckIdx < plannerIdx,
'the drift map-freshness pre-check must run before the planner is spawned — the whole point of #1592',
);
});
test('§5.65 is documented as non-blocking and warn-only (no spawn)', () => {
assert.match(SECTION, /non-blocking/i, '§5.65 must state the gate is non-blocking');
assert.match(SECTION, /never blocks, never spawns/i, '§5.65 must state it never spawns the mapper at plan time');
});
test('§5.65 gates on the dedicated plan_drift_precheck toggle (mod 1a)', () => {
assert.match(
SECTION,
/workflow\.plan_drift_precheck/,
'§5.65 must dispatch on the dedicated plan_drift_precheck toggle, not schema_drift_gate',
);
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/fix-1464-docs-manifest-validation.test.cjs — consolidation epic #1969 (B6 #1975)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:fix-1464-docs-manifest-validation (consolidation epic #1969 B6 #1975)", () => {
// allow-test-rule: source-text-is-the-product see #1464
// Tutorial docs are the product surface users follow. Reading JSON code blocks
// from them and validating through validateCapability is behavioral, not
// source-grep — it proves the manifests work, not just that they "mention" a term.
'use strict';
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { validateCapability } = require('../scripts/gen-capability-registry.cjs');
const ROOT = path.join(__dirname, '..');
// ─── Extractor ───────────────────────────────────────────────────────────────
// Required top-level fields that distinguish a complete capability manifest
// from a partial output snippet (list-entry, install-result, etc.).
// Partial output snippets have id+role but lack steps/contributions/gates/config.
const MANIFEST_REQUIRED_KEYS = new Set([
'id', 'role', 'title', 'description', 'tier',
'requires', 'runtimeCompat', 'skills', 'agents',
'config', 'steps', 'contributions', 'gates',
]);
/**
* Extract JSON code blocks from markdown that are complete capability manifests.
* A complete manifest has ALL keys in MANIFEST_REQUIRED_KEYS.
* Partial output snippets (list-entries, install-results) have only id+role and are skipped.
*/
function extractManifests(mdContent) {
const manifests = [];
const lines = mdContent.split(/\r?\n/);
for (const block of scanFencedBlocks(lines)) {
if (block.closeLineIdx === -1) continue;
if ((block.infoString || '').trim().toLowerCase() !== 'json') continue;
const body = lines.slice(block.openLineIdx + 1, block.closeLineIdx).join('\n');
let parsed;
try {
parsed = JSON.parse(body);
} catch {
continue;
}
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const keys = new Set(Object.keys(parsed));
if ([...MANIFEST_REQUIRED_KEYS].every((k) => keys.has(k))) {
manifests.push(parsed);
}
}
}
return manifests;
}
// ─── Suite 1: tutorial manifests validate ────────────────────────────────────
describe('docs tutorial manifests pass validateCapability (#1464 regression)', () => {
test('build-your-first-capability.md: every manifest passes', () => {
const content = fs.readFileSync(
path.join(ROOT, 'docs', 'tutorials', 'build-your-first-capability.md'),
'utf8',
);
const manifests = extractManifests(content);
assert.ok(
manifests.length > 0,
'expected at least one capability manifest in build tutorial',
);
for (const cap of manifests) {
const errors = validateCapability(cap, cap.id);
assert.deepStrictEqual(
errors,
[],
`build tutorial manifest id="${cap.id}" failed validateCapability:\n ${errors.join('\n ')}`,
);
}
});
test('install-your-first-capability.md: every manifest passes', () => {
const content = fs.readFileSync(
path.join(ROOT, 'docs', 'tutorials', 'install-your-first-capability.md'),
'utf8',
);
const manifests = extractManifests(content);
assert.ok(
manifests.length > 0,
'expected at least one capability manifest in install tutorial',
);
for (const cap of manifests) {
const errors = validateCapability(cap, cap.id);
assert.deepStrictEqual(
errors,
[],
`install tutorial manifest id="${cap.id}" failed validateCapability:\n ${errors.join('\n ')}`,
);
}
});
test('capability-manifest.md reference example passes', () => {
const content = fs.readFileSync(
path.join(ROOT, 'docs', 'reference', 'capability-manifest.md'),
'utf8',
);
const manifests = extractManifests(content);
assert.ok(
manifests.length > 0,
'expected at least one capability manifest in reference doc',
);
for (const cap of manifests) {
const errors = validateCapability(cap, cap.id);
assert.deepStrictEqual(
errors,
[],
`reference manifest id="${cap.id}" failed validateCapability:\n ${errors.join('\n ')}`,
);
}
});
});
// ─── Suite 2: adversarial — #1464 failure modes caught ───────────────────────
//
// These are the EXACT failure shapes from issue #1464.
// They must fail validateCapability — proving this test would have caught the bug.
describe('validateCapability catches original #1464 bug shapes', () => {
// #1464 high-1: step missing ref → validateStep rejects it
test('step without ref fails (the original broken tutorial step)', () => {
const cap = {
id: 'hello-note',
role: 'feature',
version: '0.1.0',
title: 'Hello Note',
description: 'Test fixture for #1464 regression.',
tier: 'standard',
requires: [],
engines: { gsd: '>=1.6.0' },
runtimeCompat: { supported: ['*'], unsupported: [] },
skills: [],
agents: [],
config: {},
steps: [
{
// Missing ref — this was the #1464 high-1 bug in the original tutorial
point: 'plan:pre',
produces: ['HELLO.md'],
consumes: [],
onError: 'skip',
},
],
contributions: [],
gates: [],
};
const errors = validateCapability(cap, 'hello-note');
assert.ok(errors.length > 0, 'expected validation errors for step without ref');
assert.ok(
errors.some((e) => /ref/.test(e)),
`expected an error mentioning "ref"; got: ${errors.join('; ')}`,
);
});
// #1464 shape: id must match folder name (folderId contract)
test('id not matching folderId fails', () => {
const cap = {
id: 'hello-note',
role: 'feature',
version: '0.1.0',
title: 'Hello Note',
description: 'Test fixture for id/folderId mismatch.',
tier: 'standard',
requires: [],
runtimeCompat: { supported: ['*'], unsupported: [] },
skills: [],
agents: [],
config: {},
steps: [],
contributions: [],
gates: [],
};
const errors = validateCapability(cap, 'wrong-folder');
assert.ok(errors.length > 0, 'expected id/folderId mismatch to fail validation');
assert.ok(
errors.some((e) => /folder/.test(e) || /equal/.test(e) || /id/.test(e)),
`expected error about id/folderId mismatch; got: ${errors.join('; ')}`,
);
});
// Corrected shape: contribution with fragment + into (the PR #1495 fix)
test('contribution with fragment.path + into passes (the PR #1495 fix shape)', () => {
const cap = {
id: 'hello-note',
role: 'feature',
version: '0.1.0',
title: 'Hello Note',
description: 'Injects a greeting note at plan:pre and produces HELLO.md.',
tier: 'standard',
requires: [],
runtimeCompat: { supported: ['*'], unsupported: [] },
skills: [],
agents: [],
config: {},
steps: [],
contributions: [
{
point: 'plan:pre',
into: 'planner',
fragment: { path: 'fragments/plan-pre.md' },
produces: ['HELLO.md'],
consumes: [],
onError: 'skip',
},
],
gates: [],
};
const errors = validateCapability(cap, 'hello-note');
assert.deepStrictEqual(
errors,
[],
`corrected contribution manifest has unexpected errors: ${errors.join('; ')}`,
);
});
});
// ─── Suite 3: extractManifests helper ────────────────────────────────────────
describe('extractManifests helper unit tests', () => {
test('returns empty array for plain text with no JSON fences', () => {
assert.deepStrictEqual(extractManifests('No code blocks here.'), []);
});
test('skips JSON blocks without all required manifest keys', () => {
// Partial list-entry block — only has id, role, version but not steps/contributions/etc.
const md = '```json\n{"id":"x","role":"feature","version":"1.0.0"}\n```';
assert.deepStrictEqual(extractManifests(md), []);
});
function makeCompleteManifest(overrides) {
return {
id: 'test-cap', role: 'feature', title: 'T', description: 'D',
tier: 'standard', requires: [], runtimeCompat: { supported: ['*'], unsupported: [] },
skills: [], agents: [], config: {}, steps: [], contributions: [], gates: [],
...overrides,
};
}
test('extracts a complete manifest (all required keys present)', () => {
const cap = makeCompleteManifest({ id: 'x' });
const md = '```json\n' + JSON.stringify(cap, null, 2) + '\n```';
const result = extractManifests(md);
assert.strictEqual(result.length, 1);
assert.strictEqual(result[0].id, 'x');
});
test('skips malformed JSON blocks silently', () => {
const complete = makeCompleteManifest({ id: 'y' });
const md = '```json\n{bad json here\n```\n```json\n' + JSON.stringify(complete) + '\n```';
const result = extractManifests(md);
assert.strictEqual(result.length, 1);
assert.strictEqual(result[0].id, 'y');
});
test('extracts multiple complete manifests from one doc', () => {
const a = makeCompleteManifest({ id: 'cap-a' });
const b = makeCompleteManifest({ id: 'cap-b' });
const md = [
'```json\n' + JSON.stringify(a) + '\n```',
'```json\n' + JSON.stringify(b) + '\n```',
].join('\n');
const result = extractManifests(md);
assert.strictEqual(result.length, 2);
});
});
});
}