201 lines
9 KiB
JavaScript
Executable file
201 lines
9 KiB
JavaScript
Executable file
#!/usr/bin/env node
|
||
// judge-pass-manifest.mjs — R7–R10 full-pass judge LEDGER (Spor 3 pre-pass infra, R6 Step 4).
|
||
//
|
||
// The durable per-file record of the corpus judge pass: one entry per never-verified reference
|
||
// file, carrying its per-file verdict (born-verified `pass` → surgical stamp, or `flagged` →
|
||
// R11 fix work-list), the judge provenance, and any not-grounded claim flags. Mirrors
|
||
// scripts/kb-eval/data/spor0-fix-manifest.json in shape ({_meta, <records[]>}) and discipline
|
||
// (git-tracked under data/ so drift is diff-reviewable; hand/subagent-maintained).
|
||
//
|
||
// Durability + resume are the whole point (brief NFR): R7 calls appendJudgedFile after EVERY
|
||
// judged file and writes atomically, so a crash on file 200/243 loses at most ONE file; a
|
||
// resume calls pendingFiles(manifest, worklist) and re-processes only the remainder. The
|
||
// aggregate-in-memory-write-once alternative loses a whole økt on a crash — rejected.
|
||
//
|
||
// Pure core: validateManifest / mergeBatch / appendJudgedFile / pendingFiles are disk-free and
|
||
// deterministic (path-sorted output). IO-shell: --json / --write [--merge] [--out <path>].
|
||
//
|
||
// Exit codes: 0 ok · 2 usage error · 3 re-entrance guard (judged ledger, no --merge)
|
||
|
||
import { readFileSync, existsSync, realpathSync } from 'node:fs';
|
||
import { join, dirname } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { atomicWriteJson } from '../kb-update/lib/atomic-write.mjs';
|
||
|
||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||
|
||
// Pinned: the single git-tracked ledger downstream R7–R10 batches append to (verified NOT
|
||
// under .gitignore, so the judged ledger is review-visible — brief preference).
|
||
export const DEFAULT_MANIFEST_PATH = join(__dirname, 'data', 'judge-pass-manifest.json');
|
||
|
||
// A file's per-pass verdict: `pass` = every judgeable claim grounded (AND) → born-verified
|
||
// stamp; `flagged` = ≥1 not-grounded claim OR unstampable → R11 work-list. Nothing else.
|
||
export const VALID_VERDICTS = ['pass', 'flagged'];
|
||
|
||
const KNOWN_FLAGS = new Set(['--json', '--write', '--merge', '--out']);
|
||
|
||
const byFilePath = (a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0);
|
||
|
||
/** A fresh, empty ledger scaffold. Deterministic (no live timestamp) so --write is stable. */
|
||
export function scaffoldManifest() {
|
||
return {
|
||
_meta: {
|
||
purpose:
|
||
'R7–R10 full-pass judge ledger: one record per never-verified reference file — ' +
|
||
'born-verified pass (surgical stamp) or flagged (R11 work-list). Durable per file.',
|
||
derived_from: 'scripts/kb-update/data/full-pass-worklist.json (243 due, all never-verified)',
|
||
cadence: 'R7–R10 (5 økter × ~49, 8–10 samtidige)',
|
||
batch: null,
|
||
count: 0,
|
||
generated: null,
|
||
},
|
||
files: [],
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Validate the ledger shape. Pure. Returns {valid, errors[]}. Enforces the born-verified
|
||
* contract at rest: a `pass` record MUST carry verified + verified_by; a `flagged` record MUST
|
||
* carry ≥1 flag. Duplicate file records are an error (one record per file).
|
||
*/
|
||
export function validateManifest(manifest) {
|
||
const errors = [];
|
||
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
|
||
return { valid: false, errors: ['manifest must be an object'] };
|
||
}
|
||
if (!manifest._meta || typeof manifest._meta !== 'object') errors.push('_meta must be an object');
|
||
if (!Array.isArray(manifest.files)) {
|
||
errors.push('files must be an array');
|
||
return { valid: false, errors };
|
||
}
|
||
const seen = new Set();
|
||
for (const rec of manifest.files) {
|
||
if (!rec || typeof rec !== 'object') { errors.push('file record must be an object'); continue; }
|
||
if (typeof rec.file !== 'string' || rec.file.trim() === '') { errors.push("record missing 'file'"); continue; }
|
||
if (seen.has(rec.file)) errors.push(`duplicate file record: ${rec.file}`);
|
||
seen.add(rec.file);
|
||
if (!VALID_VERDICTS.includes(rec.per_file_verdict)) {
|
||
errors.push(`${rec.file}: per_file_verdict must be one of ${VALID_VERDICTS.join('|')}`);
|
||
}
|
||
if (!Array.isArray(rec.flags)) errors.push(`${rec.file}: flags must be an array`);
|
||
if (rec.per_file_verdict === 'pass' && (!rec.verified || !rec.verified_by)) {
|
||
errors.push(`${rec.file}: a pass record must carry verified + verified_by`);
|
||
}
|
||
if (rec.per_file_verdict === 'flagged' && !(Array.isArray(rec.flags) && rec.flags.length > 0)) {
|
||
errors.push(`${rec.file}: a flagged record must carry ≥1 flag`);
|
||
}
|
||
}
|
||
return { valid: errors.length === 0, errors };
|
||
}
|
||
|
||
/**
|
||
* Add or update ONE file record, returning a NEW manifest (pure). The durability primitive:
|
||
* R7 calls this after each judged file, then writes atomically → max one file lost on crash.
|
||
* An existing record for the same `file` is replaced (idempotent re-judge). Files stay
|
||
* path-sorted and _meta.count is refreshed, so the on-disk ledger is deterministic + diff-clean.
|
||
*/
|
||
export function appendJudgedFile(manifest, record) {
|
||
if (!record || typeof record.file !== 'string' || record.file.trim() === '') {
|
||
throw new Error('appendJudgedFile: record.file is required');
|
||
}
|
||
const base = manifest && typeof manifest === 'object' && !Array.isArray(manifest) ? manifest : scaffoldManifest();
|
||
const kept = (Array.isArray(base.files) ? base.files : []).filter((r) => r.file !== record.file);
|
||
const files = [...kept, record].sort(byFilePath);
|
||
return { ...base, _meta: { ...(base._meta ?? {}), count: files.length }, files };
|
||
}
|
||
|
||
/**
|
||
* Merge a batch of file records into an existing ledger, preserving already-judged records
|
||
* verbatim (a judged entry is authoritative — a re-run never silently re-judges it). Records
|
||
* for not-yet-judged files are added/updated. Pure; output path-sorted, _meta.count refreshed.
|
||
* @param {object} existing — the current ledger
|
||
* @param {Array|object} incoming — a batch (array of records, or a manifest with files[])
|
||
*/
|
||
export function mergeBatch(existing, incoming) {
|
||
const existingFiles = Array.isArray(existing?.files) ? existing.files : [];
|
||
const judged = new Set(
|
||
existingFiles.filter((r) => VALID_VERDICTS.includes(r?.per_file_verdict)).map((r) => r.file),
|
||
);
|
||
const byFile = new Map(existingFiles.map((r) => [r.file, r]));
|
||
const incomingFiles = Array.isArray(incoming) ? incoming : Array.isArray(incoming?.files) ? incoming.files : [];
|
||
for (const rec of incomingFiles) {
|
||
if (!rec || typeof rec.file !== 'string') continue;
|
||
if (judged.has(rec.file)) continue; // judged wins — preserve verbatim
|
||
byFile.set(rec.file, rec);
|
||
}
|
||
const files = [...byFile.values()].sort(byFilePath);
|
||
return { ...(existing ?? {}), _meta: { ...(existing?._meta ?? {}), count: files.length }, files };
|
||
}
|
||
|
||
/**
|
||
* Resume primitive: given the full worklist of file paths, return those NOT yet judged (no
|
||
* ledger record with a per_file_verdict set). R7 resume processes only these — a re-run after
|
||
* a crash skips every already-judged file. Pure; preserves worklist order.
|
||
*/
|
||
export function pendingFiles(manifest, worklistPaths) {
|
||
const judged = new Set(
|
||
(manifest?.files ?? [])
|
||
.filter((r) => r && VALID_VERDICTS.includes(r.per_file_verdict))
|
||
.map((r) => r.file),
|
||
);
|
||
return (worklistPaths ?? []).filter((p) => !judged.has(p));
|
||
}
|
||
|
||
function hasJudged(manifest) {
|
||
return Array.isArray(manifest?.files) && manifest.files.some((r) => VALID_VERDICTS.includes(r?.per_file_verdict));
|
||
}
|
||
|
||
function usageExit(msg, code) {
|
||
process.stderr.write(`${msg}\nusage: judge-pass-manifest.mjs [--json] [--write] [--merge] [--out <path>]\n`);
|
||
process.exit(code);
|
||
}
|
||
|
||
function main(argv) {
|
||
const args = argv.slice(2);
|
||
let outPath = DEFAULT_MANIFEST_PATH;
|
||
for (let i = 0; i < args.length; i++) {
|
||
if (args[i] === '--out') {
|
||
if (!args[i + 1]) usageExit('--out requires a path', 2);
|
||
outPath = args[++i];
|
||
continue;
|
||
}
|
||
if (!KNOWN_FLAGS.has(args[i])) usageExit(`unknown flag: ${args[i]}`, 2);
|
||
}
|
||
const json = args.includes('--json');
|
||
const write = args.includes('--write');
|
||
const merge = args.includes('--merge');
|
||
|
||
const existing = existsSync(outPath) ? JSON.parse(readFileSync(outPath, 'utf8')) : null;
|
||
if (existing) {
|
||
const check = validateManifest(existing);
|
||
if (!check.valid) usageExit(`invalid manifest at ${outPath}:\n ${check.errors.join('\n ')}`, 2);
|
||
}
|
||
let manifest = existing ?? scaffoldManifest();
|
||
|
||
if (write) {
|
||
if (existing && hasJudged(existing) && !merge) {
|
||
process.stderr.write(
|
||
`refusing --write: ledger at ${outPath} already carries judged record(s); a fresh write ` +
|
||
'would clobber the judged pass. Re-run with --merge to preserve it.\n',
|
||
);
|
||
process.exit(3);
|
||
}
|
||
manifest = mergeBatch(manifest, []); // normalize: path-sort + refresh count, judged preserved
|
||
atomicWriteJson(outPath, manifest);
|
||
}
|
||
|
||
if (json) {
|
||
process.stdout.write(JSON.stringify(manifest, null, 2) + '\n');
|
||
} else {
|
||
console.log(`judge-pass-manifest: ${manifest.files.length} record(s)` + (write ? ` → ${outPath}` : ''));
|
||
}
|
||
}
|
||
|
||
const isMain = (() => {
|
||
try {
|
||
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
||
} catch {
|
||
return false;
|
||
}
|
||
})();
|
||
if (isMain) main(process.argv);
|