From 9a03539c2d5fabc53f8b20c6cca2f757d536ff80 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 10 Jun 2026 08:34:49 -0400 Subject: [PATCH] =?UTF-8?q?feat(#981):=20audit-uat=20+=20audit-open=20comm?= =?UTF-8?q?and=20cutover=20=E2=80=94=20commands-only=20capability=20(ADR-8?= =?UTF-8?q?57=20phase=204d-impl-3)=20(#984)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate the audit-uat + audit-open CLI commands from hardcoded gsd-tools.cjs case arms to a registry-dispatched Capability (commandFamilies mechanism, #961), mirroring the graphify cutover (#972). New src/audit-command-router.cts exports routeAuditUat/routeAuditOpen, each lazily requiring only its backing module (uat.cjs/audit.cjs) inside the route fn — matching the old per-case lazy loads. capabilities/audit/capability.json declares the two command families; commands-only (skills:[]), no config gate, audit_review cluster untouched. Behavior-CHANGING (dispatch path) but equivalence-proven: CLI output identical; existing audit regression tests pass unchanged. Closes #981 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/audit/capability.json | 27 ++ docs/ARCHITECTURE.md | 3 +- docs/INVENTORY-MANIFEST.json | 1 + docs/INVENTORY.md | 3 +- eslint.config.mjs | 1 + gsd-core/bin/gsd-tools.cjs | 21 -- gsd-core/bin/lib/capability-registry.cjs | 38 ++ scripts/lint-test-file-count.allowlist.json | 1 + src/audit-command-router.cts | 101 ++++++ tests/audit-command-cutover.test.cjs | 381 ++++++++++++++++++++ 12 files changed, 556 insertions(+), 24 deletions(-) create mode 100644 capabilities/audit/capability.json create mode 100644 src/audit-command-router.cts create mode 100644 tests/audit-command-cutover.test.cjs diff --git a/.gitignore b/.gitignore index 0dfbf0951..dcafd1f3f 100644 --- a/.gitignore +++ b/.gitignore @@ -119,6 +119,7 @@ build/ /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/audit-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 4b477b383..35671cbb0 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). **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. +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. **Audit cutover (4d-impl-3):** `audit-uat` and `audit-open` cut over as the second capability command family pair — `capabilities/audit/capability.json` declares two commands (`family: audit-uat`, `module: audit-command-router.cjs`, `router: routeAuditUat`) and (`family: audit-open`, `module: audit-command-router.cjs`, `router: routeAuditOpen`); the `case 'audit-uat':` and `case 'audit-open':` arms removed from `gsd-tools.cjs`; `commandFamilies` now holds `audit-uat`, `audit-open`, and `graphify`; dispatch flows `default → dispatchCapabilityCommand → commandFamilies["audit-uat"|"audit-open"] → audit-command-router.cjs → routeAuditUat|routeAuditOpen`; behavior equivalence proven by existing regression tests (bug-2659, bug-2911, uat.test.cjs) plus new cutover tests. Confirms hyphenated family names pass registry validator (no format restriction beyond non-empty + non-reserved). ### 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/audit/capability.json b/capabilities/audit/capability.json new file mode 100644 index 000000000..4543cc45e --- /dev/null +++ b/capabilities/audit/capability.json @@ -0,0 +1,27 @@ +{ + "id": "audit", + "role": "feature", + "title": "Audit", + "description": "Open-artifact audit and UAT-gap audit for milestone close gates; exposes `gsd-tools audit-uat` (cross-phase UAT outstanding items) and `gsd-tools audit-open` (structured open-artifact scan across debug, tasks, threads, todos, seeds, UAT, verification, context-questions).", + "tier": "full", + "requires": [], + "skills": [], + "agents": [], + "config": {}, + "commands": [ + { + "family": "audit-uat", + "module": "audit-command-router.cjs", + "router": "routeAuditUat" + }, + { + "family": "audit-open", + "module": "audit-command-router.cjs", + "router": "routeAuditOpen" + } + ], + "hooks": [], + "steps": [], + "contributions": [], + "gates": [] +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c5e100ce1..40a03e518 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -338,7 +338,7 @@ Agents always return a `RESEARCH.md` path, never raw fetched content. Context di ### CLI Tools (`gsd-core/bin/`) -Node.js CLI utility (`gsd-tools.cjs`) with domain modules split across `gsd-core/bin/lib/` (see [`docs/INVENTORY.md`](INVENTORY.md#cli-modules-33-shipped) for the authoritative roster): +Node.js CLI utility (`gsd-tools.cjs`) with domain modules split across `gsd-core/bin/lib/` (see [`docs/INVENTORY.md`](INVENTORY.md#cli-modules-104-shipped) for the authoritative roster): | Module | Responsibility | @@ -376,6 +376,7 @@ Node.js CLI utility (`gsd-tools.cjs`) with domain modules split across `gsd-core | `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 | +| `audit-command-router.cjs` | ADR-959 capability command router (phase 4d-impl-3); extracted from the `case 'audit-uat':` and `case 'audit-open':` arms in `gsd-tools.cjs`; `routeAuditUat` → `uat.cjs:cmdAuditUat`, `routeAuditOpen` → `audit.cjs:{auditOpenArtifacts,formatAuditReport}`; discovered via `commandFamilies` in the capability registry | --- diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index c3077e833..6e16f9438 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -269,6 +269,7 @@ "adr-parser.cjs", "agent-command-router.cjs", "artifacts.cjs", + "audit-command-router.cjs", "audit.cjs", "capability-registry.cjs", "capability-state.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 31267ab39..9d5f92c49 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 (103 shipped) +## CLI Modules (104 shipped) Full listing: `gsd-core/bin/lib/*.cjs`. @@ -380,6 +380,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `adr-parser.cjs` | ADR decision parser for plan-phase ingest express path; normalizes section synonyms, parses status/decision/scope fences, and enforces status rejection gates | | `agent-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools agent` | | `artifacts.cjs` | Canonical artifact registry — known `.planning/` root file names; used by `gsd-health` W019 lint | +| `audit-command-router.cjs` | ADR-959 capability command router for `gsd-tools audit-uat` and `gsd-tools audit-open` — extracted from hardcoded cases in `gsd-tools.cjs`; dispatches to `uat.cjs:cmdAuditUat` and `audit.cjs:{auditOpenArtifacts,formatAuditReport}`; phase 4d-impl-3 | | `audit.cjs` | Audit dispatch, audit open sessions, audit storage helpers | | `capability-registry.cjs` | Generated central Capability Registry — role-partitioned index of all co-located capability declarations (`capabilities//capability.json`); emitted by `scripts/gen-capability-registry.cjs --write` (ADR-894 §5) | | `capability-state.cjs` | Unified capability-state resolver (ADR-857 phase 4b) — composes install profile, runtime surface, and config activation into one per-capability view; exports pure `resolveCapabilityState` + I/O handler `cmdCapabilityState`; command surface: `gsd-tools capability state [--config-dir ]` emitting `{ runtimeConfigDir, capabilities[] }` | diff --git a/eslint.config.mjs b/eslint.config.mjs index a43766e9d..092f6be76 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -85,6 +85,7 @@ export default tseslint.config( '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/audit-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 084bc465a..7f9337e26 100755 --- a/gsd-core/bin/gsd-tools.cjs +++ b/gsd-core/bin/gsd-tools.cjs @@ -1180,27 +1180,6 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand break; } - case 'audit-uat': { - const uat = require('./lib/uat.cjs'); - uat.cmdAuditUat(cwd, raw); - break; - } - - case 'audit-open': { - const { auditOpenArtifacts, formatAuditReport } = require('./lib/audit.cjs'); - const wantJson = args.includes('--json'); - const result = auditOpenArtifacts(cwd); - if (wantJson) { - // core.output JSON-stringifies its first arg; pass the object directly. - core.output(result, raw); - } else { - // Human-readable report must bypass JSON encoding — use the rawValue - // form (third arg) which core.output emits verbatim. - core.output(null, true, formatAuditReport(result)); - } - break; - } - case 'uat': { const subcommand = args[1]; const uat = require('./lib/uat.cjs'); diff --git a/gsd-core/bin/lib/capability-registry.cjs b/gsd-core/bin/lib/capability-registry.cjs index 8e33e62fb..f64034844 100644 --- a/gsd-core/bin/lib/capability-registry.cjs +++ b/gsd-core/bin/lib/capability-registry.cjs @@ -7,6 +7,33 @@ */ const capabilities = { + "audit": { + "id": "audit", + "role": "feature", + "title": "Audit", + "description": "Open-artifact audit and UAT-gap audit for milestone close gates; exposes `gsd-tools audit-uat` (cross-phase UAT outstanding items) and `gsd-tools audit-open` (structured open-artifact scan across debug, tasks, threads, todos, seeds, UAT, verification, context-questions).", + "tier": "full", + "requires": [], + "skills": [], + "agents": [], + "config": {}, + "commands": [ + { + "family": "audit-uat", + "module": "audit-command-router.cjs", + "router": "routeAuditUat" + }, + { + "family": "audit-open", + "module": "audit-command-router.cjs", + "router": "routeAuditOpen" + } + ], + "hooks": [], + "steps": [], + "contributions": [], + "gates": [] + }, "graphify": { "id": "graphify", "role": "feature", @@ -269,6 +296,16 @@ const configSchema = { const runtimes = {}; const commandFamilies = { + "audit-open": { + "capId": "audit", + "module": "audit-command-router.cjs", + "router": "routeAuditOpen" + }, + "audit-uat": { + "capId": "audit", + "module": "audit-command-router.cjs", + "router": "routeAuditUat" + }, "graphify": { "capId": "graphify", "module": "graphify-command-router.cjs", @@ -302,6 +339,7 @@ const profileMembership = { }; const _requiresGraph = { + "audit": [], "graphify": [], "ui": [] }; diff --git a/scripts/lint-test-file-count.allowlist.json b/scripts/lint-test-file-count.allowlist.json index 9aae0f91e..85c353086 100644 --- a/scripts/lint-test-file-count.allowlist.json +++ b/scripts/lint-test-file-count.allowlist.json @@ -3,6 +3,7 @@ "modules": { "audit": { "files": [ + "audit-command-cutover.test.cjs", "audit-fix-command.test.cjs", "bug-2659-audit-open-crash.test.cjs", "bug-2836-audit-open-summary-uat-drift.test.cjs", diff --git a/src/audit-command-router.cts b/src/audit-command-router.cts new file mode 100644 index 000000000..fa40c3862 --- /dev/null +++ b/src/audit-command-router.cts @@ -0,0 +1,101 @@ +'use strict'; +/** + * Audit command routers — CLI dispatchers for `gsd-tools audit-uat` and + * `gsd-tools audit-open`. + * + * ADR-959 (phase 4d-impl-3): audit command family cutover. + * Extracted from the hardcoded `case 'audit-uat':` and `case 'audit-open':` + * arms in gsd-tools.cjs. Behaviour is preserved byte-for-behaviour from the + * prior inline cases; the dispatch path now flows: + * default → dispatchCapabilityCommand → + * require(audit-command-router.cjs) → routeAuditUat | routeAuditOpen. + * + * Router signatures: { args, cwd, raw, error } — identical to the existing + * host routers. No new handler/arg convention; the capability registry + * discovers these routers by name. + * + * Test seam: pass `_uat` / `_audit` / `_core` in the options object to inject + * recording mocks instead of the real modules. The `_`-prefix follows the + * repo's established seam convention (see graphify-command-router.cts). + * Production callers omit them. + * + * Lazy requires: uat.cjs and audit.cjs are required INSIDE each route function + * so the unneeded module is never loaded (preserves equivalence with the old + * inline case arms which each required only their own module). + */ + +// eslint-disable-next-line @typescript-eslint/no-require-imports +import core = require('./core.cjs'); + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface UatModule { + cmdAuditUat(cwd: string, raw: boolean): void; +} + +interface AuditModule { + auditOpenArtifacts(cwd: string): unknown; + formatAuditReport(result: unknown): string; +} + +interface CoreModule { + output(value: unknown, raw: boolean, rawValue?: string): void; +} + +interface RouteAuditUatOptions { + args: string[]; + cwd: string; + raw: boolean; + error: (message: string, reason?: string) => void; + /** Test seam: inject a mock uat module. Defaults to the real module. */ + _uat?: UatModule; +} + +interface RouteAuditOpenOptions { + args: string[]; + cwd: string; + raw: boolean; + error: (message: string, reason?: string) => void; + /** Test seam: inject a mock audit module. Defaults to the real module. */ + _audit?: AuditModule; + /** Test seam: inject a mock core module to capture output calls. Defaults to the real module. */ + _core?: CoreModule; +} + +// ─── routeAuditUat ──────────────────────────────────────────────────────────── + +function routeAuditUat({ args, cwd, raw, error, _uat }: RouteAuditUatOptions): void { + // Suppress unused-variable warnings for args/error — this command has no + // subcommands and passes raw through directly to the uat module. + void args; + void error; + // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment + const u: UatModule = _uat ?? require('./uat.cjs'); + u.cmdAuditUat(cwd, raw); +} + +// ─── routeAuditOpen ────────────────────────────────────────────────────────── + +function routeAuditOpen({ args, cwd, raw, error, _audit, _core }: RouteAuditOpenOptions): void { + // Suppress unused-variable warning for error — audit-open has no subcommand + // dispatch that would call error(); only flag parsing occurs here. + void error; + // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment + const a: AuditModule = _audit ?? require('./audit.cjs'); + const c: CoreModule = _core ?? core; + const wantJson = args.includes('--json'); + const result = a.auditOpenArtifacts(cwd); + if (wantJson) { + // core.output JSON-stringifies its first arg; pass the object directly. + c.output(result, raw); + } else { + // Human-readable report must bypass JSON encoding — use the rawValue + // form (third arg) which core.output emits verbatim. + c.output(null, true, a.formatAuditReport(result)); + } +} + +export = { + routeAuditUat, + routeAuditOpen, +}; diff --git a/tests/audit-command-cutover.test.cjs b/tests/audit-command-cutover.test.cjs new file mode 100644 index 000000000..d7c750f3d --- /dev/null +++ b/tests/audit-command-cutover.test.cjs @@ -0,0 +1,381 @@ +'use strict'; +/** + * audit-command-cutover.test.cjs — ADR-959 phase 4d-impl-3 equivalence tests. + * + * Verifies that `audit-uat` and `audit-open`, after cutover from the hardcoded + * `case 'audit-uat':` and `case 'audit-open':` arms in gsd-tools.cjs to the + * capability registry dispatch path (default → dispatchCapabilityCommand → + * audit-command-router.cjs → routeAuditUat | routeAuditOpen), behave + * identically to the old inline cases. + * + * Test categories: + * 1. UNIT (recording mock) — precise arg/call equivalence for each router + * 2. DISPATCH — commands reach routers via default-case registry dispatch + * 3. BEHAVIOR — subprocess tests with real output-shape assertions + * 4. JSON-ERRORS — structured {ok:false,reason,message} for error paths + * 5. REGISTRY — commandFamilies entries, audit capability in registry + */ + +const { describe, test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); + +const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); + +const registry = require('../gsd-core/bin/lib/capability-registry.cjs'); +const { routeAuditUat, routeAuditOpen } = require('../gsd-core/bin/lib/audit-command-router.cjs'); + +// ─── helpers ───────────────────────────────────────────────────────────────── + +function makeErrorRecorder() { + const calls = []; + const fn = (msg, reason) => calls.push({ msg, reason }); + fn.calls = calls; + return fn; +} + +// ─── 1. UNIT — recording mocks (precise routing equivalence) ───────────────── + +describe('audit routers: unit tests via recording mocks', () => { + const CWD = '/fake/cwd'; + const RAW = false; + + // ── routeAuditUat ────────────────────────────────────────────────────────── + + test('routeAuditUat: calls _uat.cmdAuditUat(cwd, raw) exactly once', () => { + const uatCalls = []; + const mockUat = { + cmdAuditUat: (cwd, raw) => uatCalls.push({ cwd, raw }), + }; + const errFn = makeErrorRecorder(); + + routeAuditUat({ + args: ['audit-uat'], + cwd: CWD, raw: RAW, error: errFn, + _uat: mockUat, + }); + + assert.strictEqual(errFn.calls.length, 0, 'error must not be called'); + assert.strictEqual(uatCalls.length, 1, 'cmdAuditUat must be called exactly once'); + assert.strictEqual(uatCalls[0].cwd, CWD, 'cwd passed through correctly'); + assert.strictEqual(uatCalls[0].raw, RAW, 'raw passed through correctly'); + }); + + test('routeAuditUat: raw=true is forwarded correctly', () => { + const uatCalls = []; + const mockUat = { + cmdAuditUat: (cwd, raw) => uatCalls.push({ cwd, raw }), + }; + routeAuditUat({ + args: ['audit-uat'], + cwd: CWD, raw: true, error: makeErrorRecorder(), + _uat: mockUat, + }); + assert.strictEqual(uatCalls[0].raw, true, 'raw=true must be forwarded'); + }); + + // ── routeAuditOpen ───────────────────────────────────────────────────────── + + test('routeAuditOpen (no --json): calls auditOpenArtifacts, formatAuditReport; output(null, true, report)', () => { + const auditCalls = []; + const coreCalls = []; + const FAKE_RESULT = { fake: true }; + const FAKE_REPORT = 'REPORT TEXT'; + const mockAudit = { + auditOpenArtifacts: (cwd) => { auditCalls.push({ fn: 'auditOpenArtifacts', cwd }); return FAKE_RESULT; }, + formatAuditReport: (res) => { auditCalls.push({ fn: 'formatAuditReport', res }); return FAKE_REPORT; }, + }; + // Inject a recording _core stub so no bytes reach the real process stdout. + const mockCore = { + output: (...callArgs) => coreCalls.push(callArgs), + }; + routeAuditOpen({ + args: ['audit-open'], + cwd: CWD, raw: RAW, error: makeErrorRecorder(), + _audit: mockAudit, + _core: mockCore, + }); + // auditOpenArtifacts called first, then formatAuditReport with its result + assert.strictEqual(auditCalls.length, 2, 'must call auditOpenArtifacts then formatAuditReport'); + assert.strictEqual(auditCalls[0].fn, 'auditOpenArtifacts', 'first call must be auditOpenArtifacts'); + assert.strictEqual(auditCalls[0].cwd, CWD, 'auditOpenArtifacts cwd must match'); + assert.strictEqual(auditCalls[1].fn, 'formatAuditReport', 'second call must be formatAuditReport'); + assert.strictEqual(auditCalls[1].res, FAKE_RESULT, 'formatAuditReport must receive auditOpenArtifacts result'); + // Assert the exact 3-arg core.output call form for text mode: + // core.output(null, true, formatAuditReport(result)) + assert.strictEqual(coreCalls.length, 1, 'core.output must be called exactly once'); + assert.strictEqual(coreCalls[0][0], null, 'text mode: first arg to core.output must be null'); + assert.strictEqual(coreCalls[0][1], true, 'text mode: second arg to core.output must be true'); + assert.strictEqual(coreCalls[0][2], FAKE_REPORT, 'text mode: third arg to core.output must be the formatted report'); + }); + + test('routeAuditOpen (--json): calls auditOpenArtifacts but NOT formatAuditReport; output(result, raw)', () => { + const auditCalls = []; + const coreCalls = []; + const FAKE_RESULT = { fake: true }; + const mockAudit = { + auditOpenArtifacts: (cwd) => { auditCalls.push({ fn: 'auditOpenArtifacts', cwd }); return FAKE_RESULT; }, + formatAuditReport: (res) => { auditCalls.push({ fn: 'formatAuditReport', res }); return 'REPORT'; }, + }; + // Inject a recording _core stub so no bytes reach the real process stdout. + const mockCore = { + output: (...callArgs) => coreCalls.push(callArgs), + }; + routeAuditOpen({ + args: ['audit-open', '--json'], + cwd: CWD, raw: RAW, error: makeErrorRecorder(), + _audit: mockAudit, + _core: mockCore, + }); + // auditOpenArtifacts called; formatAuditReport must NOT be called for --json + const fmtCalls = auditCalls.filter(c => c.fn === 'formatAuditReport'); + assert.strictEqual(fmtCalls.length, 0, '--json mode must NOT call formatAuditReport'); + const artifactCalls = auditCalls.filter(c => c.fn === 'auditOpenArtifacts'); + assert.strictEqual(artifactCalls.length, 1, '--json mode must call auditOpenArtifacts once'); + // Assert the exact 2-arg core.output call form for JSON mode: + // core.output(result, raw) + assert.strictEqual(coreCalls.length, 1, 'core.output must be called exactly once'); + assert.strictEqual(coreCalls[0][0], FAKE_RESULT, 'json mode: first arg to core.output must be the result object'); + assert.strictEqual(coreCalls[0][1], RAW, 'json mode: second arg to core.output must be raw'); + assert.strictEqual(coreCalls[0].length, 2, 'json mode: core.output must be called with exactly 2 args'); + }); +}); + +// ─── 2. DISPATCH — commands reach routers via default-case ─────────────────── + +describe('audit cutover: dispatch path (default-case → capability registry)', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject('gsd-audit-cutover-'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('audit-uat dispatches via capability registry (no "Unknown command" error)', () => { + const result = runGsdTools(['audit-uat'], tmpDir); + // audit-uat with a minimal project may succeed or fail on file-not-found; + // the key assertion is it never emits "Unknown command: audit-uat" + const isUnknownCmd = (result.error || '').includes('Unknown command: audit-uat'); + assert.strictEqual(isUnknownCmd, false, + `Must not emit "Unknown command: audit-uat". stderr: ${result.error}`); + assert.ok(result.success, + `audit-uat must exit 0. stderr: ${result.error}`); + }); + + test('audit-open dispatches via capability registry (no "Unknown command" error)', () => { + const result = runGsdTools(['audit-open'], tmpDir); + const isUnknownCmd = (result.error || '').includes('Unknown command: audit-open'); + assert.strictEqual(isUnknownCmd, false, + `Must not emit "Unknown command: audit-open". stderr: ${result.error}`); + assert.ok(result.success, + `audit-open must exit 0. stderr: ${result.error}`); + }); + + test('audit-open --json dispatches via capability registry', () => { + const result = runGsdTools(['audit-open', '--json'], tmpDir); + const isUnknownCmd = (result.error || '').includes('Unknown command: audit-open'); + assert.strictEqual(isUnknownCmd, false, + `Must not emit "Unknown command: audit-open" with --json. stderr: ${result.error}`); + // Must also produce valid JSON output + assert.ok(result.success, + `audit-open --json must succeed. stderr: ${result.error}`); + assert.doesNotThrow( + () => JSON.parse(result.output), + 'audit-open --json must produce valid JSON', + ); + }); +}); + +// ─── 3. BEHAVIOR — subprocess output shape (equivalence to old inline cases) ── + +describe('audit cutover: output shape equivalence', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject('gsd-audit-behavior-'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('audit-open (text) succeeds and produces non-empty output', () => { + const result = runGsdTools(['audit-open'], tmpDir); + assert.ok(result.success, + `audit-open must succeed. stderr: ${result.error}`); + assert.ok(result.output && result.output.length > 0, + 'audit-open text output must be non-empty'); + // Must be raw text, not JSON-encoded (regression guard from #2911) + assert.ok(!result.output.startsWith('"'), + 'text mode must not start with a JSON quote'); + assert.ok(!result.output.includes('\\n'), + 'text mode must not contain literal \\n sequences'); + }); + + test('audit-open --json produces valid JSON with expected shape', () => { + const result = runGsdTools(['audit-open', '--json'], tmpDir); + assert.ok(result.success, + `audit-open --json must succeed. stderr: ${result.error}`); + let parsed; + assert.doesNotThrow( + () => { parsed = JSON.parse(result.output); }, + 'audit-open --json must emit valid JSON', + ); + assert.equal(typeof parsed, 'object', 'parsed payload must be an object'); + assert.ok(parsed !== null, 'parsed payload must not be null'); + // Shape contract from auditOpenArtifacts() (regression guard from #2911) + assert.equal(typeof parsed.scanned_at, 'string', 'must include scanned_at'); + assert.equal(typeof parsed.has_open_items, 'boolean', 'must include has_open_items'); + assert.equal(typeof parsed.counts, 'object', 'must include counts'); + assert.equal(typeof parsed.items, 'object', 'must include items'); + }); + + test('audit-open (text) report title present as standalone line', () => { + const result = runGsdTools(['audit-open'], tmpDir); + assert.ok(result.success, + `audit-open must succeed. stderr: ${result.error}`); + const lines = result.output.split('\n').map(l => l.trim()).filter(Boolean); + assert.ok( + lines.includes('Milestone Close: Open Artifact Audit'), + `report title must appear as a standalone line; got: ${JSON.stringify(lines.slice(0, 5))}`, + ); + }); + + test('audit-uat succeeds and produces non-empty stdout', () => { + const result = runGsdTools(['audit-uat'], tmpDir); + assert.ok(result.success, + `audit-uat must succeed. stderr: ${result.error}`); + assert.ok(result.output && result.output.length > 0, + 'audit-uat must write non-empty output to stdout'); + }); + + test('audit-uat --raw flag passes through (does not break dispatch)', () => { + const result = runGsdTools(['audit-uat', '--raw'], tmpDir); + // --raw is a gsd-tools global flag; it modifies output encoding but + // the command must still succeed and produce output + assert.ok(result.success, + `audit-uat --raw must succeed. stderr: ${result.error}`); + }); +}); + +// ─── 4. JSON-ERRORS — GSD_JSON_ERRORS mode passes through cleanly ──────────── + +describe('audit cutover: GSD_JSON_ERRORS mode (both commands succeed without structured error)', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject('gsd-audit-jsonerr-'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('audit-open --json with GSD_JSON_ERRORS=1 succeeds (no spurious error payload)', () => { + // Successful commands must not emit JSON error payloads; verify exit 0. + const result = runGsdTools(['audit-open', '--json'], tmpDir, { GSD_JSON_ERRORS: '1' }); + assert.ok(result.success, + `audit-open --json must succeed even with GSD_JSON_ERRORS=1; stderr: ${result.error}`); + }); + + test('audit-open text with GSD_JSON_ERRORS=1 succeeds (no spurious error payload)', () => { + const result = runGsdTools(['audit-open'], tmpDir, { GSD_JSON_ERRORS: '1' }); + assert.ok(result.success, + `audit-open text mode must succeed even with GSD_JSON_ERRORS=1; stderr: ${result.error}`); + }); + + test('audit-uat with GSD_JSON_ERRORS=1 succeeds (no spurious error payload)', () => { + const result = runGsdTools(['audit-uat'], tmpDir, { GSD_JSON_ERRORS: '1' }); + assert.ok(result.success, + `audit-uat must succeed even with GSD_JSON_ERRORS=1; stderr: ${result.error}`); + }); +}); + +// ─── 5. REGISTRY — commandFamilies entries ─────────────────────────────────── + +describe('audit cutover: registry entries correct', () => { + test('commandFamilies["audit-uat"] present and well-shaped', () => { + const entry = registry.commandFamilies['audit-uat']; + assert.ok(entry, 'commandFamilies["audit-uat"] must be present'); + assert.strictEqual(entry.capId, 'audit', + 'commandFamilies["audit-uat"].capId must be "audit"'); + assert.strictEqual(entry.module, 'audit-command-router.cjs', + 'commandFamilies["audit-uat"].module must be "audit-command-router.cjs"'); + assert.strictEqual(entry.router, 'routeAuditUat', + 'commandFamilies["audit-uat"].router must be "routeAuditUat"'); + }); + + test('commandFamilies["audit-open"] present and well-shaped', () => { + const entry = registry.commandFamilies['audit-open']; + assert.ok(entry, 'commandFamilies["audit-open"] must be present'); + assert.strictEqual(entry.capId, 'audit', + 'commandFamilies["audit-open"].capId must be "audit"'); + assert.strictEqual(entry.module, 'audit-command-router.cjs', + 'commandFamilies["audit-open"].module must be "audit-command-router.cjs"'); + assert.strictEqual(entry.router, 'routeAuditOpen', + 'commandFamilies["audit-open"].router must be "routeAuditOpen"'); + }); + + test('capabilities.audit present with role:feature and tier:full', () => { + const cap = registry.capabilities.audit; + assert.ok(cap, 'capabilities.audit must be present'); + assert.strictEqual(cap.role, 'feature', 'audit capability must have role: feature'); + assert.strictEqual(cap.tier, 'full', 'audit capability must have tier: full'); + }); + + test('capabilities.audit.commands has both audit-uat and audit-open entries', () => { + const cap = registry.capabilities.audit; + assert.ok(Array.isArray(cap.commands) && cap.commands.length === 2, + 'audit capability must have exactly 2 commands'); + + const uatCmd = cap.commands.find(c => c.family === 'audit-uat'); + assert.ok(uatCmd, 'commands must include audit-uat family'); + assert.strictEqual(uatCmd.module, 'audit-command-router.cjs'); + assert.strictEqual(uatCmd.router, 'routeAuditUat'); + + const openCmd = cap.commands.find(c => c.family === 'audit-open'); + assert.ok(openCmd, 'commands must include audit-open family'); + assert.strictEqual(openCmd.module, 'audit-command-router.cjs'); + assert.strictEqual(openCmd.router, 'routeAuditOpen'); + }); + + test('routeAuditUat and routeAuditOpen are exported functions', () => { + assert.strictEqual(typeof routeAuditUat, 'function', + 'routeAuditUat must be an exported function'); + assert.strictEqual(typeof routeAuditOpen, 'function', + 'routeAuditOpen must be an exported function'); + }); + + test('profileMembership.audit is vacuous (no skills → no skill-cluster entry)', () => { + // audit declares skills:[] → no skill-cluster-based profileMembership entry. + // This is correct: profileMembership tracks skill ownership, not capability existence. + const pm = registry.profileMembership.audit; + assert.strictEqual(pm, undefined, + 'profileMembership.audit must be undefined (no skills declared)'); + }); + + test('capabilityClusters.audit is vacuous (no skills → no cluster entry)', () => { + // Same as profileMembership — skill-less capabilities produce no cluster entries. + const clusters = registry.capabilityClusters.audit; + assert.strictEqual(clusters, undefined, + 'capabilityClusters.audit must be undefined (no skills declared)'); + }); + + test('audit has no skills — vacuous install/surface (no skill-index entries)', () => { + // audit capability declares no skills, so bySkill has no "audit" entry + // (there is no skill named "audit") + const cap = registry.capabilities.audit; + assert.deepStrictEqual(cap.skills, [], + 'audit capability must have empty skills array'); + }); + + test('graphify commandFamilies entry still present (no regression)', () => { + const entry = registry.commandFamilies['graphify']; + assert.ok(entry, 'commandFamilies["graphify"] must still be present'); + assert.strictEqual(entry.capId, 'graphify'); + assert.strictEqual(entry.module, 'graphify-command-router.cjs'); + assert.strictEqual(entry.router, 'routeGraphifyCommand'); + }); +});