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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
27
capabilities/audit/capability.json
Normal file
27
capabilities/audit/capability.json
Normal 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": []
|
||||
}
|
||||
@@ -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 |
|
||||
|
||||
|
||||
---
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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[] }` |
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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": []
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
101
src/audit-command-router.cts
Normal file
101
src/audit-command-router.cts
Normal 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,
|
||||
};
|
||||
381
tests/audit-command-cutover.test.cjs
Normal file
381
tests/audit-command-cutover.test.cjs
Normal 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user