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`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@
|
|||
// and to make the V4 "no em-dash in carrier" axis unambiguous.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import {
|
||||
STATUS_VOCAB,
|
||||
parseMarkerLine,
|
||||
|
|
@ -18,6 +21,8 @@ import {
|
|||
validateCarrier,
|
||||
computeDrift,
|
||||
buildRegister,
|
||||
extractMarkers,
|
||||
discoverRepos,
|
||||
} from './build-rollup-register.mjs';
|
||||
|
||||
const EMDASH = '—';
|
||||
|
|
@ -220,3 +225,128 @@ test('buildRegister: Output B is sorted and deterministic across repos', () => {
|
|||
const { outputB } = buildRegister({ repos });
|
||||
assert.deepEqual(outputB, ['alpha: planned', 'zeta: done'], 'stable sort so the public index has no spurious diffs');
|
||||
});
|
||||
|
||||
// --- V6 marker-loss gate: a marker ATTEMPT that fails to parse must be reported ---
|
||||
// parseMarkerLine returns null for two different facts ("not a marker" / "a marker that
|
||||
// failed to parse"), so a malformed marker used to vanish with exit 0. V6 splits them.
|
||||
test('V6: a well-formed marker extracts with no loss reported', () => {
|
||||
const text = `# STATE\n\nsome-topic: planned ${EMDASH} prose after the em-dash\n`;
|
||||
const { markers, losses } = extractMarkers(text);
|
||||
assert.equal(markers.length, 1);
|
||||
assert.deepEqual(losses, [], 'a parseable marker is not a loss');
|
||||
});
|
||||
|
||||
test('V6: an ASCII "--" separator loses the marker today -> reported with line number', () => {
|
||||
// The measured shape: two STATE.md files in ~/repos wrote "--" where the grammar
|
||||
// requires the em-dash, and both vanished silently before this gate existed. The
|
||||
// fixture is SYNTHETIC on purpose — marker prose is Output-A material (LOCAL-ONLY),
|
||||
// so quoting a real one verbatim into this public repo would be the same
|
||||
// durability-vs-publicity conflation the builder header warns about.
|
||||
const text = '# STATE\n\nsome-topic: done -- prose after an ASCII separator\n';
|
||||
const { markers, losses } = extractMarkers(text);
|
||||
assert.equal(markers.length, 0, 'the line still does not parse as a marker');
|
||||
assert.equal(losses.length, 1, 'but it must no longer disappear silently');
|
||||
assert.equal(losses[0].lineNo, 3, 'the operator needs the line number to fix it');
|
||||
assert.match(losses[0].line, /some-topic: done/);
|
||||
});
|
||||
|
||||
test('V6: prose and unknown status tokens are NOT losses (no false positives)', () => {
|
||||
const text = [
|
||||
'just some prose without a marker',
|
||||
`topic: active ${EMDASH} folded away, deliberately not a marker`,
|
||||
`# STATE ${EMDASH} title heading`,
|
||||
'',
|
||||
].join('\n');
|
||||
const { markers, losses } = extractMarkers(text);
|
||||
assert.equal(markers.length, 0);
|
||||
assert.deepEqual(losses, [], 'only a KNOWN status token makes a line a marker attempt');
|
||||
});
|
||||
|
||||
test('V6: buildRegister surfaces marker losses per repo, naming the STATE file', () => {
|
||||
const repos = [{
|
||||
name: 'some-repo',
|
||||
statePath: '/repos/some-repo/STATE.md',
|
||||
markers: [], // losses-only: the repo must still be reported, not skipped
|
||||
losses: [{ lineNo: 78, line: 'some-topic: planned -- prose after an ASCII separator' }],
|
||||
carrier: null,
|
||||
carrierMode: 'absent',
|
||||
}];
|
||||
const { markerLosses } = buildRegister({ repos });
|
||||
assert.equal(markerLosses.length, 1);
|
||||
assert.equal(markerLosses[0].repo, 'some-repo');
|
||||
assert.equal(markerLosses[0].statePath, '/repos/some-repo/STATE.md');
|
||||
assert.equal(markerLosses[0].lineNo, 78);
|
||||
});
|
||||
|
||||
// --- D1 discovery: depth 1 + depth 2 under polyrepo containers (board.sh's rule) ---
|
||||
// Identity is the RAW BASENAME so this builder and board.sh name the same repo the same
|
||||
// way (board.sh:100). Mirrors board.sh:63-89: a dir that is itself a git repo contributes
|
||||
// itself and is NOT descended into; a non-repo dir containing git repos is a container.
|
||||
function tree(spec) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'rollup-discovery-'));
|
||||
for (const [rel, state] of Object.entries(spec)) {
|
||||
const dir = join(root, rel);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
if (state !== null) {
|
||||
mkdirSync(join(dir, '.git'), { recursive: true });
|
||||
writeFileSync(join(dir, 'STATE.md'), state);
|
||||
}
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
const MARKER = `topic-a: planned ${EMDASH} prose\n`;
|
||||
|
||||
test('D1: a depth-1 git repo with markers is discovered (unchanged behaviour)', () => {
|
||||
const root = tree({ 'solo': MARKER });
|
||||
const repos = discoverRepos(root);
|
||||
assert.deepEqual(repos.map((r) => r.name), ['solo']);
|
||||
});
|
||||
|
||||
test('D1: repos under a polyrepo container (depth 2) are discovered', () => {
|
||||
// The measured gap: ktg-plugin-marketplace/{catalog,linkedin-studio,ms-ai-architect}
|
||||
// carried 5 markers that the depth-1 glob could not see.
|
||||
const root = tree({
|
||||
'marketplace/catalog': MARKER,
|
||||
'marketplace/linkedin-studio': MARKER,
|
||||
'solo': MARKER,
|
||||
});
|
||||
const repos = discoverRepos(root);
|
||||
assert.deepEqual(
|
||||
repos.map((r) => r.name).sort(),
|
||||
['catalog', 'linkedin-studio', 'solo'],
|
||||
'container children contribute; the container itself never does',
|
||||
);
|
||||
});
|
||||
|
||||
test('D1: a git repo is NOT descended into (the nested same-name trap stays unreachable)', () => {
|
||||
// claude-code-100x/claude-code-100x: the outer IS a git repo, so board.sh's rule never
|
||||
// reaches the inner one. Matching that rule makes this collision structurally impossible
|
||||
// rather than merely absent today.
|
||||
const root = tree({ 'outer': MARKER, 'outer/outer': MARKER });
|
||||
const repos = discoverRepos(root);
|
||||
assert.deepEqual(repos.map((r) => r.name), ['outer'], 'the inner repo must not be reached');
|
||||
});
|
||||
|
||||
test('D1: a STATE.md in a non-git directory is not a repo', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'rollup-discovery-'));
|
||||
mkdirSync(join(root, 'scratch'), { recursive: true });
|
||||
writeFileSync(join(root, 'scratch', 'STATE.md'), MARKER);
|
||||
assert.deepEqual(discoverRepos(root), [], 'no .git -> not a repo (board.sh:78)');
|
||||
});
|
||||
|
||||
test('D1: a basename collision fails LOUDLY instead of silently overwriting', () => {
|
||||
// Raw basename was chosen so this builder and the coord mailbox name repos alike;
|
||||
// measured 0 collisions across 25 state-bearing dirs today, which is a fact, not a
|
||||
// guarantee -- so the ambiguity must be an error, never a silent pick-one.
|
||||
const root = tree({ 'dup': MARKER, 'container/dup': MARKER });
|
||||
assert.throws(
|
||||
() => discoverRepos(root),
|
||||
(err) => {
|
||||
assert.match(err.message, /collision/i);
|
||||
assert.match(err.message, /dup/);
|
||||
return true;
|
||||
},
|
||||
'two STATE.md files resolving to one name must stop the build',
|
||||
);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue