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:
parent
b5b0e2b88e
commit
5a893e3bfd
3 changed files with 109 additions and 3 deletions
|
|
@ -158,6 +158,9 @@ export function statMismatchFindings(catalogStats, statBadges) {
|
|||
const shown = approx ? `${stated.atLeast}+` : String(stated);
|
||||
findings.push({
|
||||
level: 'ERROR',
|
||||
// `kind` is a machine-readable discriminator, not decoration: release-plugin.mjs's
|
||||
// pre-flight must tell a stat finding from a dangling ref WITHOUT matching on prose.
|
||||
kind: 'stat',
|
||||
msg: `catalog says ${shown} ${axis} but the plugin's badge says ${badge} (catalog stat line is stale)`,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -125,8 +125,36 @@ export function reconcileReadmeLabel(readmeText, name, newRef) {
|
|||
// Reads the ERROR set explicitly and NEVER `failed`/`hasWarn`: pre-bump, the plugin being
|
||||
// released is SUPPOSED to be WARN (catalog ref behind plugin.json). Gating on WARN would
|
||||
// brick every release.
|
||||
export function preflightErrors(gateResult) {
|
||||
return (gateResult?.results ?? []).filter(r => r.status === 'ERROR').map(r => r.name);
|
||||
//
|
||||
// Q3f — ONE exemption, for the ONE plugin being released: its STAT findings. Measured
|
||||
// 2026-09-18 on two live orders (repo-mailbox 0.35.0, llm-security 8.0.0), the two
|
||||
// pre-flights demanded opposite values of the same stat line and no release that changes
|
||||
// a badged number could complete at all:
|
||||
// (a) preflightStatMismatches (runRelease, before the tag) reads the TARGET ref -> new number
|
||||
// (b) this gate -> runGate -> inspectPlugin reads the stat source at the PINNED ref -> old number
|
||||
// The pinned ref is moved by exactly the write (b) blocks, so retry does not help. And
|
||||
// --create-tag is deliberately ungated on (b), so a real run PUBLISHES the tag and then
|
||||
// blocks here: a pushed tag against a catalog that can never be bumped.
|
||||
//
|
||||
// Nothing goes unverified by waiving it. The released plugin's stat line is checked at
|
||||
// BOTH edges of the window where the two refs disagree: (a) validates it against the
|
||||
// target before anything is touched, and the post-write check-versions run validates it
|
||||
// against the now-pinned new ref. Only the middle — where the pinned ref is knowably
|
||||
// stale — is skipped.
|
||||
//
|
||||
// The waiver is deliberately narrow in three ways: it applies to `releasingName` only,
|
||||
// to `kind === 'stat'` findings only (a discriminator, never a prose match), and it
|
||||
// refuses to reason about an ERROR that carries no ERROR finding — an unexplained ERROR
|
||||
// blocks. A dangling ref, a badge/plugin.json disagreement, a wrong README label or a
|
||||
// dead homepage on the released plugin still block it, exactly as before.
|
||||
export function preflightErrors(gateResult, releasingName = null) {
|
||||
return (gateResult?.results ?? []).filter(r => {
|
||||
if (r.status !== 'ERROR') return false;
|
||||
if (releasingName === null || r.name !== releasingName) return true;
|
||||
const errs = (r.findings ?? []).filter(f => f.level === 'ERROR');
|
||||
if (errs.length === 0) return true;
|
||||
return errs.some(f => f.kind !== 'stat');
|
||||
}).map(r => r.name);
|
||||
}
|
||||
|
||||
// --create-tag mints AND PUSHES a tag to a public remote — the one genuinely irreversible
|
||||
|
|
@ -311,7 +339,7 @@ export function pushWithToken({ cwd, home, exists, unlink, push }) {
|
|||
// (which throws on exit 1), leaving a half-applied release in the working tree for a parallel
|
||||
// session to carry to the public remote. `io` is injected so the ORDER is testable.
|
||||
export function applyRelease({ plan, catalogDir, mktPath, readmePath }, io) {
|
||||
const errors = preflightErrors(io.runGate(catalogDir));
|
||||
const errors = preflightErrors(io.runGate(catalogDir), plan.name);
|
||||
if (errors.length > 0) return { verdict: 'BLOCKED', preflightErrors: errors, writes: [], readme: null };
|
||||
|
||||
const writes = [];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue