feat(check-versions): name ungated stat axes instead of skipping them silently

A stat-line axis the plugin does not badge was skipped in silence, so
"checked and right" and "never checked" printed the same thing. Each
plugin now gets an INFO line naming its ungated axes, and the run ends
with "stat claims - N gated - M NOT gated (no badge)". INFO never changes
status or exit code, not even under --strict. Suggested by repo-standard
(reply on BADGE-COUNT, 2026-11-03); operator decision 2026-11-04.

First live run: voyage 0 gated / 5 not gated. Suite 244/244.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-11-04 09:33:52 +01:00
commit af4a3cce32
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
3 changed files with 123 additions and 3 deletions

View file

@ -217,8 +217,12 @@ their own Forgejo repositories under `https://git.fromaitochitta.com/open/`.
stat line (`7 agents · 16 scanners · 21 commands · 1398 tests · [Full documentation →]`). The gate
compares every number on that line against the plugin's own shields badge for the same axis, and
ERRORs when they disagree — the catalog must not overstate a plugin. **The rule is per-AXIS, not
per-plugin:** an axis the plugin does not badge is skipped silently, so there is no exception list
to maintain. **Re-measured 2026-08-13 at the pinned refs, all 12 plugins: 42 catalog axis-claims —
per-plugin:** an axis the plugin does not badge is not checked, so there is no exception list
to maintain — but since 2026-11-04 it is **named, not silent**: each plugin prints an INFO line
listing its ungated axes, and the run ends with `stat claims — N gated · M NOT gated (no badge)`
(operator decision, after repo-standard's BADGE-COUNT reply). INFO never changes status or the
exit code, not even under `--strict`. First live run: voyage, the catalog's one plugin, `0 gated ·
5 NOT gated` — its whole stat line rests on hand re-measurement. **Re-measured 2026-08-13 at the pinned refs, all 12 plugins: 42 catalog axis-claims —
24 badge-covered · 18 badge-less across 8 of the 12 plugins** and therefore ungated, with **0
disagreements** on the gated set. The measurement imports `check-versions.mjs`'s OWN exported
`extractStatBadges` / `extractCatalogStats` and feeds them `git show <ref>:README.md`, so coverage is

View file

@ -54,7 +54,7 @@ export function extractCatalogLabel(readmeText, name) {
// while the plugin's own badge said 1441. This mirrors catalog line -> plugin badge.
//
// The rule is PER-AXIS, not per-plugin. An axis is gated only when the plugin
// carries a badge for it; a badge-less axis is skipped silently. That is not a
// carries a badge for it; a badge-less axis is not checked (but named — see statCoverage). That is not a
// concession to voyage (the one plugin with no stat badges at all) — re-measured
// 2026-08-04 at the pinned refs, 15 of the 42 axis-claims across 7 of the 12
// plugins are badge-less, so a per-plugin exception list would have had to name
@ -168,6 +168,37 @@ export function statMismatchFindings(catalogStats, statBadges) {
return findings;
}
// Which of the catalog's stated axes the gate can actually check (operator decision
// 2026-11-04, after repo-standard's reply on BADGE-COUNT). Skipping a badge-less axis
// in silence made "checked and right" and "never checked" print the same thing — the
// SKIP-is-not-a-pass rule above, one level up. Every badge trim (repo-standard's
// BADGE-COUNT pushes plugins to trim) moves claims from `gated` to `ungated`, so the
// leak has to be visible in the gate's own output, whoever does the trimming.
export function statCoverage(catalogStats, statBadges) {
const gated = [];
const ungated = [];
if (!catalogStats || !statBadges) return { gated, ungated };
for (const axis of catalogStats.keys()) (statBadges.has(axis) ? gated : ungated).push(axis);
return { gated, ungated };
}
// Catalog-wide denominator for the summary line: how many stated stat claims are gated,
// how many are not, and which plugins carry the ungated ones. Reads the `ungated` INFO
// finding classifyPlugin emits (and its `gated` count), so it counts what was reported.
export function coverageSummary(results) {
let gated = 0;
let ungated = 0;
const plugins = [];
for (const r of results) {
const cov = (r.findings ?? []).find(f => f.kind === 'coverage');
if (!cov) continue;
gated += cov.gated.length;
ungated += cov.axes.length;
if (cov.axes.length > 0) plugins.push(r.name);
}
return { gated, ungated, plugins };
}
// Pure classifier — all I/O is resolved into the input shape before this is called.
// tags === null means "repo not inspected" (missing locally); [] means "no tags".
export function classifyPlugin({ name, catalogRef, pluginVersion, readmeBadge, tags, catalogLabel = null, statBadges = null, catalogStats = null, homepage = null, homepageReachable = null }) {
@ -213,6 +244,10 @@ export function classifyPlugin({ name, catalogRef, pluginVersion, readmeBadge, t
if (statBadges !== null && catalogStats !== null) {
findings.push(...statMismatchFindings(catalogStats, statBadges));
}
// 5b. ...and the axes step 5 could NOT check are named, never skipped in silence.
// INFO is outside the status computation below: a stated number with no badge is
// legitimate, it is just unverified, and it needs a human pass whenever the ref moves.
const coverage = statCoverage(catalogStats, statBadges);
// 6. plugin.json homepage must resolve when present — the first link an agent follows
// from a manifest. The field stays OPTIONAL (measured 2026-08-18: 11 of 12 plugins omit
@ -230,6 +265,17 @@ export function classifyPlugin({ name, catalogRef, pluginVersion, readmeBadge, t
: findings.some(f => f.level === 'WARN') ? 'WARN'
: 'OK';
if (findings.length === 0) findings.push({ level: 'OK', msg: `consistent at ${pluginVersion}` });
if (coverage.gated.length + coverage.ungated.length > 0) {
findings.push({
level: coverage.ungated.length > 0 ? 'INFO' : 'OK',
kind: 'coverage',
axes: coverage.ungated,
gated: coverage.gated,
msg: coverage.ungated.length > 0
? `${coverage.ungated.join(', ')}: not gated — no badge (re-measure at the tag when the ref moves)`
: `stat line fully gated (${coverage.gated.join(', ')})`,
});
}
return { name, status, findings };
}
@ -361,7 +407,9 @@ if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url)
for (const f of r.findings) if (f.level !== 'OK') console.log(` ${f.msg}`);
}
const count = (s) => results.filter(r => r.status === s).length;
const cov = coverageSummary(results);
console.log('');
console.log(`stat claims — ${cov.gated} gated · ${cov.ungated} NOT gated (no badge)${cov.plugins.length ? ` in ${cov.plugins.join(', ')}` : ''}`);
console.log(`${results.length} plugins — ${count('OK')} OK, ${count('WARN')} WARN, ${count('ERROR')} ERROR, ${count('SKIP')} SKIP${strict ? ' (strict)' : ''} — verified ${verified}/${total}`);
if (skipped > 0 && !allowSkip) {
console.log(`✗ ${skipped} plugin(s) UNVERIFIED (sibling repo not found locally) — this is NOT a green run.`);

View file

@ -12,6 +12,8 @@ import {
pickStatSource,
extractCatalogStats,
gateOutcome,
statCoverage,
coverageSummary,
} from './check-versions.mjs';
test('normalizeVersion strips a leading v', () => {
@ -399,3 +401,69 @@ test('homepage omitted entirely (legacy callers) → check skipped, stays OK', (
});
assert.equal(r.status, 'OK');
});
// --- Ungated stat claims are NAMED, not silent (operator decision 2026-11-04) -------------
// repo-standard's point, accepted: a mirror that skips a badge-less axis in silence says the
// same thing as a mirror that checked it and found it right — "SKIP is never a pass", one
// level up. The ungated axes are now reported as INFO. INFO never changes a plugin's status
// or the gate's exit code: the catalog may legitimately state a number no badge carries.
test('statCoverage: splits the catalog\'s stated axes into badge-gated and ungated', () => {
const catalogStats = new Map([['agent', 6], ['scanner', 23], ['test', 2013]]);
const statBadges = new Map([['scanner', 23], ['knowledge doc', 22]]);
assert.deepEqual(statCoverage(catalogStats, statBadges), { gated: ['scanner'], ungated: ['agent', 'test'] });
});
test('statCoverage: nothing to say when either side is unknown', () => {
assert.deepEqual(statCoverage(null, new Map()), { gated: [], ungated: [] });
assert.deepEqual(statCoverage(new Map([['agent', 6]]), null), { gated: [], ungated: [] });
});
test('classifyPlugin: ungated axes are named in an INFO finding, and the plugin stays OK', () => {
const r = classifyPlugin({
name: 'p', catalogRef: 'v1.0.0', pluginVersion: '1.0.0', readmeBadge: '1.0.0', tags: ['v1.0.0'],
catalogLabel: '1.0.0',
catalogStats: new Map([['agent', 6], ['scanner', 23], ['hook', 9]]),
statBadges: new Map([['scanner', 23]]),
});
assert.equal(r.status, 'OK');
const info = r.findings.filter(f => f.level === 'INFO');
assert.equal(info.length, 1);
assert.equal(info[0].kind, 'coverage');
assert.deepEqual(info[0].axes, ['agent', 'hook']);
assert.match(info[0].msg, /agent, hook/);
assert.match(info[0].msg, /not gated/);
assert.ok(r.findings.some(f => f.level === 'OK'), 'the consistent-at line is kept alongside the INFO');
});
test('classifyPlugin: no INFO finding when every stated axis is badge-gated', () => {
const r = classifyPlugin({
name: 'p', catalogRef: 'v1.0.0', pluginVersion: '1.0.0', readmeBadge: '1.0.0', tags: ['v1.0.0'],
catalogLabel: '1.0.0',
catalogStats: new Map([['scanner', 23]]),
statBadges: new Map([['scanner', 23]]),
});
assert.equal(r.findings.filter(f => f.level === 'INFO').length, 0);
});
test('gateOutcome: INFO (ungated) never fails the gate, not even under --strict', () => {
const r = classifyPlugin({
name: 'p', catalogRef: 'v1.0.0', pluginVersion: '1.0.0', readmeBadge: '1.0.0', tags: ['v1.0.0'],
catalogLabel: '1.0.0', catalogStats: new Map([['agent', 6]]), statBadges: new Map(),
});
assert.equal(gateOutcome([r], { strict: true }).failed, false);
});
test('coverageSummary: counts gated and ungated claims across plugins, and names the plugins', () => {
const mk = (name, stats, badges) => classifyPlugin({
name, catalogRef: 'v1.0.0', pluginVersion: '1.0.0', readmeBadge: '1.0.0', tags: ['v1.0.0'],
catalogLabel: '1.0.0', catalogStats: new Map(stats), statBadges: new Map(badges),
});
const results = [
mk('a', [['agent', 6], ['scanner', 23]], [['scanner', 23]]),
mk('b', [['test', 5]], [['test', 5]]),
mk('c', [['hook', 3], ['command', 16]], []),
{ name: 'd', status: 'SKIP', findings: [{ level: 'SKIP', msg: 'x' }] },
];
assert.deepEqual(coverageSummary(results), { gated: 2, ungated: 3, plugins: ['a', 'c'] });
});