mirror of
https://github.com/glittercowboy/get-shit-done
synced 2026-04-25 17:25:23 +02:00
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:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
168
tests/gsd-sdk-query-registry-integration.test.cjs
Normal file
168
tests/gsd-sdk-query-registry-integration.test.cjs
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user