ktg-plugin-marketplace/scripts/build-rollup-register.mjs
Kjell Tore Guttormsen ba844976e9 feat(catalog): add state roll-up register builder (STEG 0 wiring)
Wire the two-output roll-up register per the ratified D2 contract
(~/.claude/coord/register.md; commons c66ccc3 + status vocab fe6b998),
resolving the AS#5 gate the coord notice handed catalog.

- Output A: rich LOCAL roll-up grepped from ~/repos/*/STATE.md markers.
- Output B: public status-token index, a projection of A, opt-in via a
  committed .rollup-carrier; absent/private modes excluded by design.
- V4 public-safety gate: a carrier line must be exactly "topic: <token>"
  (no prose, no em-dash, known token) or it is blocked from B.
- V5 drift-warn: committed-carrier status != STATE status -> warn; STATE wins.

Pure-function core + thin ~/repos CLI shell, zero deps, catalog script
pattern. 22/22 tests over the ratified adversarial axes. Commits no
carrier/output (AS#5 discipline: catalog is public-mirror class -> mode absent).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NeK9hkxrU9wFPBYGYnSV1V
2026-07-24 01:46:53 +02:00

242 lines
10 KiB
JavaScript

#!/usr/bin/env node
// build-rollup-register.mjs — the state roll-up register builder (STEG 0 wiring).
//
// WHY THIS EXISTS: cross-repo sessions each keep a LOCAL-ONLY STATE.md whose flush-left
// marker lines ("topic: status — prose") are the single source of truth for where each
// shared topic stands. This builder assembles those into two DISTINCT outputs, never
// conflating marker *durability* with *publicity* (the root cause of AS#5):
//
// - Output A = rich LOCAL roll-up: full "topic: status — prose" grepped from every
// ~/repos/*/STATE.md. For the operator, locally. NEVER published.
// - Output B = optional PUBLIC status-token index: "topic: status" ALONE, projected
// from A and filtered to repos that opted in via a committed .rollup-carrier.
//
// Contract source (ratified): ~/.claude/coord/register.md (framework-neutral contract
// authored commons-side @ c66ccc3; status vocab @ fe6b998). Catalog owns the builder
// wiring + the adversarial axes; commons owns the framework-neutral contract.
//
// Three builder modes, tolerating absence (no repo is ever forced to commit a carrier):
// 1. absent — no carrier -> omitted from B; A covers it fully.
// 2. committed — .rollup-carrier -> travels with clone/subtree/public mirror -> B.
// 3. private — .rollup-carrier.local (gitignored side-channel) -> A/private only, never B.
//
// Two gates:
// V4 (public-safety) — a carrier line must be exactly "topic: <status-token>": no prose,
// no em-dash, known token. A violating committed carrier is blocked from B.
// V5 (drift-warn) — committed carrier status != STATE marker status -> warn; STATE WINS
// (B projects the STATE status, since B is a projection of A).
//
// Pure-function core is covered by build-rollup-register.test.mjs. The CLI shell (the
// ~/repos glob + mode detection) is deliberately thin and not unit-tested. Zero npm deps.
import { readdirSync, existsSync, readFileSync, statSync } from 'node:fs';
import { join, basename } from 'node:path';
import { homedir } from 'node:os';
import { fileURLToPath } from 'node:url';
const EMDASH = '—';
// The canonical, closed status vocabulary (register.md "Status-vokabular"). "active" was
// folded into in-progress and is invalid; unknown tokens are malformed.
export const STATUS_VOCAB = [
'planned',
'in-progress',
'partial',
'blocked',
'deferred',
'done',
'not-applicable',
];
const TOPIC = '[a-z0-9][a-z0-9-]*';
// --- Output A source: a STATE.md marker line "topic: status — prose" -----------------
// Returns { topic, status, prose } or null if the line is not a well-formed marker
// (unknown status tokens are treated as "not a marker", so ordinary prose never matches).
export function parseMarkerLine(line) {
const m = line.match(new RegExp(`^(${TOPIC}):\\s+(\\S+)(?:\\s+${EMDASH}\\s+(.*))?\\s*$`));
if (!m) return null;
const [, topic, status, prose] = m;
if (!STATUS_VOCAB.includes(status)) return null;
return { topic, status, prose: prose ? prose.trim() : '' };
}
// --- Output B source: a bare carrier line "topic: status" (token ALONE) ---------------
// Returns { topic, status } or null. Any prose, em-dash, or unknown token -> null.
export function parseCarrierLine(line) {
const m = line.match(new RegExp(`^(${TOPIC}):\\s+(\\S+)\\s*$`));
if (!m) return null;
const [, topic, status] = m;
if (!STATUS_VOCAB.includes(status)) return null;
return { topic, status };
}
// --- V4 public-safety gate ------------------------------------------------------------
// Validate a whole .rollup-carrier file. Blank lines are tolerated. Every other line MUST
// be an exact bare carrier line; otherwise it is a violation with a classified reason:
// em-dash | prose-after-token | unknown-status | malformed
// Returns { ok, entries, violations:[{ lineNo, line, reason }] }.
export function validateCarrier(text) {
const entries = [];
const violations = [];
text.split('\n').forEach((line, i) => {
const lineNo = i + 1;
if (line.trim() === '') return; // blank: tolerated, not a violation
if (line.includes(EMDASH)) {
violations.push({ lineNo, line, reason: 'em-dash' });
return;
}
const entry = parseCarrierLine(line);
if (entry) {
entries.push(entry);
return;
}
// Not a clean carrier line — classify why so the operator can fix it precisely.
const shape = line.match(new RegExp(`^(${TOPIC}):\\s+(\\S+)(\\s+.*)?$`));
if (shape) {
const status = shape[2];
const trailing = shape[3];
if (!STATUS_VOCAB.includes(status)) violations.push({ lineNo, line, reason: 'unknown-status' });
else if (trailing && trailing.trim() !== '') violations.push({ lineNo, line, reason: 'prose-after-token' });
else violations.push({ lineNo, line, reason: 'malformed' });
} else {
violations.push({ lineNo, line, reason: 'malformed' });
}
});
return { ok: violations.length === 0, entries, violations };
}
// --- V5 drift-warn --------------------------------------------------------------------
// Compare a repo's STATE markers against its carrier entries. Only shared topics are
// compared; a disagreement yields { topic, stateStatus, carrierStatus }. STATE wins.
export function computeDrift(markers, carrierEntries) {
const stateByTopic = new Map(markers.map((m) => [m.topic, m.status]));
const drift = [];
for (const c of carrierEntries) {
if (stateByTopic.has(c.topic) && stateByTopic.get(c.topic) !== c.status) {
drift.push({ topic: c.topic, stateStatus: stateByTopic.get(c.topic), carrierStatus: c.status });
}
}
return drift;
}
// --- Orchestrator ---------------------------------------------------------------------
// repos: [{ name, markers:[{topic,status,prose}], carrier:string|null, carrierMode }]
// Returns:
// outputA [{ repo, topic, status, prose }] — LOCAL roll-up, every repo/mode.
// outputB ["topic: status", ...] (sorted) — PUBLIC index; committed + V4-clean only.
// drift [{ repo, topic, stateStatus, carrierStatus }]
// violations[{ repo, lineNo, line, reason }]
export function buildRegister({ repos }) {
const outputA = [];
const outputBLines = [];
const drift = [];
const violations = [];
for (const r of repos) {
for (const m of r.markers) {
outputA.push({ repo: r.name, topic: m.topic, status: m.status, prose: m.prose ?? '' });
}
// Only mode "committed" reaches the public index B.
if (r.carrierMode !== 'committed' || r.carrier == null) continue;
const v = validateCarrier(r.carrier);
if (!v.ok) {
for (const viol of v.violations) violations.push({ repo: r.name, ...viol });
continue; // a V4-violating carrier must not pollute the public index
}
for (const d of computeDrift(r.markers, v.entries)) drift.push({ repo: r.name, ...d });
// B is a projection of A: STATE status is authoritative (V5 — STATE wins on drift).
const stateByTopic = new Map(r.markers.map((m) => [m.topic, m.status]));
for (const e of v.entries) {
const status = stateByTopic.has(e.topic) ? stateByTopic.get(e.topic) : e.status;
outputBLines.push(`${e.topic}: ${status}`);
}
}
outputBLines.sort();
return { outputA, outputB: outputBLines, drift, violations };
}
// --- CLI shell (thin; not unit-tested) ------------------------------------------------
// Globs ~/repos/*/STATE.md for Output A, detects carrier mode per repo, prints both
// outputs with clear LOCAL/PUBLIC framing plus drift + violation notices. The operator
// redirects; the builder itself commits nothing.
function extractMarkers(stateText) {
const markers = [];
for (const line of stateText.split('\n')) {
const m = parseMarkerLine(line);
if (m) markers.push(m);
}
return markers;
}
function discoverRepos(reposRoot) {
const repos = [];
let dirs;
try {
dirs = readdirSync(reposRoot, { withFileTypes: true }).filter((e) => e.isDirectory());
} catch {
return repos;
}
for (const d of dirs) {
const repoDir = join(reposRoot, d.name);
const statePath = join(repoDir, 'STATE.md');
if (!existsSync(statePath) || !statSync(statePath).isFile()) continue;
const markers = extractMarkers(readFileSync(statePath, 'utf8'));
if (markers.length === 0) continue; // no roll-up markers -> nothing to contribute
const committed = join(repoDir, '.rollup-carrier');
const priv = join(repoDir, '.rollup-carrier.local');
let carrier = null;
let carrierMode = 'absent';
if (existsSync(committed)) {
carrier = readFileSync(committed, 'utf8');
carrierMode = 'committed';
} else if (existsSync(priv)) {
carrier = readFileSync(priv, 'utf8');
carrierMode = 'private';
}
repos.push({ name: basename(repoDir), markers, carrier, carrierMode });
}
return repos;
}
const isMain = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
if (isMain) {
const reposRoot = process.argv[2] || join(homedir(), 'repos');
const repos = discoverRepos(reposRoot);
const { outputA, outputB, drift, violations } = buildRegister({ repos });
const out = [];
out.push(`# State roll-up register — source: ${reposRoot}/*/STATE.md`);
out.push(`# repos with markers: ${repos.length} (` +
`${repos.filter((r) => r.carrierMode === 'committed').length} committed, ` +
`${repos.filter((r) => r.carrierMode === 'private').length} private, ` +
`${repos.filter((r) => r.carrierMode === 'absent').length} absent)`);
out.push('');
out.push('## Output A — rich LOCAL roll-up (LOCAL-ONLY, never publish)');
for (const e of outputA) {
out.push(`${e.repo.padEnd(28)} ${e.topic}: ${e.status}${e.prose ? ` ${EMDASH} ${e.prose}` : ''}`);
}
out.push('');
out.push(`## Output B — public status-token index (${outputB.length} line(s); publishable)`);
for (const line of outputB) out.push(line);
out.push('');
const notices = [];
for (const v of violations) {
notices.push(`V4 VIOLATION ${v.repo} .rollup-carrier:${v.lineNo} [${v.reason}] ${v.line.trim()}`);
}
for (const d of drift) {
notices.push(`V5 DRIFT-WARN ${d.repo} "${d.topic}": STATE=${d.stateStatus} != carrier=${d.carrierStatus} (STATE wins in B)`);
}
if (notices.length) {
process.stderr.write(`${notices.join('\n')}\n`);
}
process.stdout.write(`${out.join('\n')}\n`);
process.exit(violations.length === 0 ? 0 : 1);
}