fix(catalog): bryt stat-linje-vranglåsen mellom de to pre-flightene

Q3e innførte en pre-flight som leser den NYE ref-ens badger, mens applyRelease'
pre-flight leser den PINNEDE ref-en. De krevde motsatte verdier av samme
stat-linje, og den pinnede ref-en flyttes først av nettopp den skrivingen som
blokkeres — så ingen release som endrer et badget tall kunne fullføres. Målt på
to levende ordrer (repo-mailbox 0.35.0, llm-security 8.0.0).

Verre enn en blokk: --create-tag er med vilje ugatet på den katalog-brede
pre-flighten, så et ekte løp ville MINTET OG PUSHET taggen og deretter blokkert
— en publisert tag mot en katalog som aldri kan bumpes.

Fiks: preflightErrors tar navnet på pluginen som slippes og ser bort fra DENS
stat-funn. Ingenting blir uverifisert — (a) validerer stat-linja mot målet før
noe røres, og post-write-gaten validerer den mot den nå-pinnede nye ref-en. Kun
vinduet der den pinnede ref-en er kjent stale hoppes over.

Vakten er smal i tre ledd: kun den pluginen som slippes, kun funn med
kind='stat' (diskriminator i check-versions.mjs, aldri prosa-match), og en
ERROR uten ERROR-funn blir aldri frikjent. Hengende ref, badge-avvik, feil
README-etikett og død homepage blokkerer som før.

Valgt denne framfor å la applyRelease skrive stat-linja selv, fordi den ville
måttet innføre en maskinpolicy for badge-løse akser — og de er ved dokumentert
org-beslutning et menneskelig skjønn.

Iron Law: 5 nye tester, 2 røde før fiksen. 172/172 grønne.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-18 01:05:39 +02:00
commit 5a893e3bfd
3 changed files with 109 additions and 3 deletions

View file

@ -175,6 +175,81 @@ test('preflightErrors names every ERROR plugin, catalog-wide (not just the targe
assert.deepEqual(preflightErrors(gateResult({ alpha: 'OK', beta: 'WARN', gamma: 'SKIP' })), []);
});
// --- Q3f: the stat-line DEADLOCK between the two pre-flights ------------------
//
// Measured 2026-09-18 on two live orders (repo-mailbox 0.35.0, llm-security 8.0.0):
// a release that changes ANY badged stat number cannot complete, because the two
// pre-flights demand OPPOSITE values of the same catalog stat line:
// (a) preflightStatMismatches (runRelease, BEFORE the tag) reads the TARGET ref's
// badges -> demands the NEW number.
// (b) applyRelease's preflightErrors -> runGate -> inspectPlugin reads the stat
// source at the PINNED ref -> demands the OLD number.
// The pinned ref is moved by exactly the write that (b) blocks, so no value satisfies
// both, retry included. Worse: --create-tag is deliberately ungated on (b), so a real
// run mints and PUSHES the tag, then blocks on (b) — a published tag against a catalog
// that can never be bumped.
//
// The fix: for the ONE plugin being released, a stat finding measures the ref this
// release is about to replace, and (a) has already validated the stat line against the
// target. So (b) drops stat findings for that plugin only. Every other finding for it,
// and every finding for every other plugin, still blocks.
const statFinding = (msg = 'catalog says 22 scanner but the plugin\'s badge says 23 (catalog stat line is stale)') =>
({ level: 'ERROR', kind: 'stat', msg });
const oneResult = (name, status, findings) => ({
results: [{ name, status, findings }],
hasError: status === 'ERROR', hasWarn: status === 'WARN', failed: status === 'ERROR',
});
test('DEADLOCK: a stat-only ERROR on the plugin being RELEASED must not block its own write', () => {
const plan = planRelease({ marketplace: marketplace(), name: 'alpha', observed: observed() });
// What the real classifier produces mid-release: catalog ref still v1.0.0 (WARN, normal),
// plus the stat line already carrying the NEW number that the OLD ref contradicts.
const io = fakeIo(oneResult('alpha', 'ERROR', [
{ level: 'WARN', msg: 'catalog ref v1.0.0 != plugin.json version 1.1.0 (unreleased bump)' },
statFinding(),
]));
const r = applyRelease({ plan, ...paths }, io);
assert.equal(r.verdict, 'WROTE', 'the release must be able to complete');
assert.deepEqual(r.preflightErrors, []);
});
test('DEADLOCK fix is scoped: a stat ERROR on ANOTHER plugin still blocks', () => {
const plan = planRelease({ marketplace: marketplace(), name: 'alpha', observed: observed() });
const io = fakeIo(oneResult('beta', 'ERROR', [statFinding()]));
const r = applyRelease({ plan, ...paths }, io);
assert.equal(r.verdict, 'BLOCKED');
assert.deepEqual(r.preflightErrors, ['beta']);
assert.deepEqual(io.writes, []);
});
test('DEADLOCK fix is narrow: a NON-stat ERROR on the released plugin still blocks', () => {
const plan = planRelease({ marketplace: marketplace(), name: 'alpha', observed: observed() });
const io = fakeIo(oneResult('alpha', 'ERROR', [
statFinding(),
{ level: 'ERROR', msg: 'plugin.json version 1.1.0 != README version-badge 1.0.9' },
]));
const r = applyRelease({ plan, ...paths }, io);
assert.equal(r.verdict, 'BLOCKED', 'a dangling ref / bad badge must never be waived');
assert.deepEqual(r.preflightErrors, ['alpha']);
assert.deepEqual(io.writes, []);
});
test('DEADLOCK fix cannot reason without findings: ERROR with no ERROR finding still blocks', () => {
const plan = planRelease({ marketplace: marketplace(), name: 'alpha', observed: observed() });
const io = fakeIo(oneResult('alpha', 'ERROR', []));
const r = applyRelease({ plan, ...paths }, io);
assert.equal(r.verdict, 'BLOCKED', 'an unexplained ERROR is never waived');
assert.deepEqual(r.preflightErrors, ['alpha']);
});
test('preflightErrors without a releasing name waives nothing (catalog-wide default)', () => {
const gate = oneResult('alpha', 'ERROR', [statFinding()]);
assert.deepEqual(preflightErrors(gate), ['alpha']);
assert.deepEqual(preflightErrors(gate, 'alpha'), []);
});
test('pre-flight ERROR aborts BEFORE any file is written (ordering, not just verdict)', () => {
const plan = planRelease({ marketplace: marketplace(), name: 'alpha', observed: observed() });
const io = fakeIo(gateResult({ alpha: 'WARN', beta: 'ERROR' })); // ERROR on a DIFFERENT plugin