Merge pull request #3152 from gsd-build/adr/0002-command-contract-validation-module
enh(#3151): command contract validation module + prose @-ref cleanup + workflow extraction (ADR-0002)
This commit is contained in:
114
tests/command-contract.test.cjs
Normal file
114
tests/command-contract.test.cjs
Normal file
@@ -0,0 +1,114 @@
|
||||
// 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(', '),
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -29,7 +29,7 @@ describe('debug session management implementation', () => {
|
||||
|
||||
test('debug command contains list subcommand logic', () => {
|
||||
const content = fs.readFileSync(
|
||||
path.join(process.cwd(), 'commands/gsd/debug.md'),
|
||||
path.join(process.cwd(), 'get-shit-done/workflows/debug.md'),
|
||||
'utf8'
|
||||
);
|
||||
assert.ok(
|
||||
@@ -40,7 +40,7 @@ describe('debug session management implementation', () => {
|
||||
|
||||
test('debug command contains continue subcommand logic', () => {
|
||||
const content = fs.readFileSync(
|
||||
path.join(process.cwd(), 'commands/gsd/debug.md'),
|
||||
path.join(process.cwd(), 'get-shit-done/workflows/debug.md'),
|
||||
'utf8'
|
||||
);
|
||||
assert.ok(
|
||||
@@ -51,7 +51,7 @@ describe('debug session management implementation', () => {
|
||||
|
||||
test('debug command contains status subcommand logic', () => {
|
||||
const content = fs.readFileSync(
|
||||
path.join(process.cwd(), 'commands/gsd/debug.md'),
|
||||
path.join(process.cwd(), 'get-shit-done/workflows/debug.md'),
|
||||
'utf8'
|
||||
);
|
||||
assert.ok(
|
||||
@@ -62,7 +62,7 @@ describe('debug session management implementation', () => {
|
||||
|
||||
test('debug command contains TDD gate logic', () => {
|
||||
const content = fs.readFileSync(
|
||||
path.join(process.cwd(), 'commands/gsd/debug.md'),
|
||||
path.join(process.cwd(), 'get-shit-done/workflows/debug.md'),
|
||||
'utf8'
|
||||
);
|
||||
assert.ok(
|
||||
@@ -73,7 +73,7 @@ describe('debug session management implementation', () => {
|
||||
|
||||
test('debug.md reads tdd_mode via workflow.tdd_mode key (not bare tdd_mode)', () => {
|
||||
const content = fs.readFileSync(
|
||||
path.join(process.cwd(), 'commands/gsd/debug.md'),
|
||||
path.join(process.cwd(), 'get-shit-done/workflows/debug.md'),
|
||||
'utf8'
|
||||
);
|
||||
assert.ok(
|
||||
@@ -88,7 +88,7 @@ describe('debug session management implementation', () => {
|
||||
|
||||
test('debug command contains security hardening', () => {
|
||||
const content = fs.readFileSync(
|
||||
path.join(process.cwd(), 'commands/gsd/debug.md'),
|
||||
path.join(process.cwd(), 'get-shit-done/workflows/debug.md'),
|
||||
'utf8'
|
||||
);
|
||||
assert.ok(content.includes('DATA_START'), 'debug.md must contain DATA_START injection boundary marker');
|
||||
@@ -96,7 +96,7 @@ describe('debug session management implementation', () => {
|
||||
|
||||
test('debug command surfaces next_action before spawn', () => {
|
||||
const content = fs.readFileSync(
|
||||
path.join(process.cwd(), 'commands/gsd/debug.md'),
|
||||
path.join(process.cwd(), 'get-shit-done/workflows/debug.md'),
|
||||
'utf8'
|
||||
);
|
||||
assert.ok(
|
||||
@@ -148,13 +148,13 @@ describe('debug skill dispatch and sub-orchestrator (#2148, #2151)', () => {
|
||||
});
|
||||
|
||||
test('debug.md orchestrator has specialist skill dispatch step', () => {
|
||||
const content = fs.readFileSync(path.join(process.cwd(), 'commands', 'gsd', 'debug.md'), 'utf8');
|
||||
const content = fs.readFileSync(path.join(process.cwd(), 'get-shit-done/workflows/debug.md'), 'utf8');
|
||||
assert.ok(content.includes('specialist_hint'), 'debug.md missing specialist dispatch logic');
|
||||
assert.ok(content.includes('typescript-expert'), 'debug.md missing skill dispatch mapping');
|
||||
});
|
||||
|
||||
test('debug.md specialist dispatch prompt uses DATA_START/DATA_END boundaries', () => {
|
||||
const content = fs.readFileSync(path.join(process.cwd(), 'commands', 'gsd', 'debug.md'), 'utf8');
|
||||
const content = fs.readFileSync(path.join(process.cwd(), 'get-shit-done/workflows/debug.md'), 'utf8');
|
||||
assert.ok(content.includes('DATA_START') && content.includes('DATA_END'),
|
||||
'debug.md specialist dispatch prompt missing security boundaries');
|
||||
});
|
||||
@@ -182,7 +182,7 @@ describe('debug skill dispatch and sub-orchestrator (#2148, #2151)', () => {
|
||||
});
|
||||
|
||||
test('debug.md delegates to gsd-debug-session-manager', () => {
|
||||
const content = fs.readFileSync(path.join(process.cwd(), 'commands', 'gsd', 'debug.md'), 'utf8');
|
||||
const content = fs.readFileSync(path.join(process.cwd(), 'get-shit-done/workflows/debug.md'), 'utf8');
|
||||
assert.ok(content.includes('gsd-debug-session-manager'),
|
||||
'debug.md does not delegate to session manager');
|
||||
});
|
||||
|
||||
@@ -200,20 +200,20 @@ describe('enh-2430 Part B — graduation.md helper workflow', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('enh-2430 — extract_learnings.md graduated: field', () => {
|
||||
test('extract_learnings.md documents optional graduated: annotation', () => {
|
||||
const content = readWorkflow('extract_learnings.md');
|
||||
describe('enh-2430 — extract-learnings.md graduated: field', () => {
|
||||
test('extract-learnings.md documents optional graduated: annotation', () => {
|
||||
const content = readWorkflow('extract-learnings.md');
|
||||
assert.ok(
|
||||
content.includes('graduated:') || content.includes('Graduated:'),
|
||||
'extract_learnings.md must document optional graduated: field'
|
||||
'extract-learnings.md must document optional graduated: field'
|
||||
);
|
||||
});
|
||||
|
||||
test('extract_learnings.md clarifies graduated: is written only by graduation workflow', () => {
|
||||
const content = readWorkflow('extract_learnings.md');
|
||||
test('extract-learnings.md clarifies graduated: is written only by graduation workflow', () => {
|
||||
const content = readWorkflow('extract-learnings.md');
|
||||
assert.ok(
|
||||
content.includes('graduation workflow') || content.includes('graduation.md'),
|
||||
'extract_learnings.md must clarify that graduated: is written only by graduation.md'
|
||||
'extract-learnings.md must clarify that graduated: is written only by graduation.md'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,7 +17,7 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const COMMAND_PATH = path.join(__dirname, '..', 'commands', 'gsd', 'extract-learnings.md');
|
||||
const WORKFLOW_PATH = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'extract_learnings.md');
|
||||
const WORKFLOW_PATH = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'extract-learnings.md');
|
||||
|
||||
describe('extract-learnings command', () => {
|
||||
test('command file exists', () => {
|
||||
@@ -59,15 +59,15 @@ describe('extract-learnings command', () => {
|
||||
test('command references the workflow via execution_context', () => {
|
||||
const content = fs.readFileSync(COMMAND_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('workflows/extract_learnings.md'),
|
||||
'Command must reference workflows/extract_learnings.md in execution_context'
|
||||
content.includes('workflows/extract-learnings.md'),
|
||||
'Command must reference workflows/extract-learnings.md in execution_context'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extract-learnings workflow', () => {
|
||||
test('workflow file exists', () => {
|
||||
assert.ok(fs.existsSync(WORKFLOW_PATH), 'workflows/extract_learnings.md should exist');
|
||||
assert.ok(fs.existsSync(WORKFLOW_PATH), 'workflows/extract-learnings.md should exist');
|
||||
});
|
||||
|
||||
test('workflow has objective tag', () => {
|
||||
@@ -86,6 +86,10 @@ describe('extract-learnings workflow', () => {
|
||||
const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
|
||||
assert.ok(content.includes('<step name='), 'Workflow must have named step tags');
|
||||
assert.ok(content.includes('</step>'), 'Workflow must close step tags');
|
||||
assert.ok(
|
||||
content.includes('<step name="extract-learnings">'),
|
||||
'Workflow step must use hyphen convention: <step name="extract-learnings">',
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow has success_criteria tag', () => {
|
||||
|
||||
@@ -11,7 +11,7 @@ const path = require('path');
|
||||
|
||||
describe('thread session management (#2156)', () => {
|
||||
const threadCmd = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'commands', 'gsd', 'thread.md'),
|
||||
path.join(__dirname, '..', 'get-shit-done', 'workflows', 'thread.md'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user