paqad-ai
Version:
Spec-driven development framework — AI agents that think before they type
81 lines (71 loc) • 2.57 kB
JavaScript
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
const MAX_ERRORS = 100;
function stopWith(code, message) {
process.stderr.write(`check-spec.mjs: ${message}\n`);
process.exitCode = code;
}
function readInput(file, expected) {
try {
return readFileSync(file, 'utf8');
} catch {
stopWith(2, `file not found: ${file}. Expected ${expected}.`);
return null;
}
}
function finish(input, errors, extra = {}) {
const hash = createHash('sha256')
.update(JSON.stringify({ input, errors, ...extra }))
.digest('hex');
const shown =
errors.length > MAX_ERRORS
? [...errors.slice(0, MAX_ERRORS), `and ${errors.length - MAX_ERRORS} more`]
: errors;
process.stdout.write(
`${JSON.stringify({ ok: errors.length === 0, errors: shown, hash, ...extra })}\n`,
);
process.exitCode = errors.length === 0 ? 0 : 1;
}
const CRITERION = /^-\s*(AC-[^:\s]+):\s*(.*)$/u;
const FLAT_ID = /^AC-\d+$/u;
function section(lines, heading) {
const start = lines.findIndex((line) => line.trim().toLowerCase() === heading);
if (start === -1) return null;
const rest = lines.slice(start + 1);
const end = rest.findIndex((line) => line.startsWith('## '));
return end === -1 ? rest : rest.slice(0, end);
}
function check(text) {
const errors = [];
const lines = text.split('\n');
const criteria = section(lines, '## acceptance criteria');
if (criteria === null) return ['the spec has no "## Acceptance criteria" section'];
const seen = new Set();
let found = 0;
for (const line of criteria) {
const match = CRITERION.exec(line);
if (!match) continue;
found += 1;
const [, id, body] = match;
if (!FLAT_ID.test(id)) errors.push(`${id} is not a flat AC-N id`);
if (seen.has(id)) errors.push(`${id} appears twice`);
seen.add(id);
if (!/\bgiven\b/iu.test(body) || !/\bwhen\b/iu.test(body) || !/\bthen\b/iu.test(body)) {
errors.push(`${id} is not in given, when, then form`);
}
}
if (found === 0) errors.push('the spec has no AC-N criteria');
if (section(lines, '## edge cases') === null && !text.includes('No Additional Edge Cases')) {
errors.push('the spec has no "## Edge cases" section');
}
return errors;
}
const file = process.argv[2];
if (!file || file === '--help') {
process.stdout.write('usage: check-spec.mjs <spec.md>\nprints { ok, errors, hash }\n');
process.exitCode = file ? 0 : 2;
} else {
const text = readInput(file, 'a spec Markdown file');
if (text !== null) finish(text, check(text));
}