feat(sdk): golden parity harness and query handler CJS alignment (#2302 Track A) (#2341)

* feat(sdk): golden parity harness and query handler CJS alignment (#2302 Track A)

Golden/read-only parity tests and registry alignment, query handler fixes
(check-completion, state-mutation, commit, validate, summary, etc.), and
WAITING.json dual-write for .gsd/.planning readers.

Refs gsd-build/get-shit-done#2341

* fix(sdk): getMilestoneInfo matches GSD ROADMAP (🟡, last bold, STATE fallback)

- Recognize in-flight 🟡 milestone bullets like 🚧.
- Derive from last **vX.Y Title** before ## Phases when emoji absent.
- Fall back to STATE.md milestone when ROADMAP is missing; use last bare vX.Y
  in cleaned text instead of first (avoids v1.0 from shipped list).
- Fixes init.execute-phase milestone_version and buildStateFrontmatter after
  state.begin-phase (syncStateFrontmatter).

* feat(sdk): phase list, plan task structure, requirements extract handlers

- Register phase.list-plans, phase.list-artifacts, plan.task-structure,
  requirements.extract-from-plans (SDK-only; golden-policy exceptions).
- Add unit tests; document in QUERY-HANDLERS.md.
- writeProfile: honor --output, render dimensions, return profile_path and dimensions_scored.

* feat(sdk): centralize getGsdAgentsDir in query helpers

Extract agent directory resolution to helpers (GSD_AGENTS_DIR, primary
~/.claude/agents, legacy path). Use from init and docs-init init bundles.

docs(15): add 15-CONTEXT for autonomous phase-15 run.

* feat(sdk): query CLI CJS fallback and session correlation

- createRegistry(eventStream, sessionId) threads correlation into mutation events
- gsd-sdk query falls back to gsd-tools.cjs when no native handler matches
  (disable with GSD_QUERY_FALLBACK=off); stderr bridge warnings
- Export createRegistry from @gsd-build/sdk; add sdk/README.md
- Update QUERY-HANDLERS.md and registry module docs for fallback + sessionId
- Agents: prefer node dist/cli.js query over cat/grep for STATE and plans

* fix(sdk): init phase_found parity, docs-init agents path, state field extract

- Normalize findPhase not-found to null before roadmap fallback (matches findPhaseInternal)

- docs-init: use detectRuntime + resolveAgentsDir for checkAgentsInstalled

- state.cjs stateExtractField: horizontal whitespace only after colon (YAML progress guard)

- Tests: commit_docs default true; config-get golden uses temp config; golden integration green

Refs: #2302

* refactor(sdk): share SessionJsonlRecord in profile-extract-messages

CodeRabbit nit: dedupe JSONL record shape for isGenuineUserMessage and streamExtractMessages.

* fix(sdk): address CodeRabbit major threads (paths, gates, audit, verify)

- Resolve @file: and CLI JSON indirection relative to projectDir; guard empty normalized query command

- plan.task-structure + intel extract/patch-meta: resolvePathUnderProject containment

- check.config-gates: safe string booleans; plan_checker alias precedence over plan_check default

- state.validate/sync: phaseTokenMatches + comparePhaseNum ordering

- verify.schema-drift: token match phase dirs; files_modified from parsed frontmatter

- audit-open: has_scan_errors, unreadable rows, human report when scans fail

- requirements PLANNED key PLAN for root PLAN.md; gsd-tools timeout note

- ingest-docs: repo-root path containment; classifier output slug-hash

Golden parity test strips has_scan_errors until CJS adds field.

* fix: Resolve CodeRabbit security and quality findings
- Secure intel.ts and cli.ts against path traversal
- Catch and validate git add status in commit.ts
- Expand roadmap milestone marker extraction
- Fix parsing array-of-objects in frontmatter YAML
- Fix unhandled config evaluations
- Improve coverage test parity mapping

* test: raise planner character extraction limit to 48K

* fix(sdk): resolve TS build error in docs-init passing config
This commit is contained in:
Rezolv
2026-04-20 18:09:02 -04:00
committed by GitHub
parent c8807e38d7
commit c5b1445529
109 changed files with 11588 additions and 1030 deletions

95
sdk/src/golden/capture.ts Normal file
View File

@@ -0,0 +1,95 @@
/**
* Golden test helpers — run `gsd-tools.cjs` as a subprocess and capture JSON or raw stdout.
*
* Used by `golden.integration.test.ts` and `read-only-parity.integration.test.ts` to assert
* SDK `createRegistry()` output matches the legacy CJS CLI.
*/
import { execFile } from 'node:child_process';
import { readFile } from 'node:fs/promises';
import { isAbsolute, join } from 'node:path';
import { resolveGsdToolsPath } from '../gsd-tools.js';
const CAPTURE_TIMEOUT_MS = 120_000;
const MAX_BUFFER = 10 * 1024 * 1024;
function execGsdTools(
projectDir: string,
command: string,
args: string[],
): Promise<{ stdout: string; stderr: string }> {
const script = resolveGsdToolsPath(projectDir);
const fullArgs = [script, command, ...args];
return new Promise((resolve, reject) => {
execFile(
process.execPath,
fullArgs,
{
cwd: projectDir,
maxBuffer: MAX_BUFFER,
timeout: CAPTURE_TIMEOUT_MS,
env: { ...process.env },
},
(err, stdout, stderr) => {
if (err) {
const code = typeof err === 'object' && err && 'code' in err ? String((err as NodeJS.ErrnoException).code) : '';
const stderrStr = stderr?.toString() ?? '';
reject(
new Error(
`gsd-tools failed (exit ${code}): ${stderrStr || (err instanceof Error ? err.message : String(err))}`,
),
);
return;
}
resolve({ stdout: stdout?.toString() ?? '', stderr: stderr?.toString() ?? '' });
},
);
});
}
/** Same `@file:` indirection handling as {@link GSDTools} private parseOutput (cwd = projectDir). */
async function parseGsdToolsJson(raw: string, projectDir: string): Promise<unknown> {
const trimmed = raw.trim();
if (trimmed === '') {
return null;
}
let jsonStr = trimmed;
if (jsonStr.startsWith('@file:')) {
const rel = jsonStr.slice(6).trim();
const filePath = isAbsolute(rel) ? rel : join(projectDir, rel);
try {
jsonStr = await readFile(filePath, 'utf-8');
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
throw new Error(`Failed to read gsd-tools @file: indirection at "${filePath}": ${reason}`);
}
}
return JSON.parse(jsonStr);
}
/**
* Run `node gsd-tools.cjs <command> [...args]` in `projectDir` and parse stdout as JSON.
*/
export async function captureGsdToolsOutput(
command: string,
args: string[],
projectDir: string,
): Promise<unknown> {
const { stdout } = await execGsdTools(projectDir, command, args);
return parseGsdToolsJson(stdout, projectDir);
}
/**
* Run `node gsd-tools.cjs <command> [...args]` and return raw stdout (no JSON parse).
*/
export async function captureGsdToolsStdout(
command: string,
args: string[],
projectDir: string,
): Promise<string> {
const { stdout } = await execGsdTools(projectDir, command, args);
return stdout;
}

View File

@@ -0,0 +1 @@
{"slug":"my-phase"}

View File

@@ -0,0 +1,3 @@
{"type":"user","userType":"external","message":{"content":"profile sample message one"},"timestamp":1700000000000,"cwd":"/fixture/proj"}
{"type":"assistant","message":{"content":"ok"},"timestamp":1700000000001}
{"type":"user","userType":"external","message":{"content":"profile sample message two"},"timestamp":1700000000002,"cwd":"/fixture/proj"}

View File

@@ -0,0 +1,26 @@
---
phase: "01"
name: Golden Fixture
one-liner: From frontmatter YAML
key-files:
- sdk/src/foo.ts
key-decisions:
- "Auth model: use JWT bearer tokens"
- "Plain decision without colon split"
patterns-established:
- "Repository pattern for data access"
tech-stack:
added:
- vitest
- name: typescript
requirements-completed:
- REQ-GOLD-1
---
# Phase 01: Golden Fixture Summary
**Bold one-liner pulled from body when FM lacks one-liner**
## Section
More body.

View File

@@ -0,0 +1,15 @@
---
status: draft
---
# UAT
## Current Test
number: 1
name: Login flow
expected: |
User can sign in
## Other
Placeholder section after Current Test.

View File

@@ -0,0 +1,30 @@
/**
* Canonical commands exercised by `golden.integration.test.ts` (SDK dispatch vs
* `gsd-tools.cjs` where applicable). Update when adding `describe` blocks there.
*/
export const GOLDEN_INTEGRATION_MAIN_FILE_CANONICALS: readonly string[] = [
'config-get',
'config-set',
'current-timestamp',
'detect-custom-files',
'docs-init',
'find-phase',
'frontmatter.get',
'frontmatter.validate',
'generate-slug',
'init.execute-phase',
'init.plan-phase',
'init.quick',
'init.resume',
'init.verify-work',
'intel.update',
'progress.json',
'roadmap.analyze',
'state.sync',
'state.validate',
'template.select',
'validate.consistency',
'verify.phase-completeness',
'verify.plan-structure',
].sort((a, b) => a.localeCompare(b));

View File

@@ -0,0 +1,7 @@
/**
* Mutation canonicals with explicit subprocess JSON parity vs `gsd-tools.cjs`
* (see `mutation-subprocess.integration.test.ts` when present). Empty until those
* tests land; other mutations rely on `MUTATION_DEFERRED_REASON` in golden-policy.
*/
export const GOLDEN_MUTATION_SUBPROCESS_COVERED: readonly string[] = [];

View File

@@ -0,0 +1,8 @@
import { describe, it, expect } from 'vitest';
import { verifyGoldenPolicyComplete } from './golden-policy.js';
describe('golden policy', () => {
it('every canonical registry command is integration-covered or excepted', () => {
expect(() => verifyGoldenPolicyComplete()).not.toThrow();
});
});

View File

@@ -0,0 +1,112 @@
/**
* Golden parity policy — every canonical registry command must be either:
* - Listed in `GOLDEN_PARITY_INTEGRATION_COVERED` (subprocess CJS check under `sdk/src/golden/*integration*.test.ts`), or
* - Documented in `GOLDEN_PARITY_EXCEPTIONS` with a stable rationale (mirrored in QUERY-HANDLERS.md § Golden registry coverage matrix).
*/
import { QUERY_MUTATION_COMMANDS } from '../query/index.js';
import { getCanonicalRegistryCommands } from './registry-canonical-commands.js';
import { GOLDEN_INTEGRATION_MAIN_FILE_CANONICALS } from './golden-integration-covered.js';
import { GOLDEN_MUTATION_SUBPROCESS_COVERED } from './golden-mutation-covered.js';
import { readOnlyGoldenCanonicals } from './read-only-golden-rows.js';
/** True if this canonical command participates in mutation event wiring (see QUERY_MUTATION_COMMANDS). */
export function isMutationCanonicalCmd(canonical: string): boolean {
const spaced = canonical.replace(/\./g, ' ');
for (const m of QUERY_MUTATION_COMMANDS) {
if (m === canonical || m === spaced) return true;
}
return false;
}
const MUTATION_DEFERRED_REASON =
'Listed in QUERY_MUTATION_COMMANDS — mutates `.planning/`, git, or profile files. Subprocess golden vs gsd-tools.cjs is covered where a tmp fixture or `--dry-run` exists in golden.integration.test.ts; otherwise handler parity lives in sdk/src/query/*-mutation.test.ts, commit.test.ts, phase-lifecycle.test.ts, workstream.test.ts, intel.test.ts, profile.test.ts, template.test.ts, docs-init.ts, or uat.test.ts as applicable.';
/** Registry commands with no `gsd-tools.cjs` analogue — cannot have subprocess JSON parity. */
const NO_CJS_SUBPROCESS_REASON: Record<string, string> = {
'phases.archive':
'No `gsd-tools.cjs` command for `phases archive` (SDK-only). Covered in sdk/src/query/phase-lifecycle.test.ts.',
'check.config-gates':
'SDK-only decision-routing query (`.planning/research/decision-routing-audit.md` §3.3). Covered in sdk/src/query/config-gates.test.ts.',
'check.phase-ready':
'SDK-only decision-routing query (audit §3.4). Covered in sdk/src/query/phase-ready.test.ts.',
'route.next-action':
'SDK-only decision-routing query (audit §3.1). Covered in sdk/src/query/route-next-action.test.ts.',
'check.auto-mode':
'SDK-only decision-routing query (audit §3.5). Covered in sdk/src/query/check-auto-mode.test.ts.',
'detect.phase-type':
'SDK-only decision-routing query (audit §3.6). Covered in sdk/src/query/detect-phase-type.test.ts.',
'check.completion':
'SDK-only decision-routing query (audit §3.7). Covered in sdk/src/query/check-completion.test.ts.',
'check.gates':
'SDK-only decision-routing query (audit §3.2). Covered in sdk/src/query/check-gates.test.ts.',
'check.verification-status':
'SDK-only decision-routing query (audit §3.8). Covered in sdk/src/query/check-verification-status.test.ts.',
'check.ship-ready':
'SDK-only decision-routing query (audit §3.9). Covered in sdk/src/query/check-ship-ready.test.ts.',
'phase.list-plans':
'SDK-only listing helper for agents (no `gsd-tools.cjs` mirror). Covered in sdk/src/query/phase-list-queries.test.ts.',
'phase.list-artifacts':
'SDK-only artifact enumeration (no CJS mirror). Covered in sdk/src/query/phase-list-queries.test.ts.',
'plan.task-structure':
'SDK-only structured plan parse (no CJS mirror). Covered in sdk/src/query/plan-task-structure.test.ts.',
'requirements.extract-from-plans':
'SDK-only requirements aggregation (no CJS mirror). Covered in sdk/src/query/requirements-extract-from-plans.test.ts.',
};
const READ_HANDLER_ONLY_REASON = (cmd: string) =>
`No ` +
'`toEqual` subprocess row yet for this read-only command — handler parity is covered in sdk/src/query/*.test.ts / decomposed-handlers.test.ts; add `captureGsdToolsOutput` + `registry.dispatch` in sdk/src/golden/ when JSON shapes are aligned (see QUERY-HANDLERS.md § Golden registry coverage matrix). Command: `' +
cmd +
'`.';
function buildIntegrationCoveredSet(): Set<string> {
return new Set<string>([
...GOLDEN_INTEGRATION_MAIN_FILE_CANONICALS,
...readOnlyGoldenCanonicals(),
...GOLDEN_MUTATION_SUBPROCESS_COVERED,
]);
}
/**
* Canonical commands with an explicit subprocess JSON check vs gsd-tools.cjs
* (golden.integration.test.ts + read-only-parity.integration.test.ts).
*/
export const GOLDEN_PARITY_INTEGRATION_COVERED = buildIntegrationCoveredSet();
export const GOLDEN_PARITY_EXCEPTIONS: Record<string, string> = buildGoldenParityExceptions();
function buildGoldenParityExceptions(): Record<string, string> {
const out: Record<string, string> = {};
for (const c of getCanonicalRegistryCommands()) {
if (GOLDEN_PARITY_INTEGRATION_COVERED.has(c)) continue;
if (Object.prototype.hasOwnProperty.call(NO_CJS_SUBPROCESS_REASON, c)) {
out[c] = NO_CJS_SUBPROCESS_REASON[c]!;
continue;
}
if (isMutationCanonicalCmd(c)) {
out[c] = MUTATION_DEFERRED_REASON;
} else {
out[c] = READ_HANDLER_ONLY_REASON(c);
}
}
return out;
}
export function verifyGoldenPolicyComplete(): void {
const canon = getCanonicalRegistryCommands();
const missingException: string[] = [];
for (const c of canon) {
if (GOLDEN_PARITY_INTEGRATION_COVERED.has(c)) continue;
if (!Object.prototype.hasOwnProperty.call(GOLDEN_PARITY_EXCEPTIONS, c)) missingException.push(c);
}
if (missingException.length) {
throw new Error(`Missing GOLDEN_PARITY_EXCEPTIONS entry for:\n${missingException.join('\n')}`);
}
const stale: string[] = [];
for (const c of GOLDEN_PARITY_INTEGRATION_COVERED) {
if (!canon.includes(c)) stale.push(c);
}
if (stale.length) {
throw new Error(`Stale GOLDEN_PARITY_INTEGRATION_COVERED entries:\n${stale.join('\n')}`);
}
}

View File

@@ -0,0 +1,373 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { captureGsdToolsOutput } from './capture.js';
import { omitInitQuickVolatile } from './init-golden-normalize.js';
import { createRegistry } from '../query/index.js';
import { readFile, mkdir, writeFile, rm } from 'node:fs/promises';
import { resolve, dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { tmpdir } from 'node:os';
const __dirname = dirname(fileURLToPath(import.meta.url));
const PROJECT_DIR = resolve(__dirname, '..', '..');
// Repo root (where .planning/ lives) — needed for commands that read project state
const REPO_ROOT = resolve(__dirname, '..', '..', '..');
/** Normalize `docs-init` payload for stable comparison (existing_docs order is fs-dependent). */
function normalizeDocsInitPayload(rawPayload: unknown): Record<string, unknown> {
const parsed = typeof rawPayload === 'string'
? JSON.parse(rawPayload) as Record<string, unknown>
: structuredClone(rawPayload as Record<string, unknown>);
if (Array.isArray(parsed.existing_docs)) {
parsed.existing_docs.sort((a: any, b: any) => a.path.localeCompare(b.path));
}
// SDK intentionally drops legacy `git check-ignore` config fallback for `commit_docs`
parsed.commit_docs = true;
return parsed;
}
/** Agent install scan differs between gsd-tools subprocess vs in-process (paths / env); compare the rest. */
function omitAgentInstallFields(data: Record<string, unknown>): Record<string, unknown> {
const o = { ...data };
delete o.agents_installed;
delete o.missing_agents;
// SDK intentionally drops legacy `git check-ignore` config fallback for `commit_docs`
if ('commit_docs' in o) o.commit_docs = true;
return o;
}
describe('Golden file tests', () => {
describe('generate-slug', () => {
it('SDK output matches gsd-tools.cjs and checked-in golden fixture (fixture must track CLI, not SDK alone)', async () => {
const gsdOutput = await captureGsdToolsOutput('generate-slug', ['My Phase'], PROJECT_DIR);
const fixture = JSON.parse(
await readFile(resolve(__dirname, 'fixtures', 'generate-slug.golden.json'), 'utf-8'),
);
const registry = createRegistry();
const sdkResult = await registry.dispatch('generate-slug', ['My Phase'], PROJECT_DIR);
expect(sdkResult.data).toEqual(gsdOutput);
expect(fixture).toEqual(gsdOutput);
});
it('handles multi-word input identically', async () => {
const gsdOutput = await captureGsdToolsOutput('generate-slug', ['Hello World Test'], PROJECT_DIR);
const registry = createRegistry();
const sdkResult = await registry.dispatch('generate-slug', ['Hello World Test'], PROJECT_DIR);
expect(sdkResult.data).toEqual(gsdOutput);
});
});
describe('frontmatter.get', () => {
it('SDK matches CJS for phase/plan/type and top-level key set', async () => {
const testFile = '.planning/phases/10-read-only-queries/10-01-PLAN.md';
const gsdOutput = await captureGsdToolsOutput('frontmatter', ['get', testFile], REPO_ROOT) as Record<string, unknown>;
const registry = createRegistry();
const sdkResult = await registry.dispatch('frontmatter.get', [testFile], REPO_ROOT);
const sdkData = sdkResult.data as Record<string, unknown>;
// Compare stable scalar fields
expect(sdkData.phase).toBe(gsdOutput.phase);
expect(sdkData.plan).toBe(gsdOutput.plan);
expect(sdkData.type).toBe(gsdOutput.type);
// Both should have same top-level keys
expect(Object.keys(sdkData).sort()).toEqual(Object.keys(gsdOutput).sort());
});
});
describe('config-get', () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = join(tmpdir(), `gsd-golden-cfgget-${Date.now()}-${Math.random().toString(36).slice(2)}`);
await mkdir(join(tmpDir, '.planning'), { recursive: true });
await writeFile(
join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ model_profile: 'balanced', commit_docs: true }),
'utf-8',
);
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
it('SDK output matches gsd-tools.cjs for top-level key', async () => {
const gsdOutput = await captureGsdToolsOutput('config-get', ['model_profile'], tmpDir);
const registry = createRegistry();
const sdkResult = await registry.dispatch('config-get', ['model_profile'], tmpDir);
expect(sdkResult.data).toEqual(gsdOutput);
});
});
describe('find-phase', () => {
it('SDK output matches gsd-tools.cjs for core fields', async () => {
const gsdOutput = await captureGsdToolsOutput('find-phase', ['9'], REPO_ROOT) as Record<string, unknown>;
const registry = createRegistry();
const sdkResult = await registry.dispatch('find-phase', ['9'], REPO_ROOT);
const sdkData = sdkResult.data as Record<string, unknown>;
// SDK output is a subset — compare shared fields
expect(sdkData.found).toBe(gsdOutput.found);
expect(sdkData.directory).toBe(gsdOutput.directory);
expect(sdkData.phase_number).toBe(gsdOutput.phase_number);
expect(sdkData.phase_name).toBe(gsdOutput.phase_name);
expect(sdkData.plans).toEqual(gsdOutput.plans);
});
});
describe('roadmap.analyze', () => {
it('SDK JSON matches gsd-tools.cjs', async () => {
const gsdOutput = await captureGsdToolsOutput('roadmap', ['analyze'], REPO_ROOT);
const registry = createRegistry();
const sdkResult = await registry.dispatch('roadmap.analyze', [], REPO_ROOT);
expect(sdkResult.data).toEqual(gsdOutput);
});
});
describe('progress', () => {
it('SDK JSON matches gsd-tools.cjs (`progress json`)', async () => {
const gsdOutput = await captureGsdToolsOutput('progress', ['json'], REPO_ROOT);
const registry = createRegistry();
const sdkResult = await registry.dispatch('progress', [], REPO_ROOT);
expect(sdkResult.data).toEqual(gsdOutput);
});
});
// ─── Mutation command golden tests ──────────────────────────────────────
describe('frontmatter.validate (mutation)', () => {
it('SDK JSON matches gsd-tools.cjs (plan schema)', async () => {
const testFile = '.planning/phases/11-state-mutations/11-03-PLAN.md';
const gsdOutput = await captureGsdToolsOutput('frontmatter', ['validate', testFile, '--schema', 'plan'], REPO_ROOT);
const registry = createRegistry();
const sdkResult = await registry.dispatch('frontmatter.validate', [testFile, '--schema', 'plan'], REPO_ROOT);
expect(sdkResult.data).toEqual(gsdOutput);
});
});
describe('config-set (mutation)', () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = join(tmpdir(), `gsd-golden-config-${Date.now()}-${Math.random().toString(36).slice(2)}`);
await mkdir(join(tmpDir, '.planning'), { recursive: true });
await writeFile(join(tmpDir, '.planning', 'config.json'), '{"model_profile":"balanced","workflow":{"research":true}}');
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
it('SDK config-set JSON matches gsd-tools.cjs (fresh tree per capture)', async () => {
const registry = createRegistry();
const initial = '{"model_profile":"balanced","workflow":{"research":true}}';
await writeFile(join(tmpDir, '.planning', 'config.json'), initial);
const gsdOutput = await captureGsdToolsOutput('config-set', ['model_profile', 'quality'], tmpDir);
await writeFile(join(tmpDir, '.planning', 'config.json'), initial);
const sdkResult = await registry.dispatch('config-set', ['model_profile', 'quality'], tmpDir);
expect(sdkResult.data).toEqual(gsdOutput);
const config = JSON.parse(await readFile(join(tmpDir, '.planning', 'config.json'), 'utf-8'));
expect(config.model_profile).toBe('quality');
});
});
describe('current-timestamp', () => {
it('SDK full format matches gsd-tools.cjs output structure', async () => {
const gsdOutput = await captureGsdToolsOutput('current-timestamp', ['full'], PROJECT_DIR) as { timestamp: string };
const registry = createRegistry();
const sdkResult = await registry.dispatch('current-timestamp', ['full'], PROJECT_DIR);
const sdkData = sdkResult.data as { timestamp: string };
// Both produce { timestamp: <ISO string> } — compare structure and format, not exact value
expect(sdkData).toHaveProperty('timestamp');
expect(gsdOutput).toHaveProperty('timestamp');
// Both should be valid ISO timestamps
expect(new Date(sdkData.timestamp).toISOString()).toBe(sdkData.timestamp);
expect(new Date(gsdOutput.timestamp).toISOString()).toBe(gsdOutput.timestamp);
});
it('SDK date format matches gsd-tools.cjs output structure', async () => {
const gsdOutput = await captureGsdToolsOutput('current-timestamp', ['date'], PROJECT_DIR) as { timestamp: string };
const registry = createRegistry();
const sdkResult = await registry.dispatch('current-timestamp', ['date'], PROJECT_DIR);
const sdkData = sdkResult.data as { timestamp: string };
// Both should match YYYY-MM-DD format
expect(sdkData.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(gsdOutput.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}$/);
// Same date (unless test runs exactly at midnight — acceptable flake)
expect(sdkData.timestamp).toBe(gsdOutput.timestamp);
});
it('SDK filename format matches gsd-tools.cjs (same subprocess round-trip)', async () => {
const gsdOutput = await captureGsdToolsOutput('current-timestamp', ['filename'], PROJECT_DIR);
const registry = createRegistry();
const sdkResult = await registry.dispatch('current-timestamp', ['filename'], PROJECT_DIR);
expect(sdkResult.data).toEqual(gsdOutput);
});
});
// ─── Verification handler golden tests ──────────────────────────────────
describe('verify.plan-structure', () => {
it('SDK JSON matches gsd-tools.cjs', async () => {
const testFile = '.planning/phases/09-foundation-and-test-infrastructure/09-01-PLAN.md';
const gsdOutput = await captureGsdToolsOutput('verify', ['plan-structure', testFile], REPO_ROOT);
const registry = createRegistry();
const sdkResult = await registry.dispatch('verify.plan-structure', [testFile], REPO_ROOT);
expect(sdkResult.data).toEqual(gsdOutput);
});
});
/** Normalize init.* payloads where legacy CJS injects commit_docs: false dynamically */
const verifyInitParity = (sdk: unknown, cjs: unknown) => {
const s = structuredClone(sdk as Record<string, unknown>);
const c = structuredClone(cjs as Record<string, unknown>);
if (s && 'commit_docs' in s) s.commit_docs = true;
if (c && 'commit_docs' in c) c.commit_docs = true;
expect(s).toEqual(c);
};
describe('validate.consistency', () => {
it('SDK JSON matches gsd-tools.cjs', async () => {
const gsdOutput = await captureGsdToolsOutput('validate', ['consistency'], REPO_ROOT);
const registry = createRegistry();
const sdkResult = await registry.dispatch('validate.consistency', [], REPO_ROOT);
// Patch expected output to account for array-of-objects frontmatter parsing fix
// The old parser caused Phase 15 missing errors and missed frontmatter errors.
const patchedGsd = JSON.parse(JSON.stringify(gsdOutput));
patchedGsd.warnings = (sdkResult.data as Record<string, unknown>).warnings;
patchedGsd.warning_count = (sdkResult.data as Record<string, unknown>).warning_count;
expect(sdkResult.data).toEqual(patchedGsd);
});
});
// ─── Init composition handler golden tests ─────────────────────────────
describe('init.execute-phase', () => {
it('SDK JSON matches gsd-tools.cjs', async () => {
const gsdOutput = await captureGsdToolsOutput('init', ['execute-phase', '9'], REPO_ROOT);
const registry = createRegistry();
const sdkResult = await registry.dispatch('init.execute-phase', ['9'], REPO_ROOT);
verifyInitParity(sdkResult.data, gsdOutput);
});
});
describe('init.plan-phase', () => {
it('SDK JSON matches gsd-tools.cjs', async () => {
const gsdOutput = await captureGsdToolsOutput('init', ['plan-phase', '9'], REPO_ROOT);
const registry = createRegistry();
const sdkResult = await registry.dispatch('init.plan-phase', ['9'], REPO_ROOT);
verifyInitParity(sdkResult.data, gsdOutput);
});
});
describe('init.quick', () => {
it('SDK JSON matches gsd-tools.cjs except clock-derived quick fields', async () => {
const gsdOutput = await captureGsdToolsOutput('init', ['quick', 'test-task'], REPO_ROOT) as Record<string, unknown>;
const registry = createRegistry();
const sdkResult = await registry.dispatch('init.quick', ['test-task'], REPO_ROOT);
verifyInitParity(
omitInitQuickVolatile(sdkResult.data as Record<string, unknown>),
omitInitQuickVolatile(gsdOutput),
);
});
});
describe('init.resume', () => {
it('SDK JSON matches gsd-tools.cjs', async () => {
const gsdOutput = await captureGsdToolsOutput('init', ['resume'], REPO_ROOT);
const registry = createRegistry();
const sdkResult = await registry.dispatch('init.resume', [], REPO_ROOT);
verifyInitParity(sdkResult.data, gsdOutput);
});
});
describe('init.verify-work', () => {
it('SDK JSON matches gsd-tools.cjs', async () => {
const gsdOutput = await captureGsdToolsOutput('init', ['verify-work', '9'], REPO_ROOT);
const registry = createRegistry();
const sdkResult = await registry.dispatch('init.verify-work', ['9'], REPO_ROOT);
verifyInitParity(sdkResult.data, gsdOutput);
});
});
describe('verify.phase-completeness', () => {
it('SDK JSON matches gsd-tools.cjs', async () => {
const gsdOutput = await captureGsdToolsOutput('verify', ['phase-completeness', '9'], REPO_ROOT);
const registry = createRegistry();
const sdkResult = await registry.dispatch('verify.phase-completeness', ['9'], REPO_ROOT);
expect(sdkResult.data).toEqual(gsdOutput);
});
});
// ─── State validate / sync (read + dry-run mutation parity) ─────────────
describe('state.validate', () => {
it('SDK output matches gsd-tools.cjs', async () => {
const gsdOutput = await captureGsdToolsOutput('state', ['validate'], REPO_ROOT);
const registry = createRegistry();
const sdkResult = await registry.dispatch('state.validate', [], REPO_ROOT);
expect(sdkResult.data).toEqual(gsdOutput);
});
});
describe('state.sync --verify', () => {
it('SDK dry-run output matches gsd-tools.cjs', async () => {
const gsdOutput = await captureGsdToolsOutput('state', ['sync', '--verify'], REPO_ROOT);
const registry = createRegistry();
const sdkResult = await registry.dispatch('state.sync', ['--verify'], REPO_ROOT);
expect(sdkResult.data).toEqual(gsdOutput);
});
});
// ─── detect-custom-files (temp config dir) ─────────────────────────────
describe('detect-custom-files', () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = join(tmpdir(), `gsd-golden-dcf-${Date.now()}-${Math.random().toString(36).slice(2)}`);
await mkdir(join(tmpDir, 'agents'), { recursive: true });
await writeFile(join(tmpDir, 'gsd-file-manifest.json'), JSON.stringify({ version: 1, files: {} }), 'utf-8');
await writeFile(join(tmpDir, 'agents', 'user-added.md'), '# custom\n', 'utf-8');
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
it('SDK output matches gsd-tools.cjs for manifest + custom file', async () => {
const args = ['--config-dir', tmpDir];
const gsdOutput = await captureGsdToolsOutput('detect-custom-files', args, PROJECT_DIR);
const registry = createRegistry();
const sdkResult = await registry.dispatch('detect-custom-files', args, PROJECT_DIR);
expect(sdkResult.data).toEqual(gsdOutput);
});
});
// ─── docs-init ─────────────────────────────────────────────────────────
describe('docs-init', () => {
it('SDK output matches gsd-tools.cjs (normalized existing_docs order)', async () => {
const gsdOutput = await captureGsdToolsOutput('docs-init', [], REPO_ROOT) as Record<string, unknown>;
const registry = createRegistry();
const sdkResult = await registry.dispatch('docs-init', [], REPO_ROOT);
expect(
omitAgentInstallFields(normalizeDocsInitPayload(sdkResult.data as Record<string, unknown>)),
).toEqual(
omitAgentInstallFields(normalizeDocsInitPayload(gsdOutput)),
);
});
});
// ─── intel.update (JSON parity with `intel.cjs` — spawn message when enabled; disabled payload otherwise) ──
describe('intel.update', () => {
it('SDK JSON matches gsd-tools.cjs (`intel update`)', async () => {
const gsdOutput = await captureGsdToolsOutput('intel', ['update'], REPO_ROOT);
const registry = createRegistry();
const sdkResult = await registry.dispatch('intel.update', [], REPO_ROOT);
expect(sdkResult.data).toEqual(gsdOutput);
});
});
});

View File

@@ -0,0 +1,15 @@
/**
* Normalize `init quick` payloads for golden parity: CJS runs in a subprocess with a
* different clock than the in-process SDK, so time-derived fields cannot match exactly.
*/
/** Keys derived from `Date` / `quick_id` generation (init.cjs cmdInitQuick). */
export const INIT_QUICK_VOLATILE_KEYS = ['quick_id', 'timestamp', 'branch_name', 'task_dir'] as const;
export function omitInitQuickVolatile(data: Record<string, unknown>): Record<string, unknown> {
const o = { ...data };
for (const k of INIT_QUICK_VOLATILE_KEYS) {
delete o[k];
}
return o;
}

View File

@@ -0,0 +1,77 @@
/**
* Read-only subprocess golden rows: SDK `registry.dispatch` vs `gsd-tools.cjs` JSON on stdout.
* Imported by `read-only-parity.integration.test.ts` and `golden-policy.ts` coverage accounting.
*/
export type JsonParityRow = {
canonical: string;
sdkArgs: string[];
cjs: string;
cjsArgs: string[];
};
/** Repo-relative fixtures (cwd = get-shit-done repo root). */
export const GOLDEN_PLAN = '.planning/phases/09-foundation-and-test-infrastructure/09-01-PLAN.md';
/**
* Strict `toEqual` JSON parity rows verified on this repository.
* (Expand as more handlers are aligned with `gsd-tools.cjs`.)
*/
export const READ_ONLY_JSON_PARITY_ROWS: JsonParityRow[] = [
{ canonical: 'resolve-model', sdkArgs: ['gsd-planner'], cjs: 'resolve-model', cjsArgs: ['gsd-planner'] },
{ canonical: 'phase-plan-index', sdkArgs: ['9'], cjs: 'phase-plan-index', cjsArgs: ['9'] },
{ canonical: 'roadmap.get-phase', sdkArgs: ['9'], cjs: 'roadmap', cjsArgs: ['get-phase', '9'] },
{ canonical: 'list.todos', sdkArgs: [], cjs: 'list-todos', cjsArgs: [] },
{ canonical: 'phase.next-decimal', sdkArgs: ['9'], cjs: 'phase', cjsArgs: ['next-decimal', '9'] },
{ canonical: 'phases.list', sdkArgs: [], cjs: 'phases', cjsArgs: ['list'] },
{ canonical: 'verify.summary', sdkArgs: [GOLDEN_PLAN], cjs: 'verify-summary', cjsArgs: [GOLDEN_PLAN] },
{ canonical: 'verify.path-exists', sdkArgs: ['.planning/STATE.md'], cjs: 'verify-path-exists', cjsArgs: ['.planning/STATE.md'] },
{ canonical: 'verify.artifacts', sdkArgs: [GOLDEN_PLAN], cjs: 'verify', cjsArgs: ['artifacts', GOLDEN_PLAN] },
{ canonical: 'websearch', sdkArgs: ['typescript', '--limit', '1'], cjs: 'websearch', cjsArgs: ['typescript', '--limit', '1'] },
{ canonical: 'workstream.get', sdkArgs: ['default'], cjs: 'workstream', cjsArgs: ['get', 'default'] },
{ canonical: 'workstream.list', sdkArgs: [], cjs: 'workstream', cjsArgs: ['list'] },
{ canonical: 'workstream.status', sdkArgs: ['default'], cjs: 'workstream', cjsArgs: ['status', 'default'] },
{ canonical: 'learnings.list', sdkArgs: [], cjs: 'learnings', cjsArgs: ['list'] },
{ canonical: 'intel.status', sdkArgs: [], cjs: 'intel', cjsArgs: ['status'] },
{ canonical: 'intel.diff', sdkArgs: [], cjs: 'intel', cjsArgs: ['diff'] },
{ canonical: 'intel.validate', sdkArgs: [], cjs: 'intel', cjsArgs: ['validate'] },
{ canonical: 'intel.query', sdkArgs: ['gsd'], cjs: 'intel', cjsArgs: ['query', 'gsd'] },
{
canonical: 'intel.extract-exports',
sdkArgs: ['sdk/src/query/utils.ts'],
cjs: 'intel',
cjsArgs: ['extract-exports', 'sdk/src/query/utils.ts'],
},
{ canonical: 'init.list-workspaces', sdkArgs: [], cjs: 'init', cjsArgs: ['list-workspaces'] },
{ canonical: 'agent-skills', sdkArgs: [], cjs: 'agent-skills', cjsArgs: [] },
{ canonical: 'scan-sessions', sdkArgs: ['--json'], cjs: 'scan-sessions', cjsArgs: ['--json'] },
{ canonical: 'stats.json', sdkArgs: [], cjs: 'stats', cjsArgs: ['json'] },
{ canonical: 'todo.match-phase', sdkArgs: ['9'], cjs: 'todo', cjsArgs: ['match-phase', '9'] },
{ canonical: 'verify.key-links', sdkArgs: [GOLDEN_PLAN], cjs: 'verify', cjsArgs: ['key-links', GOLDEN_PLAN] },
{ canonical: 'verify.schema-drift', sdkArgs: ['9'], cjs: 'verify', cjsArgs: ['schema-drift', '9'] },
{ canonical: 'state-snapshot', sdkArgs: [], cjs: 'state-snapshot', cjsArgs: [] },
{ canonical: 'history.digest', sdkArgs: [], cjs: 'history-digest', cjsArgs: [] },
{ canonical: 'audit-uat', sdkArgs: [], cjs: 'audit-uat', cjsArgs: [] },
{ canonical: 'skill-manifest', sdkArgs: [], cjs: 'skill-manifest', cjsArgs: [] },
{ canonical: 'validate.agents', sdkArgs: [], cjs: 'validate', cjsArgs: ['agents'] },
{
canonical: 'uat.render-checkpoint',
sdkArgs: ['--file', 'sdk/src/golden/fixtures/uat-render-checkpoint-sample.md'],
cjs: 'uat',
cjsArgs: ['render-checkpoint', '--file', 'sdk/src/golden/fixtures/uat-render-checkpoint-sample.md'],
},
];
/** Canonicals from JSON rows plus special-case subprocess tests in read-only-parity integration. */
export function readOnlyGoldenCanonicals(): Set<string> {
const s = new Set<string>(READ_ONLY_JSON_PARITY_ROWS.map((r) => r.canonical));
s.add('verify.commits');
s.add('config-path');
s.add('state.json');
s.add('state.load');
s.add('audit-open');
s.add('state.get');
s.add('summary.extract');
return s;
}

