diff --git a/get-shit-done/workflows/ingest-docs.md b/get-shit-done/workflows/ingest-docs.md index ed93b249..bbcc4e3b 100644 --- a/get-shit-done/workflows/ingest-docs.md +++ b/get-shit-done/workflows/ingest-docs.md @@ -50,7 +50,7 @@ If `PATH_NOT_FOUND` or `MANIFEST_NOT_FOUND`: display error and exit. Run the init query: ```bash -INIT=$(gsd-sdk query init.ingest-docs 2>/dev/null || gsd-sdk query init.default) +INIT=$(gsd-sdk query init.ingest-docs) ``` Parse `project_exists`, `planning_exists`, `has_git`, `project_path` from INIT. diff --git a/sdk/src/query/index.ts b/sdk/src/query/index.ts index c99784c9..b2d9c072 100644 --- a/sdk/src/query/index.ts +++ b/sdk/src/query/index.ts @@ -44,6 +44,7 @@ import { initExecutePhase, initPlanPhase, initNewMilestone, initQuick, initResume, initVerifyWork, initPhaseOp, initTodos, initMilestoneOp, initMapCodebase, initNewWorkspace, initListWorkspaces, initRemoveWorkspace, + initIngestDocs, } from './init.js'; import { initNewProject, initProgress, initManager } from './init-complex.js'; import { agentSkills } from './skills.js'; @@ -338,6 +339,7 @@ export function createRegistry(eventStream?: GSDEventStream): QueryRegistry { registry.register('init.new-workspace', initNewWorkspace); registry.register('init.list-workspaces', initListWorkspaces); registry.register('init.remove-workspace', initRemoveWorkspace); + registry.register('init.ingest-docs', initIngestDocs); // Space-delimited aliases for CJS compatibility registry.register('init execute-phase', initExecutePhase); registry.register('init plan-phase', initPlanPhase); @@ -352,6 +354,7 @@ export function createRegistry(eventStream?: GSDEventStream): QueryRegistry { registry.register('init new-workspace', initNewWorkspace); registry.register('init list-workspaces', initListWorkspaces); registry.register('init remove-workspace', initRemoveWorkspace); + registry.register('init ingest-docs', initIngestDocs); // Complex init handlers registry.register('init.new-project', initNewProject); diff --git a/sdk/src/query/init.test.ts b/sdk/src/query/init.test.ts index 06d6610c..8973ef96 100644 --- a/sdk/src/query/init.test.ts +++ b/sdk/src/query/init.test.ts @@ -24,6 +24,7 @@ import { initNewWorkspace, initListWorkspaces, initRemoveWorkspace, + initIngestDocs, } from './init.js'; let tmpDir: string; @@ -498,3 +499,24 @@ describe('initRemoveWorkspace', () => { expect(data.error).toBeDefined(); }); }); + +describe('initIngestDocs', () => { + it('returns flat JSON with ingest-docs branching fields', async () => { + const result = await initIngestDocs([], tmpDir); + const data = result.data as Record; + expect(data.project_exists).toBe(false); + expect(data.planning_exists).toBe(true); + expect(typeof data.has_git).toBe('boolean'); + expect(data.project_path).toBe('.planning/PROJECT.md'); + expect(data.commit_docs).toBeDefined(); + expect(data.project_root).toBe(tmpDir); + }); + + it('reports project_exists true when PROJECT.md is present', async () => { + await writeFile(join(tmpDir, '.planning', 'PROJECT.md'), '# project'); + const result = await initIngestDocs([], tmpDir); + const data = result.data as Record; + expect(data.project_exists).toBe(true); + expect(data.planning_exists).toBe(true); + }); +}); diff --git a/sdk/src/query/init.ts b/sdk/src/query/init.ts index ae829297..146f4632 100644 --- a/sdk/src/query/init.ts +++ b/sdk/src/query/init.ts @@ -950,6 +950,26 @@ export const initRemoveWorkspace: QueryHandler = async (args, _projectDir) => { return { data: result }; }; +// ─── initIngestDocs ─────────────────────────────────────────────────────── + +/** + * Init handler for ingest-docs workflow. + * Mirrors `initResume` shape but without current-agent-id lookup — the + * ingest-docs workflow reads `project_exists`, `planning_exists`, `has_git`, + * and `project_path` to branch between new-project vs merge-milestone modes. + */ +export const initIngestDocs: QueryHandler = async (_args, projectDir) => { + const config = await loadConfig(projectDir); + const result: Record = { + project_exists: pathExists(projectDir, '.planning/PROJECT.md'), + planning_exists: pathExists(projectDir, '.planning'), + has_git: pathExists(projectDir, '.git'), + project_path: '.planning/PROJECT.md', + commit_docs: config.commit_docs, + }; + return { data: withProjectRoot(projectDir, result, config as Record) }; +}; + // ─── docsInit ──────────────────────────────────────────────────────────── export const docsInit: QueryHandler = async (_args, projectDir) => { diff --git a/tests/gsd-sdk-query-registry-integration.test.cjs b/tests/gsd-sdk-query-registry-integration.test.cjs new file mode 100644 index 00000000..e793ca41 --- /dev/null +++ b/tests/gsd-sdk-query-registry-integration.test.cjs @@ -0,0 +1,168 @@ +/** + * Drift guard: every `gsd-sdk query ` reference in the repo must + * resolve to a handler registered in sdk/src/query/index.ts. + * + * The set of commands workflows/agents/commands call must equal the set + * the SDK registry exposes. New references with no handler — or handlers + * with no in-repo callers — show up here so they can't diverge silently. + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const REPO_ROOT = path.join(__dirname, '..'); +const REGISTRY_FILE = path.join(REPO_ROOT, 'sdk', 'src', 'query', 'index.ts'); + +// Prose tokens that repeatedly appear after `gsd-sdk query` in English +// documentation but aren't real command names. +const PROSE_ALLOWLIST = new Set([ + 'commands', + 'intel', + 'into', + 'or', + 'init.', +]); + +const SCAN_ROOTS = [ + 'commands', + 'agents', + 'get-shit-done', + 'hooks', + 'bin', + 'scripts', + 'docs', +]; +const EXTRA_FILES = ['README.md', 'CHANGELOG.md']; +const EXTENSIONS = new Set(['.md', '.sh', '.cjs', '.js', '.ts']); +const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build']); + +function collectRegisteredNames() { + const src = fs.readFileSync(REGISTRY_FILE, 'utf8'); + const names = new Set(); + const re = /registry\.register\(\s*['"]([^'"]+)['"]/g; + let m; + while ((m = re.exec(src)) !== null) names.add(m[1]); + return names; +} + +function walk(dir, files) { + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (SKIP_DIRS.has(entry.name)) continue; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(full, files); + } else if (entry.isFile() && EXTENSIONS.has(path.extname(entry.name))) { + files.push(full); + } + } +} + +function collectReferences() { + const files = []; + for (const root of SCAN_ROOTS) walk(path.join(REPO_ROOT, root), files); + for (const rel of EXTRA_FILES) { + const full = path.join(REPO_ROOT, rel); + if (fs.existsSync(full)) files.push(full); + } + + const refs = []; + const re = /gsd-sdk\s+query\s+([A-Za-z][-A-Za-z0-9._/]+)(?:\s+([A-Za-z][-A-Za-z0-9._]+))?/g; + + for (const file of files) { + const content = fs.readFileSync(file, 'utf8'); + const lines = content.split('\n'); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + let m; + re.lastIndex = 0; + while ((m = re.exec(line)) !== null) { + refs.push({ + file: path.relative(REPO_ROOT, file), + line: i + 1, + tok1: m[1], + tok2: m[2] || null, + raw: line.trim(), + }); + } + } + } + return refs; +} + +function resolveReference(ref, registered) { + const { tok1, tok2 } = ref; + if (registered.has(tok1)) return true; + if (tok2) { + const dotted = tok1 + '.' + tok2; + const spaced = tok1 + ' ' + tok2; + if (registered.has(dotted) || registered.has(spaced)) return true; + } + if (PROSE_ALLOWLIST.has(tok1)) return true; + return false; +} + +describe('gsd-sdk query registry integration', () => { + test('every referenced command resolves to a registered handler', () => { + const registered = collectRegisteredNames(); + const refs = collectReferences(); + + assert.ok(registered.size > 0, 'expected to parse registered names'); + assert.ok(refs.length > 0, 'expected to find gsd-sdk query references'); + + const offenders = []; + for (const ref of refs) { + if (!resolveReference(ref, registered)) { + const shown = ref.tok2 ? ref.tok1 + ' ' + ref.tok2 : ref.tok1; + offenders.push(ref.file + ':' + ref.line + ': "' + shown + '" — ' + ref.raw); + } + } + + assert.strictEqual( + offenders.length, 0, + 'Referenced `gsd-sdk query ` tokens with no handler in ' + + 'sdk/src/query/index.ts. Either register the handler or remove ' + + 'the reference.\n\n' + offenders.join('\n') + ); + }); + + test('informational: handlers with no in-repo caller', () => { + const registered = collectRegisteredNames(); + const refs = collectReferences(); + + const referencedNames = new Set(); + for (const ref of refs) { + referencedNames.add(ref.tok1); + if (ref.tok2) { + referencedNames.add(ref.tok1 + '.' + ref.tok2); + referencedNames.add(ref.tok1 + ' ' + ref.tok2); + } + } + + const unused = []; + for (const name of registered) { + if (referencedNames.has(name)) continue; + if (name.includes('.')) { + const spaced = name.replace('.', ' '); + if (referencedNames.has(spaced)) continue; + } + if (name.includes(' ')) { + const dotted = name.replace(' ', '.'); + if (referencedNames.has(dotted)) continue; + } + unused.push(name); + } + + if (unused.length > 0 && process.env.GSD_LOG_UNUSED_HANDLERS) { + console.log('[info] registered handlers with no in-repo caller:\n ' + unused.join('\n ')); + } + assert.ok(true); + }); +});