feat(#972): graphify command cutover — first capability owning a command family (ADR-857 phase 4d-impl-2) (#975)

Migrate graphify into a Capability that owns its `graphify` command family,
dispatched via the registry (#961 mechanism) instead of a hardcoded case.
graphify is now an enable/disable plug-in.

- src/graphify-command-router.cts: routeGraphifyCommand (standard route*Command),
  reproduces the removed case EXACTLY (query +--budget, status, diff, build,
  hidden build snapshot, usage/unknown errors); injectable _graphify test seam.
- capabilities/graphify/capability.json: role feature, tier:full, skills:[graphify],
  config:{graphify.enabled default false}, commands:[{family:graphify, module,
  router:routeGraphifyCommand}].
- removed case 'graphify' from gsd-tools.cjs; graphify now flows
  default -> dispatchCapabilityCommand -> commandFamilies.graphify -> router.
- regenerated registry (commandFamilies/bySkill/configSchema/profileMembership/
  capabilityClusters for graphify); tier:full keeps 4c install/surface a no-op.

Equivalence-proven: 8 recording-mock unit tests assert the exact fn+args per
subcommand (budget, snapshot-vs-build); 9 subprocess tests assert distinguishing
output shapes; existing graphify tests pass unchanged through the new path.

Surfaced (not silently accepted): the pre-existing --budget-no-value NaN no-op
quirk, preserved for equivalence, filed separately.

Closes #972

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-10 07:12:57 -04:00
committed by GitHub
parent 354e0e1b94
commit 77bfd943dd
13 changed files with 748 additions and 38 deletions

1
.gitignore vendored
View File

@@ -118,6 +118,7 @@ build/
/gsd-core/bin/lib/active-workstream-store.cjs
/gsd-core/bin/lib/adr-parser.cjs
/gsd-core/bin/lib/graphify.cjs
/gsd-core/bin/lib/graphify-command-router.cjs
/gsd-core/bin/lib/install-profiles.cjs
/gsd-core/bin/lib/intel.cjs
/gsd-core/bin/lib/installer-migrations.cjs

View File

@@ -161,7 +161,7 @@ A named, stable site on a host loop step (per-step `pre`/`post` plus per-wave in
ADR-857 phase 4b unified resolver that composes the three toggle systems (install profile, runtime surface, config activation) into one per-capability view. ADDITIVE — install/surface/workflows untouched; currently consumed by nothing (phase-6 wiring out of scope). Source of truth: `gsd-core/bin/lib/capability-state.cjs` (generated from `src/capability-state.cts`). Interface: `resolveCapabilityState({ registry, installedSkills, surfacedSkills, config, cwd? }) → { capabilities: CapabilityStateEntry[] }` (pure, no I/O); `cmdCapabilityState(cwd, runtimeConfigDir, raw, opts)` (I/O entry point). CLI surface: `gsd-tools capability state [--config-dir <path>]` — emits `{ runtimeConfigDir, capabilities[] }`. Per-capability output: `{ id, tier, skills[], installed, surfaced, hooks[] }` where `installed` = every owned skill ∈ installedSkills (or `installedSkills==='*'`; vacuously true for empty-skills caps), `surfaced` = every owned skill ∈ surfacedSkills (vacuously true for empty-skills caps), `hooks` = `[{ point, kind: 'step'|'gate'|'contribution', when, active }]` derived from the cap's `steps`, `gates`, `contributions` arrays (no `when` → active=true; `when` resolved via `_resolveActivationValue` from loop-resolver). Capabilities sorted by `id` for determinism. Defensive: malformed registry → `{ capabilities: [] }`, never throws; inline literal `__proto__`/`constructor`/`prototype` prototype-pollution guard on capability id keys. `runtimeConfigDir` auto-detection falls back to `getGlobalConfigDir` based on env-var presence (CODEX_HOME → codex, CURSOR_CONFIG_DIR → cursor, GEMINI_CONFIG_DIR → gemini, CLAUDE_CONFIG_DIR → claude, default → claude/`~/.claude`).
### Capability Command Family [Planned — mechanism built, unconsumed]
ADR-959 (phase 4d) — a CLI command family (a top-level `gsd-tools` command and its subcommands) owned by a Capability via a new optional `commands: [{ family, module, router }]` field on the `feature` role. The Capability declares the `family` name, a first-party in-tree `module` (under `gsd-core/bin/lib/`), and the exported `router` — a standard `route*Command({ args, cwd, raw, error })` function identical in shape to the 12 existing host routers (so it routes through the stateless CommandRoutingHub via `routeCjsCommandFamily`, owning its own subcommand list and arg parsing). The registry materializes a `commandFamilies` index (`family → { capId, module, router }`); the formerly-dead `_dispatchNonFamily` shim is replaced by a real `dispatchCapabilityCommand` (exported from `gsd-core/bin/gsd-tools.cjs`) consulted in `runCommand`'s **`default` case** — an unmigrated command hits its hardcoded `case`; a migrated command's `case` is removed so it reaches `default` → registry → router, making collision structurally impossible. The registry *discovers* a router (it does not rebuild a handler table). First-party only; third-party command loading deferred. **Mechanism built (4d-impl-1):** `commands` schema + validator + single-family-ownership cross-check in `gen-capability-registry.cjs`; `commandFamilies` index emitted in the generated `capability-registry.cjs` (currently `{}` — no capability declares commands yet); `dispatchCapabilityCommand` wired into `runCommand`'s `default` case (behavior-preserving today). **Pending next step (4d-impl-2 / pilot):** cut over `graphify` as the first real capability command family (bundling its command + skill + `isGraphifyEnabled` gate + `tier: full`), proven equivalent old-path vs new-path and serving as the phase-6 cutover template.
ADR-959 (phase 4d) — a CLI command family (a top-level `gsd-tools` command and its subcommands) owned by a Capability via a new optional `commands: [{ family, module, router }]` field on the `feature` role. The Capability declares the `family` name, a first-party in-tree `module` (under `gsd-core/bin/lib/`), and the exported `router` — a standard `route*Command({ args, cwd, raw, error })` function identical in shape to the 12 existing host routers (so it routes through the stateless CommandRoutingHub via `routeCjsCommandFamily`, owning its own subcommand list and arg parsing). The registry materializes a `commandFamilies` index (`family → { capId, module, router }`); the formerly-dead `_dispatchNonFamily` shim is replaced by a real `dispatchCapabilityCommand` (exported from `gsd-core/bin/gsd-tools.cjs`) consulted in `runCommand`'s **`default` case** — an unmigrated command hits its hardcoded `case`; a migrated command's `case` is removed so it reaches `default` → registry → router, making collision structurally impossible. The registry *discovers* a router (it does not rebuild a handler table). First-party only; third-party command loading deferred. **Mechanism built (4d-impl-1):** `commands` schema + validator + single-family-ownership cross-check in `gen-capability-registry.cjs`; `commandFamilies` index emitted in the generated `capability-registry.cjs` (currently `{}` — no capability declares commands yet); `dispatchCapabilityCommand` wired into `runCommand`'s `default` case (behavior-preserving today). **Pilot complete (4d-impl-2):** `graphify` cut over as the first real capability command family — `capabilities/graphify/capability.json` bundles the command (`family: graphify`, `module: graphify-command-router.cjs`, `router: routeGraphifyCommand`), skill (`graphify`), config gate (`graphify.enabled`), and `tier: full`; the `case 'graphify':` arm removed from `gsd-tools.cjs`; dispatch flows `default → dispatchCapabilityCommand → commandFamilies.graphify → graphify-command-router.cjs → routeGraphifyCommand`; behavior proven equivalent (all subcommands: build, query, status, diff, build snapshot, unknown subcommand error, usage error, disabled gate). Template for phase-6 per-feature cutovers.
### Runtime Capability [Planned]
A `role: runtime` variant of a Capability (a Capability carries `role: feature | runtime`) that projects GSD's produced artifacts (skills/agents/hooks/commands) onto one host CLI's conventions — config-surface format, artifact-layout kinds, command template, hooks manifest, sandbox tier. It is a declarative descriptor over a fixed first-party primitive vocabulary (not a code adapter); install composes active Feature Capabilities × the chosen Runtime Capability at the InstallPlan seam (ADR-0058). First-party runtimes are authored through the same descriptor a third party would write (dogfooding the interface); tier-1 (Claude Code, Codex, Antigravity) is fully tested, the other existing runtimes ship lower-tier, none dropped. Third-party runtime loading is deferred to a purely additive external loader + trust gate.

View File

@@ -0,0 +1,28 @@
{
"id": "graphify",
"role": "feature",
"title": "Knowledge graph",
"description": "Build, query, and inspect the project knowledge graph in `.planning/graphs/`; exposes graphify CLI subcommands (build, query, status, diff) and the /gsd-graphify skill.",
"tier": "full",
"requires": [],
"skills": ["graphify"],
"agents": [],
"config": {
"graphify.enabled": {
"type": "boolean",
"default": false,
"description": "Enable the graphify knowledge-graph command + skill."
}
},
"commands": [
{
"family": "graphify",
"module": "graphify-command-router.cjs",
"router": "routeGraphifyCommand"
}
],
"hooks": [],
"steps": [],
"contributions": [],
"gates": []
}

View File

@@ -375,6 +375,7 @@ Node.js CLI utility (`gsd-tools.cjs`) with domain modules split across `gsd-core
| `capability-registry.cjs` | Generated central Capability Registry — role-partitioned index of all co-located capability declarations; emitted by `scripts/gen-capability-registry.cjs` (ADR-894 §5) |
| `loop-resolver.cjs` | Loop Extension Point resolver — ADR-857 phase 3c registry-consuming query; filters `byLoopPoint` by config activation, renders active hooks as markdown, emits `{ point, activeHooks, rendered }` envelope; `gsd-tools loop render-hooks <point>` |
| `capability-state.cjs` | Unified capability-state resolver — ADR-857 phase 4b; composes install profile, runtime surface, and config activation into one per-capability view; pure `resolveCapabilityState` + I/O `cmdCapabilityState`; `gsd-tools capability state [--config-dir <path>]` |
| `graphify-command-router.cjs` | ADR-959 capability command router — first real capability command cutover (phase 4d-impl-2); extracted from the `case 'graphify':` arm in `gsd-tools.cjs`; dispatches build/query/status/diff subcommands; discovered via `commandFamilies` in the capability registry |
---

View File

@@ -1,5 +1,5 @@
{
"generated": "2026-06-09",
"generated": "2026-06-10",
"families": {
"agents": [
"gsd-advisor-researcher",
@@ -297,6 +297,7 @@
"federated-config.cjs",
"frontmatter.cjs",
"gap-checker.cjs",
"graphify-command-router.cjs",
"graphify.cjs",
"gsd2-import.cjs",
"init-command-router.cjs",

View File

@@ -370,7 +370,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t
---
## CLI Modules (102 shipped)
## CLI Modules (103 shipped)
Full listing: `gsd-core/bin/lib/*.cjs`.
@@ -409,6 +409,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`.
| `frontmatter.cjs` | YAML frontmatter CRUD operations |
| `gap-checker.cjs` | Post-planning gap analysis (#2493): unified REQUIREMENTS.md + CONTEXT.md decisions vs PLAN.md coverage report (`gsd-tools gap-analysis`) |
| `graphify.cjs` | Knowledge-graph build/query/status/diff for `/gsd-graphify` |
| `graphify-command-router.cjs` | ADR-959 capability command router for `gsd-tools graphify` — dispatches build/query/status/diff subcommands; first real capability command cutover (phase 4d-impl-2) |
| `gsd2-import.cjs` | External-plan ingest for `/gsd-import --from-gsd2` |
| `init-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools init` |
| `init.cjs` | Compound context loading for each workflow type |

View File

@@ -84,6 +84,7 @@ export default tseslint.config(
'gsd-core/bin/lib/active-workstream-store.cjs',
'gsd-core/bin/lib/adr-parser.cjs',
'gsd-core/bin/lib/graphify.cjs',
'gsd-core/bin/lib/graphify-command-router.cjs',
'gsd-core/bin/lib/install-profiles.cjs',
'gsd-core/bin/lib/intel.cjs',
'gsd-core/bin/lib/installer-migrations.cjs',

View File

@@ -1507,33 +1507,6 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
break;
}
// ─── Graphify ──────────────────────────────────────────────────────────
case 'graphify': {
const graphify = require('./lib/graphify.cjs');
const subcommand = args[1];
if (subcommand === 'query') {
const term = args[2];
if (!term) error('Usage: gsd-tools graphify query <term>', ERROR_REASON.USAGE);
const budgetIdx = args.indexOf('--budget');
const budget = budgetIdx !== -1 ? parseInt(args[budgetIdx + 1], 10) : null;
core.output(graphify.graphifyQuery(cwd, term, { budget }), raw);
} else if (subcommand === 'status') {
core.output(graphify.graphifyStatus(cwd), raw);
} else if (subcommand === 'diff') {
core.output(graphify.graphifyDiff(cwd), raw);
} else if (subcommand === 'build') {
if (args[2] === 'snapshot') {
core.output(graphify.writeSnapshot(cwd), raw);
} else {
core.output(graphify.graphifyBuild(cwd), raw);
}
} else {
error('Unknown graphify subcommand. Available: build, query, status, diff', ERROR_REASON.SDK_UNKNOWN_COMMAND);
}
break;
}
// ─── Documentation ────────────────────────────────────────────────────
case 'docs-init': {
@@ -2086,7 +2059,8 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
// An unmigrated command still hits its hardcoded `case` above — untouched.
// A migrated command's `case` is removed at cutover, so it reaches here and
// dispatchCapabilityCommand routes it to the capability's registered router.
// With commandFamilies={} today, this always returns false and is a no-op.
// commandFamilies now includes migrated capabilities (e.g. graphify → graphify-command-router.cjs);
// this returns true when a registered capability owns the command, false otherwise.
if (dispatchCapabilityCommand({ command, args, cwd, raw, error })) break;
// #3243: if the caller passed a dotted form (e.g. "foo.bar"), the shim

View File

@@ -7,6 +7,36 @@
*/
const capabilities = {
"graphify": {
"id": "graphify",
"role": "feature",
"title": "Knowledge graph",
"description": "Build, query, and inspect the project knowledge graph in `.planning/graphs/`; exposes graphify CLI subcommands (build, query, status, diff) and the /gsd-graphify skill.",
"tier": "full",
"requires": [],
"skills": [
"graphify"
],
"agents": [],
"config": {
"graphify.enabled": {
"type": "boolean",
"default": false,
"description": "Enable the graphify knowledge-graph command + skill."
}
},
"commands": [
{
"family": "graphify",
"module": "graphify-command-router.cjs",
"router": "routeGraphifyCommand"
}
],
"hooks": [],
"steps": [],
"contributions": [],
"gates": []
},
"ui": {
"id": "ui",
"role": "feature",
@@ -86,6 +116,7 @@ const capabilities = {
};
const bySkill = {
"graphify": "graphify",
"ui-phase": "ui",
"ui-review": "ui"
};
@@ -202,12 +233,19 @@ const byLoopPoint = {
};
const configKeys = {
"graphify.enabled": "graphify",
"workflow.ui_phase": "ui",
"workflow.ui_review": "ui",
"workflow.ui_safety_gate": "ui"
};
const configSchema = {
"graphify.enabled": {
"owner": "graphify",
"type": "boolean",
"default": false,
"description": "Enable the graphify knowledge-graph command + skill."
},
"workflow.ui_phase": {
"owner": "ui",
"type": "boolean",
@@ -230,9 +268,18 @@ const configSchema = {
const runtimes = {};
const commandFamilies = {};
const commandFamilies = {
"graphify": {
"capId": "graphify",
"module": "graphify-command-router.cjs",
"router": "routeGraphifyCommand"
}
};
const capabilityClusters = {
"graphify": [
"graphify"
],
"ui": [
"ui-phase",
"ui-review"
@@ -240,6 +287,12 @@ const capabilityClusters = {
};
const profileMembership = {
"graphify": {
"tier": "full",
"profiles": [
"full"
]
},
"ui": {
"tier": "full",
"profiles": [
@@ -249,6 +302,7 @@ const profileMembership = {
};
const _requiresGraph = {
"graphify": [],
"ui": []
};

View File

@@ -27,6 +27,7 @@
"files": [
"bug-622-graphify-optional-graph-html.test.cjs",
"graphify-auto-update.slow.test.cjs",
"graphify-command-cutover.test.cjs",
"graphify-query.test.cjs",
"graphify-visualization.test.cjs",
"graphify.test.cjs"

View File

@@ -0,0 +1,89 @@
'use strict';
/**
* Graphify command router — CLI subcommand dispatcher for `gsd-tools graphify`.
*
* ADR-959 (phase 4d-impl-2) pilot: first real capability command cutover.
* Extracted from the hardcoded `case 'graphify':` arm in gsd-tools.cjs.
* Behaviour is preserved byte-for-behaviour from the prior inline case;
* the dispatch path now flows: default → dispatchCapabilityCommand →
* require(graphify-command-router.cjs) → routeGraphifyCommand.
*
* Router signature: { args, cwd, raw, error } — identical to the 12 existing
* host routers. No new handler/arg convention; the capability registry
* discovers this router by name.
*
* Arg indexing (preserved exactly from the original case):
* args[0] = 'graphify' (family — matched by dispatchCapabilityCommand)
* args[1] = subcommand (query | status | diff | build)
* args[2] = term (query) | 'snapshot' (build snapshot)
* args.indexOf('--budget') + 1 = budget value
*
* Test seam: pass `_graphify` in the options object to inject a recording mock
* instead of the real graphify module. The `_`-prefix follows the repo's
* established seam convention (see other routers). Production callers omit it.
*/
// eslint-disable-next-line @typescript-eslint/no-require-imports
import graphify = require('./graphify.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports
import core = require('./core.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports
import io = require('./io.cjs');
const { ERROR_REASON } = io;
// ─── Types ────────────────────────────────────────────────────────────────────
interface GraphifyModule {
graphifyQuery(cwd: string, term: string, opts: { budget: number | null }): unknown;
graphifyStatus(cwd: string): unknown;
graphifyDiff(cwd: string): unknown;
graphifyBuild(cwd: string): unknown;
writeSnapshot(cwd: string): unknown;
}
interface RouteGraphifyCommandOptions {
args: string[];
cwd: string;
raw: boolean;
error: (message: string, reason?: string) => void;
/** Test seam: inject a mock graphify module. Defaults to the real module. */
_graphify?: GraphifyModule;
}
// ─── Implementation ───────────────────────────────────────────────────────────
function routeGraphifyCommand({ args, cwd, raw, error, _graphify }: RouteGraphifyCommandOptions): void {
const subcommand = args[1];
const g: GraphifyModule = _graphify ?? graphify;
if (subcommand === 'query') {
const term = args[2];
if (!term) {
error('Usage: gsd-tools graphify query <term>', ERROR_REASON.USAGE);
return;
}
const budgetIdx = args.indexOf('--budget');
const budget = budgetIdx !== -1 ? parseInt(args[budgetIdx + 1], 10) : null;
core.output(g.graphifyQuery(cwd, term, { budget }), raw);
} else if (subcommand === 'status') {
core.output(g.graphifyStatus(cwd), raw);
} else if (subcommand === 'diff') {
core.output(g.graphifyDiff(cwd), raw);
} else if (subcommand === 'build') {
if (args[2] === 'snapshot') {
core.output(g.writeSnapshot(cwd), raw);
} else {
core.output(g.graphifyBuild(cwd), raw);
}
} else {
error(
'Unknown graphify subcommand. Available: build, query, status, diff',
ERROR_REASON.SDK_UNKNOWN_COMMAND,
);
}
}
export = {
routeGraphifyCommand,
};

View File

@@ -741,16 +741,22 @@ describe('dispatchCapabilityCommand — async router returns a Promise → struc
});
});
// ─── 9. Behavior-preservation: real registry has empty commandFamilies ────────
// ─── 9. Behavior-preservation: real registry commandFamilies ────────────────
describe('dispatchCapabilityCommand — real registry behavior-preservation', () => {
test('real capability-registry.cjs commandFamilies is {} (no capability declares commands)', () => {
test('real capability-registry.cjs commandFamilies is exported and is an object', () => {
// Phase 4d-impl-2: graphify was the first capability to declare a command family.
// This test was originally written as "commandFamilies must be empty today" but
// now asserts the structural contract instead (exported, object) since the graphify
// cutover populates it.
const realRegistry = require('../gsd-core/bin/lib/capability-registry.cjs');
assert.ok(realRegistry.commandFamilies, 'commandFamilies must be exported');
assert.deepEqual(
Object.keys(realRegistry.commandFamilies),
[],
'real registry commandFamilies must be empty today',
assert.strictEqual(typeof realRegistry.commandFamilies, 'object',
'commandFamilies must be an object');
// graphify is the first (and currently only) real capability command family
assert.ok(
Object.prototype.hasOwnProperty.call(realRegistry.commandFamilies, 'graphify'),
'real registry commandFamilies must include graphify after 4d-impl-2 cutover',
);
});

View File

@@ -0,0 +1,553 @@
'use strict';
/**
* graphify-command-cutover.test.cjs — ADR-959 phase 4d-impl-2 equivalence tests.
*
* Verifies that the `graphify` command family, after cutover from the hardcoded
* `case 'graphify':` arm in gsd-tools.cjs to the capability registry dispatch
* path (default → dispatchCapabilityCommand → graphify-command-router.cjs →
* routeGraphifyCommand), behaves identically to the old inline case.
*
* Test categories:
* 1. UNIT (recording mock) — precise arg/call equivalence for every routing path
* 2. DISPATCH — command reaches the router via default-case registry dispatch
* 3. SUBCOMMANDS — subprocess tests with real output-shape assertions
* 4. ERROR PATHS — unknown subcommand, usage (missing term), disabled gate
* 5. JSON-ERRORS — structured {ok:false,reason,message} on usage/unknown errors
* 6. REGISTRY — commandFamilies/bySkill/configSchema/profileMembership/capabilityClusters
*/
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
const {
enableGraphify,
writeGraphJson,
SAMPLE_GRAPH,
} = require('./helpers/graphify.cjs');
const registry = require('../gsd-core/bin/lib/capability-registry.cjs');
const { routeGraphifyCommand } = require('../gsd-core/bin/lib/graphify-command-router.cjs');
// ─── helpers ────────────────────────────────────────────────────────────────
function runJsonErrors(args, tmpDir, env = {}) {
const result = runGsdTools(args, tmpDir, { ...env, GSD_JSON_ERRORS: '1' });
assert.strictEqual(result.success, false,
`Expected failure with GSD_JSON_ERRORS=1 for args: ${args.join(' ')}\n` +
`stdout: ${result.output}\nstderr: ${result.error}`);
let parsed;
try {
parsed = JSON.parse(result.error);
} catch (e) {
throw new Error(
`GSD_JSON_ERRORS=1 must emit valid JSON on stderr.\n` +
`Args: ${args.join(' ')}\nstderr: ${result.error}\nparse error: ${e.message}`,
);
}
return parsed;
}
function assertTypedError(parsed, expectedReason, label) {
assert.strictEqual(parsed.ok, false, `${label}: error object must have ok: false`);
assert.strictEqual(parsed.reason, expectedReason,
`${label}: reason must be "${expectedReason}", got: ${parsed.reason}`);
assert.ok(typeof parsed.message === 'string' && parsed.message.length > 0,
`${label}: message must be a non-empty string`);
}
/**
* Build a recording mock for the graphify module.
* Each public function records its call and returns a sentinel object
* `{ _mock: '<fnName>', args: [...] }` so tests can assert on WHICH function
* was called and with WHICH arguments without running real I/O.
*/
function makeGraphifyMock() {
const calls = [];
function recorder(name, ...fnArgs) {
const sentinel = { _mock: name, args: fnArgs };
calls.push(sentinel);
return sentinel;
}
return {
calls,
mock: {
graphifyQuery: (cwd, term, opts) => recorder('graphifyQuery', cwd, term, opts),
graphifyStatus: (cwd) => recorder('graphifyStatus', cwd),
graphifyDiff: (cwd) => recorder('graphifyDiff', cwd),
graphifyBuild: (cwd) => recorder('graphifyBuild', cwd),
writeSnapshot: (cwd) => recorder('writeSnapshot', cwd),
},
};
}
// ─── 1. UNIT — precise routing equivalence via recording mock ─────────────────
describe('graphify router: precise unit tests (recording mock)', () => {
const CWD = '/fake/cwd';
const RAW = false;
function makeErrorRecorder() {
const calls = [];
const fn = (msg, reason) => calls.push({ msg, reason });
fn.calls = calls;
return fn;
}
test('query with term → calls graphifyQuery(cwd, term, { budget: null })', () => {
const { calls, mock } = makeGraphifyMock();
const errFn = makeErrorRecorder();
routeGraphifyCommand({
args: ['graphify', 'query', 'myterm'],
cwd: CWD, raw: RAW, error: errFn, _graphify: mock,
});
assert.strictEqual(errFn.calls.length, 0, 'error must not be called');
assert.strictEqual(calls.length, 1, 'exactly one graphify fn called');
assert.strictEqual(calls[0]._mock, 'graphifyQuery');
assert.deepStrictEqual(calls[0].args, [CWD, 'myterm', { budget: null }]);
});
test('query with --budget → calls graphifyQuery(cwd, term, { budget: 5 }) as integer', () => {
const { calls, mock } = makeGraphifyMock();
const errFn = makeErrorRecorder();
routeGraphifyCommand({
args: ['graphify', 'query', 'myterm', '--budget', '5'],
cwd: CWD, raw: RAW, error: errFn, _graphify: mock,
});
assert.strictEqual(errFn.calls.length, 0, 'error must not be called');
assert.strictEqual(calls.length, 1);
assert.strictEqual(calls[0]._mock, 'graphifyQuery');
// budget must be parsed as integer 5, not the string '5'
assert.deepStrictEqual(calls[0].args, [CWD, 'myterm', { budget: 5 }]);
assert.strictEqual(typeof calls[0].args[2].budget, 'number',
'budget must be a number, not a string');
});
test('query missing term → error(usage msg, USAGE); graphifyQuery NOT called', () => {
const { calls, mock } = makeGraphifyMock();
const errFn = makeErrorRecorder();
routeGraphifyCommand({
args: ['graphify', 'query'],
cwd: CWD, raw: RAW, error: errFn, _graphify: mock,
});
assert.strictEqual(errFn.calls.length, 1, 'error must be called once');
assert.ok(
errFn.calls[0].msg.includes('Usage: gsd-tools graphify query <term>'),
`usage message must match exactly; got: ${errFn.calls[0].msg}`,
);
assert.strictEqual(errFn.calls[0].reason, 'usage',
`reason must be 'usage'; got: ${errFn.calls[0].reason}`);
assert.strictEqual(calls.length, 0, 'graphifyQuery must NOT be called');
});
test('status → calls graphifyStatus(cwd)', () => {
const { calls, mock } = makeGraphifyMock();
const errFn = makeErrorRecorder();
routeGraphifyCommand({
args: ['graphify', 'status'],
cwd: CWD, raw: RAW, error: errFn, _graphify: mock,
});
assert.strictEqual(errFn.calls.length, 0);
assert.strictEqual(calls.length, 1);
assert.strictEqual(calls[0]._mock, 'graphifyStatus');
assert.deepStrictEqual(calls[0].args, [CWD]);
});
test('diff → calls graphifyDiff(cwd)', () => {
const { calls, mock } = makeGraphifyMock();
const errFn = makeErrorRecorder();
routeGraphifyCommand({
args: ['graphify', 'diff'],
cwd: CWD, raw: RAW, error: errFn, _graphify: mock,
});
assert.strictEqual(errFn.calls.length, 0);
assert.strictEqual(calls.length, 1);
assert.strictEqual(calls[0]._mock, 'graphifyDiff');
assert.deepStrictEqual(calls[0].args, [CWD]);
});
test('build (no snapshot) → calls graphifyBuild(cwd); NOT writeSnapshot', () => {
const { calls, mock } = makeGraphifyMock();
const errFn = makeErrorRecorder();
routeGraphifyCommand({
args: ['graphify', 'build'],
cwd: CWD, raw: RAW, error: errFn, _graphify: mock,
});
assert.strictEqual(errFn.calls.length, 0);
assert.strictEqual(calls.length, 1);
assert.strictEqual(calls[0]._mock, 'graphifyBuild',
'build without "snapshot" arg must call graphifyBuild, NOT writeSnapshot');
assert.deepStrictEqual(calls[0].args, [CWD]);
const wroteSnapshot = calls.some(c => c._mock === 'writeSnapshot');
assert.strictEqual(wroteSnapshot, false, 'writeSnapshot must NOT be called for plain build');
});
test('build snapshot → calls writeSnapshot(cwd); NOT graphifyBuild', () => {
const { calls, mock } = makeGraphifyMock();
const errFn = makeErrorRecorder();
routeGraphifyCommand({
args: ['graphify', 'build', 'snapshot'],
cwd: CWD, raw: RAW, error: errFn, _graphify: mock,
});
assert.strictEqual(errFn.calls.length, 0);
assert.strictEqual(calls.length, 1);
assert.strictEqual(calls[0]._mock, 'writeSnapshot',
'build snapshot must call writeSnapshot, NOT graphifyBuild');
assert.deepStrictEqual(calls[0].args, [CWD]);
const calledBuild = calls.some(c => c._mock === 'graphifyBuild');
assert.strictEqual(calledBuild, false, 'graphifyBuild must NOT be called for build snapshot');
});
test('unknown subcommand → error(sdk_unknown_command); no graphify fn called', () => {
const { calls, mock } = makeGraphifyMock();
const errFn = makeErrorRecorder();
routeGraphifyCommand({
args: ['graphify', 'bogus'],
cwd: CWD, raw: RAW, error: errFn, _graphify: mock,
});
assert.strictEqual(errFn.calls.length, 1, 'error must be called once');
assert.ok(
errFn.calls[0].msg.includes('Unknown graphify subcommand'),
`unknown-subcommand message must include "Unknown graphify subcommand"; got: ${errFn.calls[0].msg}`,
);
assert.ok(
errFn.calls[0].msg.includes('build') &&
errFn.calls[0].msg.includes('query') &&
errFn.calls[0].msg.includes('status') &&
errFn.calls[0].msg.includes('diff'),
`unknown-subcommand message must list all subcommands; got: ${errFn.calls[0].msg}`,
);
assert.strictEqual(errFn.calls[0].reason, 'sdk_unknown_command',
`reason must be 'sdk_unknown_command'; got: ${errFn.calls[0].reason}`);
assert.strictEqual(calls.length, 0, 'no graphify fn must be called for unknown subcommand');
});
});
// ─── 2. DISPATCH — command reaches router via default-case ───────────────────
describe('graphify cutover: dispatch path (default-case → capability registry)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('graphify status dispatches via capability registry (not hardcoded case)', () => {
// With graphify disabled the router returns a disabledResponse; the key
// assertion here is that the command REACHES the router at all (no
// "Unknown command: graphify" error) — proving default→registry dispatch.
const result = runGsdTools(['graphify', 'status'], tmpDir);
// graphify disabled → status returns disabled response JSON (not unknown-command error)
assert.ok(result.success, `Expected success (disabled response), got error: ${result.error}`);
const isUnknownCommand = (result.error || '').includes('Unknown command: graphify');
assert.strictEqual(isUnknownCommand, false, 'Must not emit "Unknown command: graphify"');
});
test('unknown subcommand emits sdk_unknown_command (proves router reached)', () => {
// If dispatch failed we'd see "Unknown command: graphify" with reason
// sdk_unknown_command. Getting "Unknown graphify subcommand" confirms the
// router was reached.
const parsed = runJsonErrors(['graphify', 'bogus-xyzzy'], tmpDir);
assertTypedError(parsed, 'sdk_unknown_command', 'unknown-subcommand dispatch proof');
assert.ok(
parsed.message.includes('graphify') || parsed.message.includes('Unknown'),
`message should mention graphify or Unknown subcommand; got: ${parsed.message}`,
);
});
});
// ─── 3. SUBCOMMANDS — subprocess tests with real output-shape assertions ──────
describe('graphify cutover: subcommand behavior equivalence', () => {
let tmpDir;
let planningDir;
beforeEach(() => {
tmpDir = createTempProject();
planningDir = path.join(tmpDir, '.planning');
});
afterEach(() => {
cleanup(tmpDir);
});
test('status (disabled) → disabled response with disabled:true', () => {
const result = runGsdTools(['graphify', 'status'], tmpDir);
assert.ok(result.success, `Expected success; error: ${result.error}`);
const parsed = JSON.parse(result.output);
assert.strictEqual(parsed.disabled, true, 'disabled graphify: disabled must be true');
});
test('status (enabled, no graph) → exists:false shape distinguishes from disabled', () => {
enableGraphify(planningDir);
const result = runGsdTools(['graphify', 'status'], tmpDir);
assert.ok(result.success, `Expected success; error: ${result.error}`);
const parsed = JSON.parse(result.output);
// enabled but no graph → { exists: false } — NOT { disabled: true }
assert.notStrictEqual(parsed.disabled, true,
'enabled graphify status: disabled must not be true');
assert.strictEqual(parsed.exists, false,
'enabled graphify status (no graph): exists must be false');
});
test('status (enabled, with graph) → exists:true with node_count/edge_count fields', () => {
enableGraphify(planningDir);
writeGraphJson(planningDir, SAMPLE_GRAPH);
const result = runGsdTools(['graphify', 'status'], tmpDir);
assert.ok(result.success, `Expected success; error: ${result.error}`);
const parsed = JSON.parse(result.output);
assert.strictEqual(parsed.exists, true,
'status with graph: exists must be true');
assert.ok('node_count' in parsed,
'status with graph must include node_count field');
assert.ok('edge_count' in parsed,
'status with graph must include edge_count field');
assert.strictEqual(parsed.node_count, SAMPLE_GRAPH.nodes.length,
`node_count must match graph: got ${parsed.node_count}, expected ${SAMPLE_GRAPH.nodes.length}`);
// status shape is distinct from query/diff/build by presence of exists+node_count
assert.ok(!('term' in parsed),
'status shape must not have term field (would indicate wrong function called)');
assert.ok(!('action' in parsed),
'status shape must not have action field (would indicate build was called instead)');
});
test('diff (enabled, no snapshot) → no_baseline:true shape (distinct from status/query/build)', () => {
enableGraphify(planningDir);
const result = runGsdTools(['graphify', 'diff'], tmpDir);
assert.ok(result.success, `Expected success; error: ${result.error}`);
const parsed = JSON.parse(result.output);
// diff with no snapshot → { no_baseline: true }
assert.strictEqual(parsed.no_baseline, true,
'diff (no snapshot) must return no_baseline:true — routing reached diff handler');
// Shape is distinct from status (which has exists:) and query (which has term:)
assert.ok(!('exists' in parsed),
'diff response must not have exists field (would indicate status was called instead)');
assert.ok(!('term' in parsed),
'diff response must not have term field (would indicate query was called instead)');
});
test('build (enabled, no graphify binary) → action:spawn_agent shape (distinct from snapshot)', () => {
enableGraphify(planningDir);
const result = runGsdTools(['graphify', 'build'], tmpDir);
// graphify binary is not installed in test environments → graphifyBuild returns
// an error about missing binary, NOT action:spawn_agent. Either way, the output
// shape is from graphifyBuild (not writeSnapshot which returns {saved:true,...}).
const parsed = JSON.parse(result.output);
// graphifyBuild with no binary → { error: '...' } (installed check failed)
// graphifyBuild with binary → { action: 'spawn_agent', ... }
// writeSnapshot → { saved: true, timestamp, node_count, edge_count }
// Key: must NOT be snapshot's {saved:true} shape
assert.strictEqual(parsed.saved, undefined,
'build (not snapshot) must NOT return saved:true — routing must call graphifyBuild not writeSnapshot');
assert.ok('error' in parsed || 'action' in parsed,
`build must return graphifyBuild shape ({error:...} or {action:...}); got: ${JSON.stringify(parsed)}`);
});
test('build snapshot (enabled) → saved:true shape (distinct from plain build)', () => {
enableGraphify(planningDir);
// write graph.json so writeSnapshot can read it
writeGraphJson(planningDir, SAMPLE_GRAPH);
const result = runGsdTools(['graphify', 'build', 'snapshot'], tmpDir);
assert.ok(result.success, `Expected success; error: ${result.error}`);
const parsed = JSON.parse(result.output);
// writeSnapshot → { saved: true, timestamp: <ISO>, node_count: N, edge_count: M }
assert.strictEqual(parsed.saved, true,
'build snapshot must return saved:true — routing must call writeSnapshot, not graphifyBuild');
assert.ok('timestamp' in parsed,
'build snapshot result must include timestamp field');
assert.ok('node_count' in parsed,
'build snapshot result must include node_count field');
assert.ok('edge_count' in parsed,
'build snapshot result must include edge_count field');
// Shape must NOT be graphifyBuild shape (which has action: or error:)
assert.strictEqual(parsed.action, undefined,
'build snapshot must not have action field (that would be graphifyBuild, not writeSnapshot)');
});
test('query with term (enabled, with graph) → term field echoed in response', () => {
enableGraphify(planningDir);
writeGraphJson(planningDir, SAMPLE_GRAPH);
const result = runGsdTools(['graphify', 'query', 'AuthService'], tmpDir);
assert.ok(result.success, `Expected success; error: ${result.error}`);
const parsed = JSON.parse(result.output);
// graphifyQuery → { term, nodes, edges, total_nodes, total_edges, trimmed }
assert.strictEqual(parsed.term, 'AuthService',
'query response must echo the search term — confirms graphifyQuery was called');
assert.ok('nodes' in parsed,
'query response must include nodes array');
assert.ok('total_nodes' in parsed,
'query response must include total_nodes field');
// Shape distinct from status (exists), diff (no_baseline), build (action/saved)
assert.strictEqual(parsed.exists, undefined,
'query must not have exists field (that would be status)');
assert.strictEqual(parsed.saved, undefined,
'query must not have saved field (that would be writeSnapshot)');
});
test('query with --budget flag → same term field, budget applied (NaN budget graceful)', () => {
enableGraphify(planningDir);
writeGraphJson(planningDir, SAMPLE_GRAPH);
const result = runGsdTools(['graphify', 'query', 'AuthService', '--budget', '5'], tmpDir);
assert.ok(result.success, `Expected success; error: ${result.error}`);
const parsed = JSON.parse(result.output);
// Must still return the query shape (term echoed), not a routing error
assert.strictEqual(parsed.term, 'AuthService',
'query+--budget must echo term — confirms graphifyQuery reached with --budget arg');
// Confirms it's not a build/snapshot shape (which would have saved:/action:)
assert.strictEqual(parsed.saved, undefined, 'must not be writeSnapshot shape');
assert.strictEqual(parsed.action, undefined, 'must not be graphifyBuild shape');
});
test('diff (disabled) → disabled response with disabled:true', () => {
const result = runGsdTools(['graphify', 'diff'], tmpDir);
assert.ok(result.success, `Expected success (disabled); error: ${result.error}`);
const parsed = JSON.parse(result.output);
assert.strictEqual(parsed.disabled, true, 'disabled diff: disabled must be true');
});
});
// ─── 4. ERROR PATHS ──────────────────────────────────────────────────────────
describe('graphify cutover: error path equivalence', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('unknown subcommand → non-zero exit', () => {
const result = runGsdTools(['graphify', 'bogus-sub-xyzzy'], tmpDir);
assert.strictEqual(result.success, false, 'unknown subcommand must fail');
});
test('unknown subcommand error message mentions expected subcommands', () => {
const result = runGsdTools(['graphify', 'bogus-sub-xyzzy'], tmpDir);
assert.ok(
result.error.includes('build') &&
result.error.includes('query') &&
result.error.includes('status') &&
result.error.includes('diff'),
`unknown-subcommand error should list build, query, status, diff; got: ${result.error}`,
);
});
test('query with no term → non-zero exit with usage error', () => {
const result = runGsdTools(['graphify', 'query'], tmpDir);
assert.strictEqual(result.success, false, 'missing term must fail');
});
test('query with no term → error message contains usage hint', () => {
const result = runGsdTools(['graphify', 'query'], tmpDir);
assert.ok(
result.error.includes('Usage') || result.error.includes('graphify query'),
`missing-term error should mention usage; got: ${result.error}`,
);
});
});
// ─── 5. JSON-ERRORS ──────────────────────────────────────────────────────────
describe('graphify cutover: --json-errors structured output', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('unknown subcommand → sdk_unknown_command reason (behavior preserved)', () => {
const parsed = runJsonErrors(['graphify', 'bogus-xyzzy'], tmpDir);
assertTypedError(parsed, 'sdk_unknown_command', 'unknown graphify subcommand');
});
test('query missing term → usage reason (behavior preserved)', () => {
const parsed = runJsonErrors(['graphify', 'query'], tmpDir);
assertTypedError(parsed, 'usage', 'graphify query missing term');
});
test('unknown subcommand message text preserved', () => {
const parsed = runJsonErrors(['graphify', 'bogus-xyzzy'], tmpDir);
assert.ok(
parsed.message.includes('Unknown graphify subcommand'),
`message must start with "Unknown graphify subcommand"; got: ${parsed.message}`,
);
});
test('query missing term message text preserved', () => {
const parsed = runJsonErrors(['graphify', 'query'], tmpDir);
assert.ok(
parsed.message.includes('graphify query'),
`message must include "graphify query"; got: ${parsed.message}`,
);
});
});
// ─── 6. REGISTRY ─────────────────────────────────────────────────────────────
describe('graphify cutover: registry entries correct', () => {
test('commandFamilies.graphify entry present and well-shaped', () => {
const entry = registry.commandFamilies.graphify;
assert.ok(entry, 'commandFamilies.graphify must be present');
assert.strictEqual(entry.capId, 'graphify', 'commandFamilies.graphify.capId must be "graphify"');
assert.strictEqual(entry.module, 'graphify-command-router.cjs',
'commandFamilies.graphify.module must be "graphify-command-router.cjs"');
assert.strictEqual(entry.router, 'routeGraphifyCommand',
'commandFamilies.graphify.router must be "routeGraphifyCommand"');
});
test('bySkill.graphify maps to graphify capability', () => {
assert.strictEqual(registry.bySkill.graphify, 'graphify',
'bySkill["graphify"] must point to the graphify capability');
});
test('configSchema["graphify.enabled"] entry present', () => {
const entry = registry.configSchema['graphify.enabled'];
assert.ok(entry, 'configSchema["graphify.enabled"] must be present');
assert.strictEqual(entry.owner, 'graphify', 'configSchema owner must be "graphify"');
assert.strictEqual(entry.type, 'boolean', 'configSchema type must be "boolean"');
assert.strictEqual(entry.default, false, 'configSchema default must be false');
});
test('profileMembership.graphify is tier:full, profiles:["full"]', () => {
const pm = registry.profileMembership.graphify;
assert.ok(pm, 'profileMembership.graphify must be present');
assert.strictEqual(pm.tier, 'full', 'profileMembership.graphify.tier must be "full"');
assert.deepStrictEqual(pm.profiles, ['full'],
'profileMembership.graphify.profiles must be ["full"]');
});
test('capabilityClusters.graphify is ["graphify"]', () => {
const clusters = registry.capabilityClusters.graphify;
assert.deepStrictEqual(clusters, ['graphify'],
'capabilityClusters.graphify must be ["graphify"]');
});
test('graphify capability id in capabilities map', () => {
const cap = registry.capabilities.graphify;
assert.ok(cap, 'capabilities.graphify must be present');
assert.strictEqual(cap.role, 'feature', 'graphify capability must have role: feature');
assert.strictEqual(cap.tier, 'full', 'graphify capability must have tier: full');
});
test('graphify capability commands[0] entry', () => {
const cap = registry.capabilities.graphify;
assert.ok(Array.isArray(cap.commands) && cap.commands.length > 0,
'graphify capability must have commands array');
const cmd = cap.commands[0];
assert.strictEqual(cmd.family, 'graphify');
assert.strictEqual(cmd.module, 'graphify-command-router.cjs');
assert.strictEqual(cmd.router, 'routeGraphifyCommand');
});
});