feat(#981): audit-uat + audit-open command cutover — commands-only capability (ADR-857 phase 4d-impl-3) (#984)

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 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-10 08:34:49 -04:00
committed by GitHub
parent 77bfd943dd
commit 9a03539c2d
12 changed files with 556 additions and 24 deletions

1
.gitignore vendored
View File

@@ -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

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). **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.

View File

@@ -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": []
}

View File

@@ -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 <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 |
| `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 |
---

View File

@@ -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",

View File

@@ -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/<id>/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 <path>]` emitting `{ runtimeConfigDir, capabilities[] }` |

View File

@@ -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',

View File

@@ -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');

View File

@@ -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": []
};

View File

@@ -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",

View File

@@ -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,
};

View File

@@ -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');
});
});