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
This commit is contained in:
parent
09521f8f2d
commit
ba844976e9
2 changed files with 464 additions and 0 deletions
242
scripts/build-rollup-register.mjs
Normal file
242
scripts/build-rollup-register.mjs
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
#!/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);
|
||||
}
|
||||
222
scripts/build-rollup-register.test.mjs
Normal file
222
scripts/build-rollup-register.test.mjs
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
// Tests for the state roll-up register builder (build-rollup-register.mjs).
|
||||
// Style mirrors check-okf-parity.test.mjs / check-versions.test.mjs: node:test,
|
||||
// pure-function core, zero npm deps. The CLI shell (filesystem glob over ~/repos)
|
||||
// is NOT unit-tested here — only the pure builder core the contract lives in.
|
||||
//
|
||||
// Contract source (ratified): ~/.claude/coord/register.md — two outputs, three
|
||||
// builder modes, V4 public-safety gate, V5 drift-warn, closed 7-token status vocab.
|
||||
// Axes are catalog-owned; this file encodes them as fixtures.
|
||||
//
|
||||
// The em-dash (U+2014) is written as '—' throughout to keep the source ASCII-clean
|
||||
// and to make the V4 "no em-dash in carrier" axis unambiguous.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
STATUS_VOCAB,
|
||||
parseMarkerLine,
|
||||
parseCarrierLine,
|
||||
validateCarrier,
|
||||
computeDrift,
|
||||
buildRegister,
|
||||
} from './build-rollup-register.mjs';
|
||||
|
||||
const EMDASH = '—';
|
||||
|
||||
// --- Status vocabulary: the closed 7-token set (register.md "Status-vokabular") ---
|
||||
test('STATUS_VOCAB is exactly the closed 7-token set; "active" is dropped', () => {
|
||||
assert.deepEqual(
|
||||
[...STATUS_VOCAB].sort(),
|
||||
['blocked', 'deferred', 'done', 'in-progress', 'not-applicable', 'partial', 'planned'].sort(),
|
||||
);
|
||||
assert.ok(!STATUS_VOCAB.includes('active'), '"active" was folded into in-progress and is invalid');
|
||||
});
|
||||
|
||||
// --- parseMarkerLine: Output A source (topic: status — prose) ---
|
||||
test('parseMarkerLine: parses "topic: status — prose" from a STATE marker', () => {
|
||||
const line = `llm-ingestion-okf: planned ${EMDASH} STEG 3 levert, STEG 4 neste`;
|
||||
assert.deepEqual(parseMarkerLine(line), {
|
||||
topic: 'llm-ingestion-okf',
|
||||
status: 'planned',
|
||||
prose: 'STEG 3 levert, STEG 4 neste',
|
||||
});
|
||||
});
|
||||
|
||||
test('parseMarkerLine: a status-only marker (no prose) parses with empty prose', () => {
|
||||
assert.deepEqual(parseMarkerLine('some-topic: done'), {
|
||||
topic: 'some-topic',
|
||||
status: 'done',
|
||||
prose: '',
|
||||
});
|
||||
});
|
||||
|
||||
test('parseMarkerLine: unknown status token -> null (not a marker line)', () => {
|
||||
assert.equal(parseMarkerLine(`topic: active ${EMDASH} folded away`), null);
|
||||
assert.equal(parseMarkerLine(`# STATE ${EMDASH} title heading`), null);
|
||||
assert.equal(parseMarkerLine('just some prose without a marker'), null);
|
||||
});
|
||||
|
||||
// --- parseCarrierLine: Output B source (topic: status ALONE) ---
|
||||
test('parseCarrierLine: parses a bare "topic: status" carrier line', () => {
|
||||
assert.deepEqual(parseCarrierLine('llm-ingestion-guard: planned'), {
|
||||
topic: 'llm-ingestion-guard',
|
||||
status: 'planned',
|
||||
});
|
||||
});
|
||||
|
||||
test('parseCarrierLine: rejects anything beyond the bare token (prose/em-dash/unknown)', () => {
|
||||
assert.equal(parseCarrierLine(`topic: planned ${EMDASH} prose`), null, 'em-dash + prose is not a carrier line');
|
||||
assert.equal(parseCarrierLine('topic: planned extra words'), null, 'trailing prose is not a carrier line');
|
||||
assert.equal(parseCarrierLine('topic: active'), null, 'unknown token is malformed');
|
||||
});
|
||||
|
||||
// --- V4 public-safety gate: validateCarrier rejects prose / em-dash / unknown ---
|
||||
test('V4: a clean carrier (bare topic: token lines) validates ok', () => {
|
||||
const text = 'llm-ingestion-guard: planned\nllm-ingestion-okf: not-applicable\n';
|
||||
const r = validateCarrier(text);
|
||||
assert.equal(r.ok, true);
|
||||
assert.deepEqual(r.violations, []);
|
||||
assert.deepEqual(r.entries, [
|
||||
{ topic: 'llm-ingestion-guard', status: 'planned' },
|
||||
{ topic: 'llm-ingestion-okf', status: 'not-applicable' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('V4: blank lines are tolerated, not violations', () => {
|
||||
const r = validateCarrier('\nllm-ingestion-guard: planned\n\n');
|
||||
assert.equal(r.ok, true);
|
||||
assert.equal(r.entries.length, 1);
|
||||
});
|
||||
|
||||
test('V4: em-dash in a carrier line is a violation (the core public-safety axis)', () => {
|
||||
const r = validateCarrier(`topic: planned ${EMDASH} leaked prose`);
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.violations.length, 1);
|
||||
assert.equal(r.violations[0].reason, 'em-dash');
|
||||
assert.equal(r.violations[0].lineNo, 1);
|
||||
});
|
||||
|
||||
test('V4: prose after a valid token (no em-dash) is still a violation', () => {
|
||||
const r = validateCarrier('topic: planned some trailing prose');
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.violations[0].reason, 'prose-after-token');
|
||||
});
|
||||
|
||||
test('V4: an unknown status token is a violation', () => {
|
||||
const r = validateCarrier('topic: active');
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.violations[0].reason, 'unknown-status');
|
||||
});
|
||||
|
||||
test('V4: a structurally malformed line (no "topic: status") is a violation', () => {
|
||||
const r = validateCarrier('this is not a marker at all');
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.violations[0].reason, 'malformed');
|
||||
});
|
||||
|
||||
test('V4: reports every offending line, not just the first', () => {
|
||||
const text = `a: planned\nb: active\nc: done ${EMDASH} x\n`;
|
||||
const r = validateCarrier(text);
|
||||
assert.equal(r.ok, false);
|
||||
assert.deepEqual(r.violations.map((v) => v.lineNo), [2, 3]);
|
||||
assert.deepEqual(r.violations.map((v) => v.reason), ['unknown-status', 'em-dash']);
|
||||
});
|
||||
|
||||
// --- V5 drift-warn: STATE status != carrier status -> warn, STATE wins ---
|
||||
test('V5: agreeing topics produce no drift warning', () => {
|
||||
const markers = [{ topic: 't', status: 'planned' }];
|
||||
const carrier = [{ topic: 't', status: 'planned' }];
|
||||
assert.deepEqual(computeDrift(markers, carrier), []);
|
||||
});
|
||||
|
||||
test('V5: disagreeing topic yields a drift warning carrying both sides', () => {
|
||||
const markers = [{ topic: 't', status: 'done' }];
|
||||
const carrier = [{ topic: 't', status: 'in-progress' }];
|
||||
assert.deepEqual(computeDrift(markers, carrier), [
|
||||
{ topic: 't', stateStatus: 'done', carrierStatus: 'in-progress' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('V5: a carrier topic absent from STATE is not a drift (only shared topics compared)', () => {
|
||||
const markers = [{ topic: 'a', status: 'done' }];
|
||||
const carrier = [{ topic: 'b', status: 'planned' }];
|
||||
assert.deepEqual(computeDrift(markers, carrier), []);
|
||||
});
|
||||
|
||||
// --- buildRegister: three-mode assembly (register.md "Tre builder-moduser") ---
|
||||
function repo(name, markers, carrier, carrierMode) {
|
||||
return { name, markers, carrier, carrierMode };
|
||||
}
|
||||
|
||||
test('mode "absent": repo has no carrier -> omitted from Output B, present in Output A', () => {
|
||||
const repos = [repo('catalog', [{ topic: 'x', status: 'planned', prose: 'p' }], null, 'absent')];
|
||||
const { outputB, outputA } = buildRegister({ repos });
|
||||
assert.equal(outputB.length, 0, 'absent repo contributes nothing to public index B');
|
||||
assert.equal(outputA.length, 1, 'but A (local) still covers it');
|
||||
assert.deepEqual(outputA[0], { repo: 'catalog', topic: 'x', status: 'planned', prose: 'p' });
|
||||
});
|
||||
|
||||
test('mode "committed": a valid carrier projects into Output B (STATE-authoritative status)', () => {
|
||||
const repos = [
|
||||
repo(
|
||||
'guard',
|
||||
[{ topic: 'llm-ingestion-guard', status: 'planned', prose: 'B6 detail' }],
|
||||
'llm-ingestion-guard: planned\n',
|
||||
'committed',
|
||||
),
|
||||
];
|
||||
const { outputB } = buildRegister({ repos });
|
||||
assert.deepEqual(outputB, ['llm-ingestion-guard: planned']);
|
||||
});
|
||||
|
||||
test('mode "private": private side-channel carrier stays out of Output B', () => {
|
||||
const repos = [
|
||||
repo(
|
||||
'secret',
|
||||
[{ topic: 'z', status: 'in-progress', prose: 'p' }],
|
||||
'z: in-progress\n',
|
||||
'private',
|
||||
),
|
||||
];
|
||||
const { outputB, outputA } = buildRegister({ repos });
|
||||
assert.equal(outputB.length, 0, 'private-mode carrier never reaches the public index');
|
||||
assert.equal(outputA.length, 1, 'but it is present in the local A index');
|
||||
});
|
||||
|
||||
test('buildRegister: Output B projects STATE status on drift (STATE wins) and records the drift', () => {
|
||||
const repos = [
|
||||
repo(
|
||||
'r',
|
||||
[{ topic: 't', status: 'done', prose: 'finished' }],
|
||||
't: in-progress\n', // carrier drifted behind STATE
|
||||
'committed',
|
||||
),
|
||||
];
|
||||
const { outputB, drift } = buildRegister({ repos });
|
||||
assert.deepEqual(outputB, ['t: done'], 'STATE status wins in the projection, not the stale carrier');
|
||||
assert.deepEqual(drift, [{ repo: 'r', topic: 't', stateStatus: 'done', carrierStatus: 'in-progress' }]);
|
||||
});
|
||||
|
||||
test('buildRegister: a V4-violating committed carrier is blocked from B and its violation reported', () => {
|
||||
const repos = [
|
||||
repo(
|
||||
'bad',
|
||||
[{ topic: 't', status: 'planned', prose: 'p' }],
|
||||
`t: planned ${EMDASH} leaked`,
|
||||
'committed',
|
||||
),
|
||||
];
|
||||
const { outputB, violations } = buildRegister({ repos });
|
||||
assert.equal(outputB.length, 0, 'a repo whose carrier fails V4 must not pollute the public index');
|
||||
assert.equal(violations.length, 1);
|
||||
assert.equal(violations[0].repo, 'bad');
|
||||
assert.equal(violations[0].reason, 'em-dash');
|
||||
});
|
||||
|
||||
test('buildRegister: Output B is sorted and deterministic across repos', () => {
|
||||
const repos = [
|
||||
repo('b', [{ topic: 'zeta', status: 'done', prose: 'p' }], 'zeta: done\n', 'committed'),
|
||||
repo('a', [{ topic: 'alpha', status: 'planned', prose: 'p' }], 'alpha: planned\n', 'committed'),
|
||||
];
|
||||
const { outputB } = buildRegister({ repos });
|
||||
assert.deepEqual(outputB, ['alpha: planned', 'zeta: done'], 'stable sort so the public index has no spurious diffs');
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue