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:
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