The catalog half of a shared gap that was previously PINNED rather than fixed. This reader of ~/repos tested `.git` with a directory check, so a plain `git worktree add ~/repos/feature-x` produced a depth-1 sibling that can carry its own STATE.md and was dropped SILENTLY - no warning, no count, indistinguishable from a repo with nothing to say. A submodule fails the same way for the same reason. It was pinned, not fixed, because diverging would break the very same-name-from-both-readers invariant discoverRepos exists to hold. ~/repos answered YES to the gated question and reported their half landed. Verified here rather than taken on report: board.sh's Discovery block tests `-e`, and has done since board.sh was introduced (repo-mailbox 61e224c, first released v0.9.0) - so the released reader has no `-d` era at all, and only this side was ever the outlier. The two move together from here - change one, change the other. THE ACCEPTANCE TRAP, MEASURED RATHER THAN ASSUMED. This changes NO number against real ~/repos. Old and new builders were run back to back against the live tree and diffed: stdout and stderr byte-identical, both exit 0. Directly measured why: 0 directories at depth 1 or 2 currently carry .git as a file. A number standing still is not ambiguous here, it is the ONLY possible outcome, and it is why a fixture is not the best way to test this but the only way. FIXTURE GATED BEFORE BEHAVIOUR. The worktree fixture uses a real `git worktree add`, not a hand-written `.git` file - faking it would assert against our guess at git's on-disk format instead of against git. A separate test asserts the fixture itself: .git exists, is a file, is not a directory. Without it, the day git stops writing worktree .git as a file the behaviour tests would go green while measuring nothing, and a green run cannot distinguish that from success. The gate says which one it was, and says the premise of the -e rule is gone rather than inviting a test tweak. Two behaviour tests: a worktree at depth 1, and one under a polyrepo container at depth 2 - the same rule at both depths board.sh walks. Suite 90/90 across the six files (rollup 31 -> 34). check-versions 11 OK, 0 WARN, 0 ERROR. Real run unchanged at 8 repos, 17 markers, Output B 0 lines, 1 V6 warning, exit 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KSX2v1m7Fz22BKrGJZuUpQ
345 lines
15 KiB
JavaScript
345 lines
15 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.
|
|
//
|
|
// Three 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).
|
|
// V6 (marker-loss) — a line that LOOKS like a marker attempt ("topic: <known status>")
|
|
// but does not parse is REPORTED, never silently dropped. V4/V5 only
|
|
// ever gated the carrier, so a malformed STATE marker used to vanish
|
|
// with exit 0 — the loss channel this gate closes.
|
|
//
|
|
// Pure-function core is covered by build-rollup-register.test.mjs. 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() : '' };
|
|
}
|
|
|
|
// --- V6 marker-loss gate --------------------------------------------------------------
|
|
// parseMarkerLine collapses two DIFFERENT facts into one null: "this is not a marker" and
|
|
// "this is a marker that failed to parse". Callers cannot tell them apart, so a malformed
|
|
// marker disappeared with no warning and exit 0. A marker ATTEMPT is a line whose topic and
|
|
// KNOWN status token are both present — an unknown token stays "not a marker" (that is the
|
|
// existing, deliberate rule), which is what keeps ordinary prose out of the loss channel.
|
|
// Deliberately a warning, not a failure: a typo in another repo's LOCAL state file must be
|
|
// visible, but it is not this builder's business to fail on.
|
|
const MARKER_ATTEMPT = new RegExp(`^(${TOPIC}):\\s+(\\S+)`);
|
|
|
|
// Returns { markers, losses:[{ lineNo, line }] } for one STATE.md text.
|
|
export function extractMarkers(stateText) {
|
|
const markers = [];
|
|
const losses = [];
|
|
stateText.split('\n').forEach((line, i) => {
|
|
const m = parseMarkerLine(line);
|
|
if (m) {
|
|
markers.push(m);
|
|
return;
|
|
}
|
|
const attempt = line.match(MARKER_ATTEMPT);
|
|
if (attempt && STATUS_VOCAB.includes(attempt[2])) losses.push({ lineNo: i + 1, line });
|
|
});
|
|
return { markers, losses };
|
|
}
|
|
|
|
// --- 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, statePath, markers:[{topic,status,prose}], losses, 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 }]
|
|
// markerLosses[{ repo, statePath, lineNo, line }] — V6: marker attempts that did not parse.
|
|
export function buildRegister({ repos }) {
|
|
const outputA = [];
|
|
const outputBLines = [];
|
|
const drift = [];
|
|
const violations = [];
|
|
const markerLosses = [];
|
|
|
|
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 ?? '' });
|
|
}
|
|
for (const l of r.losses ?? []) {
|
|
markerLosses.push({ repo: r.name, statePath: r.statePath ?? null, ...l });
|
|
}
|
|
// 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, markerLosses };
|
|
}
|
|
|
|
// --- Repo discovery: depth 1 + depth 2 under polyrepo containers ----------------------
|
|
// Mirrors board.sh:63-89 EXACTLY, and deliberately so: a directory that is itself a git
|
|
// repo is one repo and is NOT descended into; a directory that is not a repo but holds
|
|
// git repos is a polyrepo container (the plugin marketplace) contributing its children,
|
|
// never itself. A depth-1-only glob missed 5 markers across 11 depth-2 STATE.md files.
|
|
//
|
|
// Identity is the RAW BASENAME (board.sh:100), not "parent/child", so this builder, board.sh
|
|
// and the coord mailboxes (~/.claude/coord/<repo>/) all name the same repo the same way.
|
|
// Two names for one repo is the AS#5 defect class. Measured today: 0 collisions across 25
|
|
// state-bearing directories — a fact, not a guarantee, hence the collision gate below.
|
|
//
|
|
// Not descending into a git repo also makes the known nested-name trap
|
|
// (claude-code-100x/claude-code-100x, both git repos) structurally unreachable rather than
|
|
// merely absent.
|
|
//
|
|
// "Is a repo" tests `.git` for EXISTENCE, not directory-ness: a worktree or submodule has
|
|
// `.git` as a FILE. A plain `git worktree add ~/repos/feature-x` lands a depth-1 sibling
|
|
// that can carry its own STATE.md, and a directory test dropped it SILENTLY — no warning,
|
|
// no count, indistinguishable from a repo with nothing to say. This was pinned rather than
|
|
// fixed one-sidedly because diverging would break the very same-name-from-both-readers
|
|
// invariant this function exists to hold. board.sh's side is in place — MEASURED, not
|
|
// reported: its Discovery block tests `-e`, and has since board.sh was introduced
|
|
// (repo-mailbox 61e224c, first released v0.9.0). This is the other half. The two move
|
|
// together or not at all — change one, change the other.
|
|
function isGitRepo(dir) {
|
|
const dotGit = join(dir, '.git');
|
|
return existsSync(dotGit);
|
|
}
|
|
|
|
function subdirs(dir) {
|
|
try {
|
|
return readdirSync(dir, { withFileTypes: true })
|
|
.filter((e) => e.isDirectory())
|
|
.map((e) => join(dir, e.name));
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
// Read one repo dir into a register record, or null when it contributes nothing.
|
|
function readRepo(repoDir) {
|
|
const statePath = join(repoDir, 'STATE.md');
|
|
if (!existsSync(statePath) || !statSync(statePath).isFile()) return null;
|
|
const { markers, losses } = extractMarkers(readFileSync(statePath, 'utf8'));
|
|
// A repo with only LOSSES still contributes: dropping it here would re-open the exact
|
|
// silent channel V6 closes.
|
|
if (markers.length === 0 && losses.length === 0) return null;
|
|
|
|
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';
|
|
}
|
|
return { name: basename(repoDir), statePath, markers, losses, carrier, carrierMode };
|
|
}
|
|
|
|
export function discoverRepos(reposRoot) {
|
|
const repoDirs = [];
|
|
for (const entry of subdirs(reposRoot)) {
|
|
if (isGitRepo(entry)) {
|
|
repoDirs.push(entry);
|
|
continue;
|
|
}
|
|
for (const child of subdirs(entry)) {
|
|
if (isGitRepo(child)) repoDirs.push(child);
|
|
}
|
|
}
|
|
|
|
const repos = [];
|
|
const seen = new Map();
|
|
for (const dir of repoDirs) {
|
|
const r = readRepo(dir);
|
|
if (!r) continue; // contributes nothing -> cannot collide with anything
|
|
const prior = seen.get(r.name);
|
|
// Fail HIGH: a silent pick-one would make the register quietly wrong about which repo
|
|
// a marker came from, which is worse than not building at all. Gated on CONTRIBUTING
|
|
// repos only — two same-named directories where just one bears a STATE.md are not
|
|
// ambiguous, so they must not stop the build.
|
|
if (prior) {
|
|
throw new Error(
|
|
`repo name collision: "${r.name}" resolves to two STATE-bearing directories `
|
|
+ `(${prior} and ${dir}). The register cannot attribute markers unambiguously.`,
|
|
);
|
|
}
|
|
seen.set(r.name, dir);
|
|
repos.push(r);
|
|
}
|
|
return repos;
|
|
}
|
|
|
|
// --- CLI shell (thin) -----------------------------------------------------------------
|
|
// Discovers repos for Output A, detects carrier mode per repo, prints both outputs with
|
|
// clear LOCAL/PUBLIC framing plus drift + violation + marker-loss notices. The operator
|
|
// redirects; the builder itself commits nothing.
|
|
|
|
const isMain = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
|
|
if (isMain) {
|
|
const reposRoot = process.argv[2] || join(homedir(), 'repos');
|
|
let repos;
|
|
try {
|
|
repos = discoverRepos(reposRoot);
|
|
} catch (err) {
|
|
// A name collision makes every marker's attribution suspect, so emit nothing at all.
|
|
process.stderr.write(`FATAL ${err.message}\n`);
|
|
process.exit(2);
|
|
}
|
|
const { outputA, outputB, drift, violations, markerLosses } = buildRegister({ repos });
|
|
|
|
const out = [];
|
|
out.push(`# State roll-up register — source: ${reposRoot}/*/STATE.md + <container>/*/STATE.md`);
|
|
// "discovered", not "with markers": a repo carrying only V6 losses is discovered and
|
|
// reported, but contributes nothing to A.
|
|
out.push(`# repos discovered: ${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)`);
|
|
}
|
|
for (const l of markerLosses) {
|
|
notices.push(`V6 MARKER-LOSS ${l.statePath ?? l.repo}:${l.lineNo} did not parse as a marker: ${l.line.trim()}`);
|
|
}
|
|
if (notices.length) {
|
|
process.stderr.write(`${notices.join('\n')}\n`);
|
|
}
|
|
|
|
process.stdout.write(`${out.join('\n')}\n`);
|
|
process.exit(violations.length === 0 ? 0 : 1);
|
|
}
|