docs: close doc drift vectors — bidirectional parity, manifest, schema-driven config (#2479)
Option A — ghost-entry guard (INVENTORY ⊆ actual): tests/inventory-source-parity.test.cjs parses every declared row in INVENTORY.md and asserts the source file exists. Catches deletions and renames that leave ghost entries behind. Option B — auto-generated structural manifest: scripts/gen-inventory-manifest.cjs walks all six family dirs and emits docs/INVENTORY-MANIFEST.json. tests/inventory-manifest-sync.test.cjs fails CI when a new surface ships without a manifest update, surfacing exactly which entries are missing. Option C — schema-driven config validation + docs parity: get-shit-done/bin/lib/config-schema.cjs extracted from config.cjs as the single source of truth for VALID_CONFIG_KEYS and dynamic patterns. config.cjs now imports from it. tests/config-schema-docs-parity.test.cjs asserts every exact-match key appears in docs/CONFIGURATION.md, surfacing 14 previously undocumented keys (planning.sub_repos, workflow.ai_integration_phase, git.base_branch, learnings.max_inject, and 10 others) — all now documented in their appropriate sections. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
40
tests/config-schema-docs-parity.test.cjs
Normal file
40
tests/config-schema-docs-parity.test.cjs
Normal file
@@ -0,0 +1,40 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Asserts every exact-match key in config-schema.cjs appears at least once
|
||||
* in docs/CONFIGURATION.md. A key present in the validator but absent from
|
||||
* the docs means users can set it but have no guidance. A key in the docs but
|
||||
* absent from the validator means config-set silently rejects it.
|
||||
*
|
||||
* Dynamic patterns (agent_skills.*, review.models.*, features.*) are excluded
|
||||
* from this check — they are documented by namespace in CONFIGURATION.md.
|
||||
*/
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const { VALID_CONFIG_KEYS } = require('../get-shit-done/bin/lib/config-schema.cjs');
|
||||
const CONFIGURATION_MD = fs.readFileSync(path.join(ROOT, 'docs', 'CONFIGURATION.md'), 'utf8');
|
||||
|
||||
// Keys starting with _ are internal runtime state, not user-facing config.
|
||||
const INTERNAL_KEYS = new Set(['workflow._auto_chain_active']);
|
||||
|
||||
test('every key in VALID_CONFIG_KEYS is documented in docs/CONFIGURATION.md', () => {
|
||||
const undocumented = [];
|
||||
for (const key of VALID_CONFIG_KEYS) {
|
||||
if (INTERNAL_KEYS.has(key)) continue;
|
||||
if (!CONFIGURATION_MD.includes('`' + key + '`')) {
|
||||
undocumented.push(key);
|
||||
}
|
||||
}
|
||||
assert.deepStrictEqual(
|
||||
undocumented,
|
||||
[],
|
||||
'Keys in VALID_CONFIG_KEYS with no mention in docs/CONFIGURATION.md:\n' +
|
||||
undocumented.map((k) => ' ' + k).join('\n') +
|
||||
'\nAdd a row in the appropriate section of docs/CONFIGURATION.md.',
|
||||
);
|
||||
});
|
||||
54
tests/inventory-manifest-sync.test.cjs
Normal file
54
tests/inventory-manifest-sync.test.cjs
Normal file
@@ -0,0 +1,54 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Asserts docs/INVENTORY-MANIFEST.json is in sync with the filesystem.
|
||||
* A stale manifest means a surface shipped without updating INVENTORY.md.
|
||||
* Fix by running: node scripts/gen-inventory-manifest.cjs --write
|
||||
* then adding the corresponding row(s) in docs/INVENTORY.md.
|
||||
*/
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const MANIFEST_PATH = path.join(ROOT, 'docs', 'INVENTORY-MANIFEST.json');
|
||||
|
||||
const FAMILIES = [
|
||||
{ name: 'agents', dir: path.join(ROOT, 'agents'), filter: (f) => /^gsd-.*\.md$/.test(f), toName: (f) => f.replace(/\.md$/, '') },
|
||||
{ name: 'commands', dir: path.join(ROOT, 'commands', 'gsd'), filter: (f) => f.endsWith('.md'), toName: (f) => '/gsd-' + f.replace(/\.md$/, '') },
|
||||
{ name: 'workflows', dir: path.join(ROOT, 'get-shit-done', 'workflows'), filter: (f) => f.endsWith('.md'), toName: (f) => f },
|
||||
{ name: 'references', dir: path.join(ROOT, 'get-shit-done', 'references'), filter: (f) => f.endsWith('.md'), toName: (f) => f },
|
||||
{ name: 'cli_modules', dir: path.join(ROOT, 'get-shit-done', 'bin', 'lib'), filter: (f) => f.endsWith('.cjs'), toName: (f) => f },
|
||||
{ name: 'hooks', dir: path.join(ROOT, 'hooks'), filter: (f) => /\.(js|sh)$/.test(f), toName: (f) => f },
|
||||
];
|
||||
|
||||
test('docs/INVENTORY-MANIFEST.json matches the filesystem', () => {
|
||||
const committed = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8'));
|
||||
const additions = [];
|
||||
const removals = [];
|
||||
|
||||
for (const { name, dir, filter, toName } of FAMILIES) {
|
||||
const live = new Set(
|
||||
fs.readdirSync(dir)
|
||||
.filter((f) => fs.statSync(path.join(dir, f)).isFile() && filter(f))
|
||||
.map(toName),
|
||||
);
|
||||
const recorded = new Set((committed.families || {})[name] || []);
|
||||
|
||||
for (const entry of live) {
|
||||
if (!recorded.has(entry)) additions.push(name + '/' + entry);
|
||||
}
|
||||
for (const entry of recorded) {
|
||||
if (!live.has(entry)) removals.push(name + '/' + entry);
|
||||
}
|
||||
}
|
||||
|
||||
const msg = [
|
||||
additions.length ? 'New surfaces not in manifest (run node scripts/gen-inventory-manifest.cjs --write):\n' + additions.map((e) => ' + ' + e).join('\n') : '',
|
||||
removals.length ? 'Manifest entries with no matching file:\n' + removals.map((e) => ' - ' + e).join('\n') : '',
|
||||
].filter(Boolean).join('\n');
|
||||
|
||||
assert.ok(additions.length === 0 && removals.length === 0, msg);
|
||||
});
|
||||
132
tests/inventory-source-parity.test.cjs
Normal file
132
tests/inventory-source-parity.test.cjs
Normal file
@@ -0,0 +1,132 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Reverse-direction parity: every row declared in docs/INVENTORY.md must
|
||||
* resolve to a real file on the filesystem. Complements the forward tests
|
||||
* (actual ⊆ INVENTORY) with the reverse direction (INVENTORY ⊆ actual),
|
||||
* catching ghost entries left behind when artifacts are deleted or renamed.
|
||||
*/
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const INVENTORY = fs.readFileSync(path.join(ROOT, 'docs', 'INVENTORY.md'), 'utf8');
|
||||
|
||||
/** Extract the text of a named top-level section (## Header ... next ##). */
|
||||
function section(header) {
|
||||
const start = INVENTORY.indexOf('## ' + header);
|
||||
if (start === -1) return '';
|
||||
const next = INVENTORY.indexOf('\n## ', start + 1);
|
||||
return next === -1 ? INVENTORY.slice(start) : INVENTORY.slice(start, next);
|
||||
}
|
||||
|
||||
/** Extract backtick-quoted filenames from column-1 table cells. */
|
||||
function backtickNames(text, ext) {
|
||||
const re = new RegExp('\\|\\s*`([^`]+\\.' + ext + ')`\\s*\\|', 'gm');
|
||||
const names = [];
|
||||
let m;
|
||||
while ((m = re.exec(text)) !== null) names.push(m[1]);
|
||||
return names;
|
||||
}
|
||||
|
||||
/** Extract agent names from `| gsd-xxx | ...` rows (no backticks). */
|
||||
function agentNames(text) {
|
||||
const re = /^\|\s*(gsd-[a-z0-9-]+)\s*\|/gm;
|
||||
const names = [];
|
||||
let m;
|
||||
while ((m = re.exec(text)) !== null) names.push(m[1]);
|
||||
return names;
|
||||
}
|
||||
|
||||
/** Extract relative source paths from markdown links in Commands section. */
|
||||
function commandSourcePaths(text) {
|
||||
const re = /\[commands\/gsd\/[^\]]+\]\(\.\.\/(commands\/gsd\/[^)]+)\)/g;
|
||||
const paths = [];
|
||||
let m;
|
||||
while ((m = re.exec(text)) !== null) paths.push(m[1]);
|
||||
return paths;
|
||||
}
|
||||
|
||||
describe('INVENTORY.md declared artifacts exist on the filesystem (ghost-entry guard)', () => {
|
||||
describe('Agents', () => {
|
||||
const names = agentNames(section('Agents'));
|
||||
for (const name of names) {
|
||||
test(name, () => {
|
||||
const p = path.join(ROOT, 'agents', name + '.md');
|
||||
assert.ok(
|
||||
fs.existsSync(p),
|
||||
'INVENTORY.md declares agent "' + name + '" but agents/' + name + '.md does not exist — remove the ghost row or restore the file',
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('Commands', () => {
|
||||
const paths = commandSourcePaths(section('Commands'));
|
||||
for (const rel of paths) {
|
||||
test(rel, () => {
|
||||
const p = path.join(ROOT, rel);
|
||||
assert.ok(
|
||||
fs.existsSync(p),
|
||||
'INVENTORY.md declares source "' + rel + '" but the file does not exist — remove the ghost row or restore the file',
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('Workflows', () => {
|
||||
const names = backtickNames(section('Workflows'), 'md');
|
||||
for (const name of names) {
|
||||
test(name, () => {
|
||||
const p = path.join(ROOT, 'get-shit-done', 'workflows', name);
|
||||
assert.ok(
|
||||
fs.existsSync(p),
|
||||
'INVENTORY.md declares workflow "' + name + '" but get-shit-done/workflows/' + name + ' does not exist — remove the ghost row or restore the file',
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('References', () => {
|
||||
const names = backtickNames(section('References'), 'md');
|
||||
for (const name of names) {
|
||||
test(name, () => {
|
||||
const p = path.join(ROOT, 'get-shit-done', 'references', name);
|
||||
assert.ok(
|
||||
fs.existsSync(p),
|
||||
'INVENTORY.md declares reference "' + name + '" but get-shit-done/references/' + name + ' does not exist — remove the ghost row or restore the file',
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('CLI Modules', () => {
|
||||
const names = backtickNames(section('CLI Modules'), 'cjs');
|
||||
for (const name of names) {
|
||||
test(name, () => {
|
||||
const p = path.join(ROOT, 'get-shit-done', 'bin', 'lib', name);
|
||||
assert.ok(
|
||||
fs.existsSync(p),
|
||||
'INVENTORY.md declares CLI module "' + name + '" but get-shit-done/bin/lib/' + name + ' does not exist — remove the ghost row or restore the file',
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('Hooks', () => {
|
||||
const jsNames = backtickNames(section('Hooks'), 'js');
|
||||
const shNames = backtickNames(section('Hooks'), 'sh');
|
||||
for (const name of [...jsNames, ...shNames]) {
|
||||
test(name, () => {
|
||||
const p = path.join(ROOT, 'hooks', name);
|
||||
assert.ok(
|
||||
fs.existsSync(p),
|
||||
'INVENTORY.md declares hook "' + name + '" but hooks/' + name + ' does not exist — remove the ghost row or restore the file',
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user