91 lines
3.1 KiB
JavaScript
91 lines
3.1 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* lint-pieces: enforces that no piece YAML uses `COMPLETE` / `ABORT` / `ASK`
|
|
* as `transition.rules[].next` values. Terminal moves go through the
|
|
* `complete` tool (Phase 6 design).
|
|
*
|
|
* `default_next: COMPLETE` is allowed — that's an engine-internal sentinel
|
|
* used by context-overflow / ASK-limit / SpawnSubTask-unavailable fallback
|
|
* paths and is never exposed to the LLM.
|
|
*
|
|
* Usage:
|
|
* node scripts/lint-pieces.mjs # lint all pieces/*.yaml
|
|
* node scripts/lint-pieces.mjs path/x.yaml # lint specific files
|
|
*
|
|
* Exit codes:
|
|
* 0 — no violations
|
|
* 1 — at least one piece uses a banned terminal in rules[].next
|
|
*
|
|
* History:
|
|
* - Phase 6a-1: introduced ALLOWLIST mechanism for the 11 unmigrated pieces
|
|
* - Phase 6a-2: ALLOWLIST drained to empty as pieces migrated
|
|
* - Phase 6b: ALLOWLIST mechanism removed; violations are always hard fails
|
|
*/
|
|
|
|
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { parse } from 'yaml';
|
|
|
|
const TERMINAL_NEXTS = new Set(['COMPLETE', 'ABORT', 'ASK']);
|
|
|
|
function collectPieceFiles(args) {
|
|
if (args.length > 0) return args;
|
|
const dir = 'pieces';
|
|
if (!existsSync(dir)) {
|
|
console.error(`[lint-pieces] directory not found: ${dir}`);
|
|
process.exit(2);
|
|
}
|
|
return readdirSync(dir).filter((f) => f.endsWith('.yaml')).map((f) => join(dir, f));
|
|
}
|
|
|
|
function findTerminalNexts(piece) {
|
|
const offenders = [];
|
|
if (!piece || typeof piece !== 'object') return offenders;
|
|
const movements = Array.isArray(piece.movements) ? piece.movements : [];
|
|
for (const movement of movements) {
|
|
const rules = Array.isArray(movement?.rules) ? movement.rules : [];
|
|
for (const rule of rules) {
|
|
if (rule && typeof rule.next === 'string' && TERMINAL_NEXTS.has(rule.next)) {
|
|
offenders.push({
|
|
movement: String(movement?.name ?? '<unnamed>'),
|
|
condition: String(rule.condition ?? ''),
|
|
next: rule.next,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
return offenders;
|
|
}
|
|
|
|
function main() {
|
|
const files = collectPieceFiles(process.argv.slice(2));
|
|
const violations = [];
|
|
|
|
for (const filePath of files) {
|
|
let parsed;
|
|
try {
|
|
parsed = parse(readFileSync(filePath, 'utf-8'));
|
|
} catch (e) {
|
|
console.error(`[lint-pieces] failed to parse ${filePath}: ${e.message}`);
|
|
process.exit(2);
|
|
}
|
|
const offenders = findTerminalNexts(parsed);
|
|
for (const offender of offenders) {
|
|
violations.push({ file: filePath, ...offender });
|
|
}
|
|
}
|
|
|
|
if (violations.length > 0) {
|
|
console.error('\n[lint-pieces] ❌ pieces must not use COMPLETE/ABORT/ASK in rules[].next.');
|
|
console.error('Use the `complete` tool for terminal moves (status: success | aborted | needs_user_input).');
|
|
console.error('See docs/plans/2026-05-01-phase-6a-complete-tool.md.\n');
|
|
for (const v of violations) {
|
|
console.error(` ${v.file} → movement="${v.movement}" rule.next="${v.next}" condition="${v.condition}"`);
|
|
}
|
|
process.exit(1);
|
|
}
|
|
console.log(`[lint-pieces] ✓ ${files.length} piece(s) checked, no violations`);
|
|
}
|
|
|
|
main();
|