diff --git a/scripts/kb-update/apply-verified-stamp.mjs b/scripts/kb-update/apply-verified-stamp.mjs new file mode 100644 index 0000000..87aa553 --- /dev/null +++ b/scripts/kb-update/apply-verified-stamp.mjs @@ -0,0 +1,121 @@ +#!/usr/bin/env node +// apply-verified-stamp.mjs — R7–R10 surgical born-verified stamp driver. +// +// Applies the judge-pass ledger's `pass` verdicts (scripts/kb-eval/data/judge-pass-manifest.json, +// R6 Step 4) to disk: for every record with per_file_verdict='pass', insert/update ONLY the +// **Verified:** and **Verified by:** header lines via transform.insertVerifiedFields — the body +// below the header is byte-identical (NFR non-destruktivt; composeKbFile would rebuild the file +// and diff the body, which is why this driver exists — R6 plan Step 2). `flagged` records are +// never touched. An unstampable file (stamp would land past the 500-byte window — +// insertVerifiedFields throws) is collected as an error and reported; R7 flips that record to +// flagged «header-slanking kreves», never a forced stamp. Other files still stamp (exit 1). +// +// INVARIANT (asserted per file BEFORE any write; breach aborts that file, never writes): +// - body (from first `## `/`---`) byte-identical +// - header minus Verified/Verified by-lines byte-identical (only the stamp lines differ) +// - parseVerifiedHeader/parseVerifiedByHeader read back exactly the stamped values +// Idempotent: a re-run finds the stamp in place and is a no-op. Crash-safe: atomicWriteSync +// (tmp+rename) — a reader sees the old file or the new one, never a partial. +// +// Usage: node scripts/kb-update/apply-verified-stamp.mjs [--manifest ] [--root ] [--write] +// (default: dry-run — prints the plan, writes nothing) +// Exit codes: 0 ok · 1 one or more files failed (unstampable/missing/invariant) · 2 usage + +import { readFileSync, existsSync, realpathSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { insertVerifiedFields } from './lib/transform.mjs'; +import { parseVerifiedHeader, parseVerifiedByHeader } from './lib/kb-headers.mjs'; +import { atomicWriteSync } from './lib/atomic-write.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const PLUGIN_ROOT = join(__dirname, '..', '..'); +const DEFAULT_MANIFEST = join(__dirname, '..', 'kb-eval', 'data', 'judge-pass-manifest.json'); + +/** + * Pure stamp plan for one file: returns { content, changed }. `changed:false` means the exact + * stamp is already in place (idempotent re-run). Throws when the stamp cannot land inside the + * 500-byte header window (caller records the failure — the R7 «header-slanking kreves» path). + */ +export function planVerifiedStamp(content, { verified, verified_by }) { + const stamped = insertVerifiedFields(content, { verified, verified_by }); + return { content: stamped, changed: stamped !== String(content) }; +} + +// Header/body split mirroring strip-stale-verified-pipe.mjs / transform.mjs headerEndIndex. +function splitHeaderBody(content) { + const lines = String(content).split('\n'); + let i = 0; + for (; i < lines.length; i++) { + if (/^##\s/.test(lines[i]) || /^---\s*$/.test(lines[i])) break; + } + return { header: lines.slice(0, i).join('\n'), body: lines.slice(i).join('\n') }; +} + +const RE_STAMP_LINE = /^\*\*Verified(?: by)?:\*\*/i; + +// The hard per-file invariant: only the stamp lines may differ; the body never. +function assertInvariant(rel, oldC, newC, { verified, verified_by }) { + const o = splitHeaderBody(oldC); + const n = splitHeaderBody(newC); + if (o.body !== n.body) throw new Error(`${rel}: body changed — refusing to write`); + const strip = (h) => h.split('\n').filter((l) => !RE_STAMP_LINE.test(l)).join('\n'); + if (strip(o.header) !== strip(n.header)) { + throw new Error(`${rel}: header lines other than Verified/Verified by changed — refusing to write`); + } + if (parseVerifiedHeader(newC) !== verified || parseVerifiedByHeader(newC) !== verified_by) { + throw new Error(`${rel}: stamped values do not read back — refusing to write`); + } +} + +function usageExit(msg) { + process.stderr.write(`${msg}\nusage: apply-verified-stamp.mjs [--manifest ] [--root ] [--write]\n`); + process.exit(2); +} + +function main(argv) { + const args = argv.slice(2); + let manifestPath = DEFAULT_MANIFEST; + let root = PLUGIN_ROOT; + let write = false; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--manifest') { if (!args[i + 1]) usageExit('--manifest requires a path'); manifestPath = args[++i]; } + else if (args[i] === '--root') { if (!args[i + 1]) usageExit('--root requires a dir'); root = args[++i]; } + else if (args[i] === '--write') write = true; + else usageExit(`unknown flag: ${args[i]}`); + } + if (!existsSync(manifestPath)) usageExit(`manifest not found: ${manifestPath}`); + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); + const passRecs = (manifest.files ?? []).filter((r) => r?.per_file_verdict === 'pass'); + + let stamped = 0, noop = 0; + const failed = []; + for (const rec of passRecs) { + const abs = join(root, rec.file); + try { + if (!rec.verified || !rec.verified_by) throw new Error('pass record missing verified/verified_by'); + if (!existsSync(abs)) throw new Error('file not found'); + const oldC = readFileSync(abs, 'utf8'); + const { content: newC, changed } = planVerifiedStamp(oldC, { verified: rec.verified, verified_by: rec.verified_by }); + if (!changed) { noop++; continue; } + assertInvariant(rec.file, oldC, newC, { verified: rec.verified, verified_by: rec.verified_by }); + if (write) atomicWriteSync(abs, newC); + stamped++; + console.log(`${write ? 'stamped' : 'would stamp'} ${rec.file}`); + } catch (e) { + failed.push([rec.file, e.message]); + console.error(`FAILED ${rec.file} — ${e.message}`); + } + } + console.log(`\n${write ? '' : '(dry-run) '}pass records: ${passRecs.length} · stamped: ${write ? stamped : 0}${write ? '' : ` (would stamp: ${stamped})`} · already stamped: ${noop} · failed: ${failed.length}`); + if (failed.length > 0) process.exit(1); +} + +const isMain = (() => { + try { + return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)); + } catch { + return false; + } +})(); +if (isMain) main(process.argv); diff --git a/tests/kb-update/test-apply-verified-stamp.test.mjs b/tests/kb-update/test-apply-verified-stamp.test.mjs new file mode 100644 index 0000000..9b3dc67 --- /dev/null +++ b/tests/kb-update/test-apply-verified-stamp.test.mjs @@ -0,0 +1,128 @@ +// test-apply-verified-stamp.test.mjs — R7.1: the surgical born-verified stamp driver. +// Applies the judge-pass ledger's `pass` verdicts to disk via insertVerifiedFields: +// ONLY the **Verified:** / **Verified by:** header lines are added/updated inside the +// 500-byte window; the body below the header is byte-identical; writes are atomic and +// idempotent; a `flagged` record is never touched; an unstampable file is collected as +// an error (R7 flips it to flagged «header-slanking kreves») without aborting the rest. + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync, readFileSync, mkdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { planVerifiedStamp } from '../../scripts/kb-update/apply-verified-stamp.mjs'; +import { parseVerifiedHeader, parseVerifiedByHeader } from '../../scripts/kb-update/lib/kb-headers.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const CLI = join(__dirname, '..', '..', 'scripts', 'kb-update', 'apply-verified-stamp.mjs'); + +const FILE_A = `# Testfil A + +**Last updated:** 2026-06 +**Status:** GA +**Category:** Test +**Type:** reference +**Source:** https://learn.microsoft.com/test-a + +--- + +## Innhold + +Brødtekst som aldri skal endres. +`; + +// Header so fat the stamp insertion point lands at ~byte 488 — the two stamp lines then land +// past the 500-byte window and insertVerifiedFields refuses (header-slanking required). +const FILE_FAT = `# Testfil ${'x'.repeat(49)} + +**Last updated:** 2026-06 ${'y'.repeat(400)} + +--- + +## Body +`; + +function withTmp(fn) { + const dir = mkdtempSync(join(tmpdir(), 'avs-')); + try { return fn(dir); } finally { rmSync(dir, { recursive: true, force: true }); } +} + +function bodyOf(content) { + const lines = content.split('\n'); + const i = lines.findIndex((l) => /^##\s/.test(l) || /^---\s*$/.test(l)); + return lines.slice(i).join('\n'); +} + +test('planVerifiedStamp stamps both fields in-window, body byte-identical, idempotent re-plan', () => { + const { content: stamped, changed } = planVerifiedStamp(FILE_A, { verified: '2026-07-18', verified_by: 'judge-v3.1' }); + assert.equal(changed, true); + assert.equal(parseVerifiedHeader(stamped), '2026-07-18'); + assert.equal(parseVerifiedByHeader(stamped), 'judge-v3.1'); + assert.equal(bodyOf(stamped), bodyOf(FILE_A)); + const again = planVerifiedStamp(stamped, { verified: '2026-07-18', verified_by: 'judge-v3.1' }); + assert.equal(again.changed, false); + assert.equal(again.content, stamped); +}); + +function ledgerWith(files) { + return { + _meta: { purpose: 'test', derived_from: 'test', cadence: 'test', batch: 1, count: files.length, generated: '2026-07-18' }, + files, + }; +} + +function passRec(file) { + return { file, batch: 1, judged_at: '2026-07-18', per_file_verdict: 'pass', claim_count: 1, verified: '2026-07-18', verified_by: 'judge-v3.1', flags: [] }; +} + +test('CLI --write stamps pass records, skips flagged, is idempotent on re-run', () => { + withTmp((dir) => { + mkdirSync(join(dir, 'skills'), { recursive: true }); + writeFileSync(join(dir, 'skills', 'a.md'), FILE_A); + writeFileSync(join(dir, 'skills', 'b.md'), FILE_A); + const ledger = ledgerWith([ + passRec('skills/a.md'), + { file: 'skills/b.md', batch: 1, judged_at: '2026-07-18', per_file_verdict: 'flagged', claim_count: 1, verified: null, verified_by: null, + flags: [{ id: 'b.md#1', judge_verdict: 'not_grounded', rule: 'R1', evidence_url: '', evidence_quote: '', reason: 'x', file: 'skills/b.md', line: 3, claim: 'c', disposition: 'outdated' }] }, + ]); + const mPath = join(dir, 'ledger.json'); + writeFileSync(mPath, JSON.stringify(ledger)); + + // Dry-run: nothing written. + execFileSync('node', [CLI, '--manifest', mPath, '--root', dir], { encoding: 'utf8' }); + assert.equal(readFileSync(join(dir, 'skills', 'a.md'), 'utf8'), FILE_A); + + const out1 = execFileSync('node', [CLI, '--manifest', mPath, '--root', dir, '--write'], { encoding: 'utf8' }); + assert.match(out1, /stamped: 1/); + const stamped = readFileSync(join(dir, 'skills', 'a.md'), 'utf8'); + assert.equal(parseVerifiedHeader(stamped), '2026-07-18'); + assert.equal(bodyOf(stamped), bodyOf(FILE_A)); + // Flagged never touched. + assert.equal(readFileSync(join(dir, 'skills', 'b.md'), 'utf8'), FILE_A); + // Idempotent re-run: no change. + const out2 = execFileSync('node', [CLI, '--manifest', mPath, '--root', dir, '--write'], { encoding: 'utf8' }); + assert.match(out2, /stamped: 0/); + assert.equal(readFileSync(join(dir, 'skills', 'a.md'), 'utf8'), stamped); + }); +}); + +test('CLI collects an unstampable pass record as error (exit 1) but still stamps the rest', () => { + withTmp((dir) => { + mkdirSync(join(dir, 'skills'), { recursive: true }); + writeFileSync(join(dir, 'skills', 'ok.md'), FILE_A); + writeFileSync(join(dir, 'skills', 'fat.md'), FILE_FAT); + const mPath = join(dir, 'ledger.json'); + writeFileSync(mPath, JSON.stringify(ledgerWith([passRec('skills/ok.md'), passRec('skills/fat.md')]))); + let failed = null; + try { + execFileSync('node', [CLI, '--manifest', mPath, '--root', dir, '--write'], { encoding: 'utf8', stdio: 'pipe' }); + } catch (e) { failed = e; } + assert.ok(failed, 'expected exit 1'); + assert.equal(failed.status, 1); + assert.match(String(failed.stdout) + String(failed.stderr), /fat\.md/); + // The stampable sibling was still stamped. + assert.equal(parseVerifiedHeader(readFileSync(join(dir, 'skills', 'ok.md'), 'utf8')), '2026-07-18'); + }); +});