Files
get-shit-done/tests/command-contract.test.cjs
Tom Boucher a411e08e88 fix(coderabbit): resolve all 12 findings on PR #3152
MAJOR (security/correctness):
- commands/gsd/debug.md: add Write to allowed-tools (session file creation
  requires it — workflow explicitly says 'use Write tool, never heredoc')
- workflows/debug.md: add SLUG sanitization guard to steps 1b+1c (status/
  continue subcommands used raw user input in file paths — path traversal)
- workflows/thread.md: sanitize $ARGUMENTS in RESUME mode before file path
  construction (was bypassing the sanitization guard in CLOSE/STATUS modes)

MINOR (consistency/correctness):
- docs/INVENTORY-MANIFEST.json: remove stale top-level 'workflows' array
  (duplicate of families.workflows introduced in earlier update)
- commands/gsd/resume-work.md: normalize process to 'Execute end-to-end.'
- commands/gsd/settings.md: normalize process to 'Execute end-to-end.'
- commands/gsd/update.md: normalize otherwise branch to 'execute end-to-end.'
- docs/adr/0002: add Status: Accepted + Date header (ADR convention)
- workflows/extract-learnings.md: rename step extract_learnings → extract-learnings
- tests/extract-learnings.test.cjs: tighten step-name assertion to exact name

ARCHITECTURE:
- scripts/command-contract-helpers.cjs: extract CANONICAL_TOOLS, parseFrontmatter,
  executionContextRefs as shared module — single source of truth consumed by
  both lint script and test suite (prevents silent lint/test disagreement)
- scripts/lint-command-contract.cjs: require() helpers instead of duplicating
- tests/command-contract.test.cjs: require() helpers; move readFileSync calls
  inside test() callbacks (registration-time throws surface as named failures)
2026-05-05 16:06:29 -04:00

115 lines
4.3 KiB
JavaScript

// allow-test-rule: source-text-is-the-product — commands/gsd/*.md files ARE the
// deployed skill surface. Testing their contract tests the runtime behaviour.
'use strict';
/**
* Command Contract tests (ADR-0002)
*
* Authoritative behavioral contract for every commands/gsd/*.md file.
* Replaces scattered coverage in enh-2790-skill-consolidation and
* bug-3135-capture-backlog-workflow for the full-surface contract checks.
*
* Contract:
* 1. name: present, non-empty, starts with gsd: or gsd-
* 2. description: present, non-empty
* 3. allowed-tools: present, non-empty, all entries from CANONICAL_TOOLS
* 4. execution_context @-refs: every reference resolves to an existing file
* 5. execution_context @-refs: each on its own line (no trailing prose)
*/
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.join(__dirname, '..');
const COMMANDS_DIR = path.join(ROOT, 'commands', 'gsd');
const GSD_ROOT = path.join(ROOT, 'get-shit-done');
const {
CANONICAL_TOOLS,
parseFrontmatter,
executionContextRefs,
} = require('../scripts/command-contract-helpers.cjs');
const commandFiles = fs
.readdirSync(COMMANDS_DIR)
.filter(f => f.endsWith('.md'))
.map(f => ({ name: f, full: path.join(COMMANDS_DIR, f) }));
// ─── contract tests ───────────────────────────────────────────────────────────
describe('command contract: name field (ADR-0002)', () => {
for (const { name, full } of commandFiles) {
test(`${name}: name: present and starts with gsd: or gsd-`, () => {
const fm = parseFrontmatter(fs.readFileSync(full, 'utf-8'));
assert.ok(fm.name && fm.name.trim(), `${name}: name: field missing or empty`);
assert.ok(
/^gsd[:-]/.test(fm.name.trim()),
`${name}: name: must start with "gsd:" or "gsd-", got "${fm.name.trim()}"`,
);
});
}
});
describe('command contract: description field (ADR-0002)', () => {
for (const { name, full } of commandFiles) {
test(`${name}: description: present and non-empty`, () => {
const fm = parseFrontmatter(fs.readFileSync(full, 'utf-8'));
assert.ok(
fm.description && fm.description.trim(),
`${name}: description: field missing or empty`,
);
});
}
});
describe('command contract: allowed-tools (ADR-0002)', () => {
for (const { name, full } of commandFiles) {
test(`${name}: allowed-tools: present, non-empty, all canonical`, () => {
const fm = parseFrontmatter(fs.readFileSync(full, 'utf-8'));
assert.ok(
fm['allowed-tools'] && fm['allowed-tools'].trim(),
`${name}: allowed-tools: block missing or empty`,
);
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__*'));
assert.ok(valid, `${name}: unknown tool "${tool}" in allowed-tools`);
}
});
}
});
describe('command contract: execution_context @-refs resolve (ADR-0002)', () => {
for (const { name, full } of commandFiles) {
test(`${name}: all execution_context @-refs exist on disk`, () => {
const refs = executionContextRefs(fs.readFileSync(full, 'utf-8'));
for (const { normalized } of refs) {
assert.ok(
fs.existsSync(path.join(GSD_ROOT, normalized)),
`${name}: execution_context @-ref "${normalized}" does not exist — ` +
'create the file or remove the reference',
);
}
});
}
});
describe('command contract: execution_context @-refs on own line (ADR-0002)', () => {
for (const { name, full } of commandFiles) {
test(`${name}: no @-refs with trailing prose in execution_context`, () => {
const refs = executionContextRefs(fs.readFileSync(full, 'utf-8'));
const bad = refs.filter(r => r.trailingProse);
assert.equal(
bad.length, 0,
`${name}: @-refs with trailing prose in execution_context: ` +
bad.map(r => r.token).join(', '),
);
});
}
});