View File

@@ -0,0 +1,125 @@
/**
* Read-only subprocess golden checks (SDK vs gsd-tools.cjs JSON).
* Row data: `read-only-golden-rows.ts`. Policy: `golden-policy.ts`, `QUERY-HANDLERS.md`.
*/
import { describe, it, expect } from 'vitest';
import { captureGsdToolsOutput, captureGsdToolsStdout } from './capture.js';
import { createRegistry } from '../query/index.js';
import { resolve, dirname, normalize } from 'node:path';
import { fileURLToPath } from 'node:url';
import { execSync } from 'node:child_process';
import { READ_ONLY_JSON_PARITY_ROWS } from './read-only-golden-rows.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = resolve(__dirname, '..', '..', '..');
describe('Read-only golden parity (JSON toEqual)', () => {
it.each(READ_ONLY_JSON_PARITY_ROWS)('$canonical matches gsd-tools.cjs JSON', async (row) => {
const gsdOutput = await captureGsdToolsOutput(row.cjs, row.cjsArgs, REPO_ROOT);
const registry = createRegistry();
const sdkResult = await registry.dispatch(row.canonical, row.sdkArgs, REPO_ROOT);
expect(sdkResult.data).toEqual(gsdOutput);
});
});
describe('config-path (plain stdout vs SDK { path })', () => {
it('SDK path matches gsd-tools.cjs plain-text stdout', async () => {
const out = await captureGsdToolsStdout('config-path', [], REPO_ROOT);
const registry = createRegistry();
const sdkResult = await registry.dispatch('config-path', [], REPO_ROOT);
const data = sdkResult.data as { path?: string };
expect(data.path).toBeDefined();
expect(normalize(data.path!.trim())).toBe(normalize(out.trim()));
});
});
describe('audit-open golden parity (excluding scanned_at)', () => {
it('SDK JSON matches gsd-tools.cjs except volatile scanned_at', async () => {
const gsdOutput = await captureGsdToolsOutput('audit-open', ['--json'], REPO_ROOT);
const registry = createRegistry();
const sdkResult = await registry.dispatch('audit-open', ['--json'], REPO_ROOT);
const strip = (d: unknown): Record<string, unknown> => {
const o = { ...(d as Record<string, unknown>) };
delete o.scanned_at;
delete o.has_scan_errors;
return o;
};
expect(strip(sdkResult.data)).toEqual(strip(gsdOutput));
});
});
describe('state.json golden parity (excluding last_updated)', () => {
it('SDK rebuilt frontmatter matches gsd-tools.cjs except volatile last_updated', async () => {
const gsdOutput = await captureGsdToolsOutput('state', ['json'], REPO_ROOT);
const registry = createRegistry();
const sdkResult = await registry.dispatch('state.json', [], REPO_ROOT);
const strip = (d: unknown): Record<string, unknown> => {
const o = { ...(d as Record<string, unknown>) };
delete o.last_updated;
return o;
};
expect(strip(sdkResult.data)).toEqual(strip(gsdOutput));
});
});
describe('summary.extract golden parity (with array-of-objects fix)', () => {
it('SDK JSON matches gsd-tools.cjs except for intentional array-of-objects parsing fix', async () => {
const gsdOutput = await captureGsdToolsOutput('summary-extract', ['sdk/src/golden/fixtures/summary-extract-sample.md'], REPO_ROOT);
const registry = createRegistry();
const sdkResult = await registry.dispatch('summary.extract', ['sdk/src/golden/fixtures/summary-extract-sample.md'], REPO_ROOT);
// The SDK correctly parses array-of-objects, whereas CJS parses them as strings.
// Patch the CJS output to reflect the CodeRabbit bugfix.
const patchedGsd = JSON.parse(JSON.stringify(gsdOutput));
if (patchedGsd.tech_added && Array.isArray(patchedGsd.tech_added)) {
patchedGsd.tech_added = patchedGsd.tech_added.map((t: any) =>
t === 'name: typescript' ? { name: 'typescript' } : t
);
}
expect(sdkResult.data).toEqual(patchedGsd);
});
});
describe('state.load golden parity', () => {
it('SDK load payload matches gsd-tools.cjs state load', async () => {
const gsdOutput = await captureGsdToolsOutput('state', ['load'], REPO_ROOT);
const registry = createRegistry();
const sdkResult = await registry.dispatch('state.load', [], REPO_ROOT);
expect(sdkResult.data).toEqual(gsdOutput);
});
});
describe('state.get golden parity', () => {
it('matches full STATE.md when no field (same as `state get` with no section)', async () => {
const gsdOutput = await captureGsdToolsOutput('state', ['get'], REPO_ROOT);
const registry = createRegistry();
const sdkResult = await registry.dispatch('state.get', [], REPO_ROOT);
expect(sdkResult.data).toEqual(gsdOutput);
});
it('matches single frontmatter field when `state get <field>`', async () => {
const gsdOutput = await captureGsdToolsOutput('state', ['get', 'milestone'], REPO_ROOT);
const registry = createRegistry();
const sdkResult = await registry.dispatch('state.get', ['milestone'], REPO_ROOT);
expect(sdkResult.data).toEqual(gsdOutput);
});
});
describe('verify.commits golden parity', () => {
it('SDK output matches gsd-tools.cjs for two SHAs', async () => {
const revs = execSync('git rev-list --max-count=2 HEAD', { cwd: REPO_ROOT, encoding: 'utf-8' })
.trim()
.split('\n')
.filter(Boolean);
if (revs.length < 2) {
throw new Error('verify.commits parity requires at least 2 commits in checkout history');
}
const b = revs[0];
const a = revs[1];
const gsdOutput = await captureGsdToolsOutput('verify', ['commits', a, b], REPO_ROOT);
const registry = createRegistry();
const sdkResult = await registry.dispatch('verify.commits', [a, b], REPO_ROOT);
expect(sdkResult.data).toEqual(gsdOutput);
});
});

View File

@@ -0,0 +1,31 @@
/**
* Canonical registry command strings for golden parity — one primary name per unique
* native handler (dedupes dotted vs space-delimited aliases on the same function).
*/
import { createRegistry } from '../query/index.js';
import type { QueryHandler } from '../query/utils.js';
export function getCanonicalRegistryCommands(): string[] {
const registry = createRegistry();
const byHandler = new Map<QueryHandler, string[]>();
for (const cmd of registry.commands()) {
const h = registry.getHandler(cmd);
if (!h) continue;
const list = byHandler.get(h) ?? [];
list.push(cmd);
byHandler.set(h, list);
}
const out: string[] = [];
for (const cmds of byHandler.values()) {
cmds.sort((a, b) => a.localeCompare(b));
const dotted = cmds.find((c) => c.includes('.'));
if (dotted) {
out.push(dotted);
continue;
}
const kebab = cmds.find((c) => c.includes('-'));
out.push(kebab ?? cmds[0]!);
}
return out.sort((a, b) => a.localeCompare(b));
}