feat(adr-0002): command contract validation module + prose @-ref cleanup + workflow extraction
ADR-0002: commands/gsd/*.md contract now enforced at two layers: LINT (scripts/lint-command-contract.cjs — new CI step): - name: present, starts with gsd: or gsd- - description: non-empty - allowed-tools: non-empty, all entries canonical - execution_context @-refs: resolve on disk, no trailing prose on same line - handles both @~/ and $HOME/ path prefixes TEST (tests/command-contract.test.cjs — 361 assertions): - Behavioral contract for all 65 command files - Replaces scattered coverage in enh-2790 + bug-3135 - Per-command per-rule test — one failure names the exact file + rule CI (.github/workflows/test.yml): - 'Lint — command contract (ADR-0002)' step added to lint-tests job PROSE @-REF CLEANUP (39 command files, ~900 tokens/invocation recovered): - Removed redundant @~/.claude/get-shit-done/... paths from <process> prose - execution_context block is now the single authoritative load declaration - Routing commands (sketch, spike, update, pause-work, etc.) keep routing instructions; only the inert path token is stripped WORKFLOW EXTRACTION (debug.md + thread.md, ~15,000 chars / ~3,750 tokens): - get-shit-done/workflows/debug.md: full process extracted from commands/gsd/debug.md - get-shit-done/workflows/thread.md: full process extracted from commands/gsd/thread.md - Command files reduced to frontmatter + objective + execution_context + context - debug.md: 9,603 → 1,703 chars; thread.md: 7,868 → 585 chars RENAME: - get-shit-done/workflows/extract_learnings.md → extract-learnings.md (aligns with hyphen convention of all other workflow files) DOCS: - docs/INVENTORY.md: count 85→87, new rows, rename row, fix add-todo --backlog attribution - docs/INVENTORY-MANIFEST.json: +debug.md +thread.md +extract-learnings.md -extract_learnings.md Closes ADR-0002 implementation.
This commit is contained in:
155
scripts/lint-command-contract.cjs
Normal file
155
scripts/lint-command-contract.cjs
Normal file
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* lint-command-contract.cjs (ADR-0002)
|
||||
*
|
||||
* Enforces the commands/gsd/*.md contract across all 65 command files:
|
||||
*
|
||||
* 1. name: present, non-empty, matches gsd: or gsd- prefix
|
||||
* 2. description: present, non-empty
|
||||
* 3. allowed-tools: block present, non-empty, all entries from CANONICAL_TOOLS
|
||||
* 4. execution_context @-refs: every @-reference resolves to an existing file on disk
|
||||
* 5. execution_context @-refs: each appears on its own line (no trailing prose)
|
||||
*
|
||||
* Exit 0 = clean. Exit 1 = violations (with diagnostics).
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const COMMANDS_DIR = path.join(ROOT, 'commands', 'gsd');
|
||||
const GSD_ROOT = path.join(ROOT, 'get-shit-done');
|
||||
|
||||
// All tool names the Claude Code / GSD runtime recognises.
|
||||
// Wildcard entries (mcp__context7__*) match any mcp__context7__ prefixed name.
|
||||
const CANONICAL_TOOLS = new Set([
|
||||
'Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep',
|
||||
'Task', 'Agent', 'Skill', 'SlashCommand',
|
||||
'AskUserQuestion', 'WebFetch', 'WebSearch', 'TodoWrite',
|
||||
'mcp__context7__resolve-library-id',
|
||||
'mcp__context7__query-docs',
|
||||
'mcp__context7__*',
|
||||
]);
|
||||
|
||||
// ─── parsers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function parseFrontmatter(content) {
|
||||
const lines = content.split('\n');
|
||||
if (lines[0].trim() !== '---') return {};
|
||||
const end = lines.indexOf('---', 1);
|
||||
if (end === -1) return {};
|
||||
const fm = {};
|
||||
let key = null;
|
||||
for (const line of lines.slice(1, end)) {
|
||||
const kv = line.match(/^([a-zA-Z0-9_-]+):\s*(.*)/);
|
||||
if (kv) { key = kv[1]; fm[key] = kv[2].trim(); }
|
||||
else if (key && line.match(/^\s+-\s+/)) {
|
||||
const val = line.replace(/^\s+-\s+/, '').trim();
|
||||
fm[key] = fm[key] ? fm[key] + '\n' + val : val;
|
||||
}
|
||||
}
|
||||
return fm;
|
||||
}
|
||||
|
||||
function extractExecutionContextRefs(content) {
|
||||
const results = [];
|
||||
const blockRe = /<execution_context(?:_extended)?>([\s\S]*?)<\/execution_context(?:_extended)?>/g;
|
||||
let m;
|
||||
while ((m = blockRe.exec(content)) !== null) {
|
||||
const block = m[1];
|
||||
for (const rawLine of block.split('\n')) {
|
||||
const line = rawLine.trim();
|
||||
if (!line.startsWith('@')) continue;
|
||||
// Capture the @-reference token (stops at first space)
|
||||
const refToken = line.split(/\s+/)[0];
|
||||
const hasTrailingProse = line.length > refToken.length;
|
||||
// Normalise path: strip @~/.../get-shit-done/ or @$HOME/.../get-shit-done/ prefix
|
||||
const normalized = refToken
|
||||
.replace(/^@(?:~|\$HOME)\//, '')
|
||||
.replace(/^(?:\.claude\/)?(?:get-shit-done\/)?/, '');
|
||||
results.push({ ref: refToken, normalized, hasTrailingProse, rawLine });
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
// ─── check one file ───────────────────────────────────────────────────────────
|
||||
|
||||
function check(filePath) {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const rel = path.relative(ROOT, filePath);
|
||||
const fm = parseFrontmatter(content);
|
||||
const violations = [];
|
||||
|
||||
// 1. name: present + gsd: / gsd- prefix
|
||||
if (!fm.name || !fm.name.trim()) {
|
||||
violations.push('name: field missing or empty');
|
||||
} else if (!/^gsd[:-]/.test(fm.name.trim())) {
|
||||
violations.push(`name: must start with "gsd:" or "gsd-", got "${fm.name.trim()}"`);
|
||||
}
|
||||
|
||||
// 2. description: present + non-empty
|
||||
if (!fm.description || !fm.description.trim()) {
|
||||
violations.push('description: field missing or empty');
|
||||
}
|
||||
|
||||
// 3. allowed-tools: present + non-empty + all entries canonical
|
||||
if (!fm['allowed-tools'] || !fm['allowed-tools'].trim()) {
|
||||
violations.push('allowed-tools: block missing or empty');
|
||||
} else {
|
||||
const tools = fm['allowed-tools'].split('\n').map(t => t.trim()).filter(Boolean);
|
||||
for (const tool of tools) {
|
||||
const valid =
|
||||
CANONICAL_TOOLS.has(tool) ||
|
||||
(tool.startsWith('mcp__context7__') && CANONICAL_TOOLS.has('mcp__context7__*'));
|
||||
if (!valid) violations.push(`allowed-tools: unknown tool "${tool}"`);
|
||||
}
|
||||
}
|
||||
|
||||
// 4+5. execution_context @-refs resolve + no trailing prose
|
||||
const refs = extractExecutionContextRefs(content);
|
||||
for (const { ref, normalized, hasTrailingProse } of refs) {
|
||||
const absPath = path.join(GSD_ROOT, normalized);
|
||||
if (!fs.existsSync(absPath)) {
|
||||
violations.push(`execution_context: @-ref "${normalized}" does not exist on disk`);
|
||||
}
|
||||
if (hasTrailingProse) {
|
||||
violations.push(`execution_context: @-ref "${ref}" has trailing prose on the same line`);
|
||||
}
|
||||
}
|
||||
|
||||
if (violations.length === 0) return null;
|
||||
return { file: rel, violations };
|
||||
}
|
||||
|
||||
// ─── run ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const commandFiles = fs
|
||||
.readdirSync(COMMANDS_DIR)
|
||||
.filter(f => f.endsWith('.md'))
|
||||
.map(f => path.join(COMMANDS_DIR, f));
|
||||
|
||||
const results = commandFiles.map(check).filter(Boolean);
|
||||
|
||||
if (results.length === 0) {
|
||||
console.log(
|
||||
`ok lint-command-contract: ${commandFiles.length} command files checked, 0 violations`,
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const total = results.reduce((n, r) => n + r.violations.length, 0);
|
||||
process.stderr.write(
|
||||
`\nERROR lint-command-contract: ${total} violation(s) across ${results.length} file(s)\n\n`,
|
||||
);
|
||||
for (const r of results) {
|
||||
process.stderr.write(` ${r.file}\n`);
|
||||
for (const v of r.violations) {
|
||||
process.stderr.write(` - ${v}\n`);
|
||||
}
|
||||
process.stderr.write('\n');
|
||||
}
|
||||
process.stderr.write('See docs/adr/0002-command-contract-validation-module.md for the contract spec.\n\n');
|
||||
process.exit(1);
|
||||
105
scripts/strip-prose-atrefs.cjs
Normal file
105
scripts/strip-prose-atrefs.cjs
Normal file
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* strip-prose-atrefs.cjs
|
||||
*
|
||||
* Removes redundant @~/.claude/get-shit-done/ path tokens from prose lines
|
||||
* in <process> and <context> blocks. The path is already declared in
|
||||
* <execution_context> where it actually loads the file. Prose copies are
|
||||
* inert and add ~900 tokens/invocation of dead weight.
|
||||
*
|
||||
* Transformation rules (applied per matching line):
|
||||
* - "Execute the X workflow from @PATH end-to-end." → "Execute end-to-end."
|
||||
* - "Execute @PATH end-to-end." → "Execute end-to-end."
|
||||
* - "Read and execute the X workflow from @PATH end-to-end." → "Execute end-to-end."
|
||||
* - "Follow the X workflow at @PATH." → "Execute end-to-end."
|
||||
* - "Output the X reference from @PATH." → "Execute end-to-end."
|
||||
* - "**Follow the X** from `@PATH`." → "**Follow the X.**"
|
||||
* - "- If it is '...': ... from @PATH end-to-end." → strip path token only
|
||||
* - "- Otherwise: ... from @PATH end-to-end." → strip path token only
|
||||
* - "- @PATH (label)" → "- (label)"
|
||||
*
|
||||
* Run with --dry-run to preview without writing.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const DRY_RUN = process.argv.includes('--dry-run');
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const COMMANDS_DIR = path.join(ROOT, 'commands', 'gsd');
|
||||
|
||||
const AT_PATH_RE = /@(?:~|\$HOME)\/.+?get-shit-done\/[^\s`\)]+/g;
|
||||
|
||||
function transformLine(line) {
|
||||
if (!AT_PATH_RE.test(line)) return line;
|
||||
AT_PATH_RE.lastIndex = 0;
|
||||
|
||||
const trimmed = line.trim();
|
||||
|
||||
// "- @PATH (label)" → "- (label)"
|
||||
if (/^- @(?:~|\$HOME)\//.test(trimmed)) {
|
||||
return line.replace(/^(\s*- )@(?:~|\$HOME)\/[^\s(]+\s*/, '$1');
|
||||
}
|
||||
|
||||
// "**Follow the X workflow** from `@PATH`." → "**Follow the X workflow.**"
|
||||
// "**Follow the X workflow** from `@PATH`" → "**Follow the X workflow.**"
|
||||
if (/\*\*Follow the .+ workflow\*\* from `@/.test(trimmed)) {
|
||||
return line.replace(/\s+from `@(?:~|\$HOME)\/[^`]+`\.?/, '.');
|
||||
}
|
||||
|
||||
// Routing bullet: keep everything except "from @PATH" or bare "@PATH"
|
||||
// "- If …: … from @PATH end-to-end." → strip path, keep bullet
|
||||
// "- Otherwise: … from @PATH end-to-end." → strip path, keep bullet
|
||||
if (/^- (?:If |Otherwise:|pass all)/.test(trimmed)) {
|
||||
return line
|
||||
.replace(/\s+from\s+@(?:~|\$HOME)\/\S+/g, '')
|
||||
.replace(/@(?:~|\$HOME)\/\S+/g, '');
|
||||
}
|
||||
|
||||
// "Execute [the X workflow] [from] @PATH [end-to-end]."
|
||||
// "Read and execute …" / "Follow …" / "Output …"
|
||||
// → collapse to leading indent + "Execute end-to-end."
|
||||
const indent = line.match(/^(\s*)/)[1];
|
||||
return `${indent}Execute end-to-end.`;
|
||||
}
|
||||
|
||||
function processFile(filePath) {
|
||||
const original = fs.readFileSync(filePath, 'utf-8');
|
||||
const lines = original.split('\n');
|
||||
const out = [];
|
||||
let inProse = false; // true when inside <process> or <context> (not execution_context)
|
||||
|
||||
for (const line of lines) {
|
||||
const t = line.trim();
|
||||
if (/<(process|context)>/.test(t) && !t.includes('execution_context')) inProse = true;
|
||||
if (/<\/(process|context)>/.test(t) && !t.includes('execution_context')) inProse = false;
|
||||
|
||||
if (inProse && AT_PATH_RE.test(line)) {
|
||||
AT_PATH_RE.lastIndex = 0;
|
||||
out.push(transformLine(line));
|
||||
} else {
|
||||
out.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
const result = out.join('\n');
|
||||
if (result === original) return false; // no change
|
||||
|
||||
if (!DRY_RUN) fs.writeFileSync(filePath, result, 'utf-8');
|
||||
return true;
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(COMMANDS_DIR)
|
||||
.filter(f => f.endsWith('.md'))
|
||||
.map(f => path.join(COMMANDS_DIR, f));
|
||||
|
||||
let changed = 0;
|
||||
for (const f of files) {
|
||||
if (processFile(f)) {
|
||||
console.log(`${DRY_RUN ? '[dry]' : 'fixed'}: ${path.basename(f)}`);
|
||||
changed++;
|
||||
}
|
||||
}
|
||||
console.log(`\n${changed} file(s) ${DRY_RUN ? 'would be' : 'were'} modified.`);
|
||||
Reference in New Issue
Block a user