From 77bfd943dd70dcafbde1cb6d0dd809e59c35f198 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 10 Jun 2026 07:12:57 -0400 Subject: [PATCH] =?UTF-8?q?feat(#972):=20graphify=20command=20cutover=20?= =?UTF-8?q?=E2=80=94=20first=20capability=20owning=20a=20command=20family?= =?UTF-8?q?=20(ADR-857=20phase=204d-impl-2)=20(#975)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 1 + CONTEXT.md | 2 +- capabilities/graphify/capability.json | 28 + docs/ARCHITECTURE.md | 1 + docs/INVENTORY-MANIFEST.json | 3 +- docs/INVENTORY.md | 3 +- eslint.config.mjs | 1 + gsd-core/bin/gsd-tools.cjs | 30 +- gsd-core/bin/lib/capability-registry.cjs | 56 +- scripts/lint-test-file-count.allowlist.json | 1 + src/graphify-command-router.cts | 89 ++++ tests/capability-command-dispatch.test.cjs | 18 +- tests/graphify-command-cutover.test.cjs | 553 ++++++++++++++++++++ 13 files changed, 748 insertions(+), 38 deletions(-) create mode 100644 capabilities/graphify/capability.json create mode 100644 src/graphify-command-router.cts create mode 100644 tests/graphify-command-cutover.test.cjs diff --git a/.gitignore b/.gitignore index fc083c671..0dfbf0951 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/CONTEXT.md b/CONTEXT.md index 4b4203420..4b477b383 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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 ]` — 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. diff --git a/capabilities/graphify/capability.json b/capabilities/graphify/capability.json new file mode 100644 index 000000000..3545e5be1 --- /dev/null +++ b/capabilities/graphify/capability.json @@ -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": [] +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 553a754de..c5e100ce1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 ` | | `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 ]` | +| `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 | --- diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index e2fa07caf..c3077e833 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -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", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 1189cbaf4..31267ab39 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -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 | diff --git a/eslint.config.mjs b/eslint.config.mjs index 4606ce3f0..a43766e9d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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', diff --git a/gsd-core/bin/gsd-tools.cjs b/gsd-core/bin/gsd-tools.cjs index 1d92d91f5..084bc465a 100755 --- a/gsd-core/bin/gsd-tools.cjs +++ b/gsd-core/bin/gsd-tools.cjs @@ -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 ', 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 diff --git a/gsd-core/bin/lib/capability-registry.cjs b/gsd-core/bin/lib/capability-registry.cjs index 710076578..8e33e62fb 100644 --- a/gsd-core/bin/lib/capability-registry.cjs +++ b/gsd-core/bin/lib/capability-registry.cjs @@ -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": [] }; diff --git a/scripts/lint-test-file-count.allowlist.json b/scripts/lint-test-file-count.allowlist.json index 55b1a42ef..9aae0f91e 100644 --- a/scripts/lint-test-file-count.allowlist.json +++ b/scripts/lint-test-file-count.allowlist.json @@ -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" diff --git a/src/graphify-command-router.cts b/src/graphify-command-router.cts new file mode 100644 index 000000000..67216482d --- /dev/null +++ b/src/graphify-command-router.cts @@ -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 ', 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, +}; diff --git a/tests/capability-command-dispatch.test.cjs b/tests/capability-command-dispatch.test.cjs index 6f57feadc..4be8d1a2e 100644 --- a/tests/capability-command-dispatch.test.cjs +++ b/tests/capability-command-dispatch.test.cjs @@ -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', ); }); diff --git a/tests/graphify-command-cutover.test.cjs b/tests/graphify-command-cutover.test.cjs new file mode 100644 index 000000000..8f667b974 --- /dev/null +++ b/tests/graphify-command-cutover.test.cjs @@ -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: '', 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 '), + `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: , 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'); + }); +});