fix(sdk): register init.ingest-docs handler and add registry drift guard (#2442)

The ingest-docs workflow called `gsd-sdk query init.ingest-docs` with a
fallback to `init.default` — neither was registered in createRegistry(),
so the workflow proceeded with `{}` and tried to parse project_exists,
planning_exists, has_git, and project_path from empty.

- Add initIngestDocs handler; register dotted + space aliases
- Simplify workflow call; drop broken fallback
- Repo-wide drift guard scans commands/, agents/, get-shit-done/,
  hooks/, bin/, scripts/, docs/ for `gsd-sdk query <cmd>` and fails
  on any reference with no registered handler (file:line citations)
- Unit tests for the new handler

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jeremy McSpadden
2026-04-19 15:55:37 -05:00
parent a20aa81a0e
commit 0a62e5223e
5 changed files with 214 additions and 1 deletions

View File

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

View File

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

View File

@@ -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<string, unknown>;
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<string, unknown>;
expect(data.project_exists).toBe(true);
expect(data.planning_exists).toBe(true);
});
});

View File

@@ -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<string, unknown> = {
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<string, unknown>) };
};
// ─── docsInit ────────────────────────────────────────────────────────────
export const docsInit: QueryHandler = async (_args, projectDir) => {

View File

@@ -0,0 +1,168 @@
/**
* Drift guard: every `gsd-sdk query <cmd>` 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 <cmd>` 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);
});
});