feat(rollup): V6 marker-loss gate + depth-1+2 discovery matching board.sh
Two measured defects in build-rollup-register.mjs, reported by ~/repos and
independently re-measured here with the producer's own parser as instrument.
D1 - discovery saw only depth 1, so 5 markers across 3 marketplace repos were
invisible (11 STATE.md files live at depth 2). discoverRepos now mirrors
board.sh:63-89 EXACTLY: a dir that is itself a git repo contributes itself and
is NOT descended into; a non-repo dir holding git repos is a polyrepo container
contributing its children. Identity stays the RAW BASENAME (board.sh:100) so
this builder, board.sh and the coord mailboxes name a repo alike.
Not descending into a git repo is what makes the known nested-name trap
(claude-code-100x/claude-code-100x, both git repos) structurally unreachable
rather than merely absent - blanket depth-2, as proposed, would have reached it.
A collision gate still fails HIGH on two STATE-bearing dirs resolving to one
name, gated on CONTRIBUTING repos so a same-named dir without STATE.md cannot
stop the build. Measured today: 0 collisions across 25 state-bearing dirs.
D2 - parseMarkerLine returns null for two DIFFERENT facts ("not a marker" /
"a marker that failed to parse"), so a malformed marker vanished with exit 0.
V4/V5 only ever gated the carrier; nothing gated marker loss. New V6 reports a
line whose topic and KNOWN status token are both present but which does not
parse, naming file + line number. A warning, not a failure: a typo in another
repo's LOCAL state file must be visible, but is not this builder's business to
fail on. Unknown tokens stay "not a marker", keeping prose out of the channel.
A repo with only losses is still discovered - skipping it would re-open the
exact silent channel V6 closes.
Acceptance against real ~/repos: 15 markers (was 10, +5 exactly as predicted),
2 V6 warnings naming the two measured "--" lines, 0 collisions, exit 0.
Test fixtures are synthetic: marker prose is Output-A material (LOCAL-ONLY),
so quoting a real one into this public repo would be the same
durability-vs-publicity conflation the builder header warns about.
Suite 87/87 (was 78, +9). check-versions 11 OK / 0 WARN / 0 ERROR.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPHvLhn2U3j2XD3Cn8zeYa
This commit is contained in:
parent
5ddc6c77a5
commit
1eabad1d27
2 changed files with 273 additions and 47 deletions
|
|
@ -20,14 +20,17 @@
|
|||
// 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:
|
||||
// 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. The CLI shell (the
|
||||
// ~/repos glob + mode detection) is deliberately thin and not unit-tested. Zero npm deps.
|
||||
// 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';
|
||||
|
|
@ -61,6 +64,32 @@ export function parseMarkerLine(line) {
|
|||
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) {
|
||||
|
|
@ -121,22 +150,27 @@ export function computeDrift(markers, carrierEntries) {
|
|||
}
|
||||
|
||||
// --- Orchestrator ---------------------------------------------------------------------
|
||||
// repos: [{ name, markers:[{topic,status,prose}], carrier:string|null, carrierMode }]
|
||||
// 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 }]
|
||||
// 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;
|
||||
|
||||
|
|
@ -157,62 +191,121 @@ export function buildRegister({ repos }) {
|
|||
}
|
||||
|
||||
outputBLines.sort();
|
||||
return { outputA, outputB: outputBLines, drift, violations };
|
||||
return { outputA, outputB: outputBLines, drift, violations, markerLosses };
|
||||
}
|
||||
|
||||
// --- 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;
|
||||
// --- 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. SHARED, DELIBERATE GAP: like board.sh, "is a repo" means `.git` is a
|
||||
// DIRECTORY, so a git worktree or submodule (where `.git` is a file) is not seen by either
|
||||
// reader. Pinned here rather than fixed one-sidedly — diverging would break the very
|
||||
// same-name-from-both-readers invariant this function exists to hold.
|
||||
function isGitRepo(dir) {
|
||||
const dotGit = join(dir, '.git');
|
||||
return existsSync(dotGit) && statSync(dotGit).isDirectory();
|
||||
}
|
||||
|
||||
function discoverRepos(reposRoot) {
|
||||
const repos = [];
|
||||
let dirs;
|
||||
function subdirs(dir) {
|
||||
try {
|
||||
dirs = readdirSync(reposRoot, { withFileTypes: true }).filter((e) => e.isDirectory());
|
||||
return readdirSync(dir, { withFileTypes: true })
|
||||
.filter((e) => e.isDirectory())
|
||||
.map((e) => join(dir, e.name));
|
||||
} catch {
|
||||
return repos;
|
||||
return [];
|
||||
}
|
||||
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';
|
||||
// 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;
|
||||
}
|
||||
repos.push({ name: basename(repoDir), markers, carrier, carrierMode });
|
||||
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');
|
||||
const repos = discoverRepos(reposRoot);
|
||||
const { outputA, outputB, drift, violations } = buildRegister({ 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`);
|
||||
out.push(`# repos with markers: ${repos.length} (` +
|
||||
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)`);
|
||||
|
|
@ -233,6 +326,9 @@ if (isMain) {
|
|||
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`);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue