feat(gate): install truth, honest badges anywhere, and two stripCode bugs

Install truth is the brief's first control and the gate only checked
syntax. Now: the marketplace URL must be the real one (offline, from the
register), and the plugin must actually be pinned in the catalog (one
call, SKIP if unreachable). A well-formed `claude plugin install x@mkt`
fails silently when x was never pinned.

This makes the gate block ITSELF until publication finishes - the run
against this repo now has exactly one ERROR, and it is true: repo-standard
is not in the catalog yet. That is the post-publish acceptance test,
enforced mechanically instead of remembered.

Badge honesty no longer keys on img.shields.io. A self-hosted SVG asserts
the same unverified thing, and the README claimed the general rule while
the code checked one host.

Two stripCode bugs, both silent false passes:
- 4-space indent treated as code unconditionally made links inside nested
  list items invisible. Fixed by requiring a blank line to OPEN a block.
- That fix alone ended the block after line 1, so multi-line indented
  templates leaked back into scanning. Caught by the gate on this repo's
  own SKILL.md, which shows a README template containing a CHANGELOG link.
A block now opens on a blank line and continues while the indent holds.

Also corrected two claims in this README: it said "one network call"
when there are two, and it still argued against a CONTRIBUTING using
reasoning the solo-maintainer section had already replaced.

77 tests. llm-security regression: still zero link and boilerplate noise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WYJ3FHLtVgzFXMZ6UF598h
This commit is contained in:
Kjell Tore Guttormsen 2026-07-27 16:13:17 +02:00
commit 6b1db0096e
5 changed files with 187 additions and 16 deletions

View file

@ -215,6 +215,23 @@ export function checkInstallBlock({ readme, name, klass }, register) {
});
}
// A well-formed command pointing at the wrong marketplace is still a command
// that does not work. Checked against the register, so it needs no network.
if (hasAdd && mkt.url) {
const urls = addLines
.map((l) => /marketplace\s+add\s+(\S+)/.exec(l)?.[1])
.filter(Boolean)
.map((u) => u.replace(/[`'"]+$/, ''));
if (urls.length && !urls.some((u) => normalizeRepoRef(u) === normalizeRepoRef(mkt.url))) {
findings.push({
level: 'ERROR',
code: 'INSTALL-URL-MISMATCH',
bucket: 'broken',
msg: `\`marketplace add\` points at ${urls[0]}, but this marketplace is ${mkt.url}`,
});
}
}
if (form === 'plugin' || form === 'catalog') {
if (!hasAdd) {
findings.push({
@ -279,6 +296,26 @@ export function checkInstallBlock({ readme, name, klass }, register) {
// Per class, never flat. A flat standard demands a CONTRIBUTING from a CSS
// library that takes no contributions and a ROADMAP from a five-line profile.
// Syntax is not truth. The most disqualifying failure a repo can have is an
// install command that does not work for a stranger, and a perfectly formed
// `claude plugin install x@mkt` fails silently if `x` was never pinned in the
// catalog. This is the one check that answers the brief's first question.
export function checkInstallTruth({ name, klass, catalogNames }) {
if (klass !== 'plugin') return [{ level: 'OK', code: 'INSTALL-TRUTH', msg: 'not a marketplace plugin — nothing to resolve' }];
if (!catalogNames) {
return [{ level: 'SKIP', code: 'INSTALL-TRUTH', msg: 'catalog not reachable — cannot verify the install command actually resolves' }];
}
if (!catalogNames.includes(name)) {
return [{
level: 'ERROR',
code: 'INSTALL-NOT-IN-CATALOG',
bucket: 'broken',
msg: `\`${name}\` is not pinned in the marketplace catalog — the documented install command cannot succeed for anyone`,
}];
}
return [{ level: 'OK', code: 'INSTALL-TRUTH', msg: 'the install command resolves against the catalog' }];
}
// Requirements come from two axes. The CLASS is structural — it can be read off
// the catalog and the remotes. A TRAIT is about what the code does, which no
// remote can tell you: `security` attaches the obligations a tool acquires by
@ -395,7 +432,9 @@ const CLAIM_BADGE = /(tests?|build|ci|coverage|passing|status)/i;
export function checkBadges({ readme }) {
const findings = [];
for (const line of String(readme ?? '').split('\n')) {
for (const m of line.matchAll(/(\[)?!\[([^\]]*)\]\((https:\/\/img\.shields\.io\/badge\/[^)]+)\)(\])?/g)) {
// Any image, any host. Restricting this to img.shields.io would have missed
// a self-hosted SVG asserting exactly the same unverified thing.
for (const m of line.matchAll(/(\[)?!\[([^\]]*)\]\(([^)\s]+)\)(\])?/g)) {
const linked = m[1] === '[' && m[4] === ']';
const label = `${m[2]} ${m[3]}`;
if (!linked && CLAIM_BADGE.test(label)) {
@ -471,6 +510,8 @@ export function checkLicenseClaim({ readme, present }) {
// one of them was noise.
export function stripCode(text) {
let fenced = false;
let prevBlank = true;
let inIndented = false;
return String(text ?? '')
.split('\n')
.map((line) => {
@ -479,7 +520,22 @@ export function stripCode(text) {
return '';
}
if (fenced) return '';
if (/^(\s{4,}|\t)\S/.test(line)) return ''; // indented code block
const blank = line.trim() === '';
const indented = /^(\s{4,}|\t)\S/.test(line);
// An indented line OPENS a code block only after a blank line — otherwise
// a nested list item would count, which made links inside nested bullets
// invisible. But once open, the block CONTINUES while lines stay indented;
// requiring a blank line before every line let everything after line 1
// leak back into scanning.
if (indented && (prevBlank || inIndented)) {
inIndented = true;
prevBlank = false;
return '';
}
if (!blank && !indented) inIndented = false;
prevBlank = blank;
if (inIndented && blank) return '';
return line.replace(/`[^`]*`/g, '');
})
.join('\n');
@ -567,7 +623,7 @@ export function bucketsOf(findings) {
}
export function classifyRepo(
{ name, files, present, description, pluginVersion, readmeBadge, changelogTop, tags },
{ name, files, present, description, pluginVersion, readmeBadge, changelogTop, tags, catalogNames },
register,
) {
const klass = register.repos?.[name];
@ -591,6 +647,7 @@ export function classifyRepo(
const findings = [
...checkFirstScreen({ readme, name, description }),
...checkInstallBlock({ readme, name, klass }, register),
...checkInstallTruth({ name, klass, catalogNames }),
...checkHeadings({ readme, klass, traits }, register),
...checkRequiredFiles({ present, klass, traits }, register),
...checkLinks({ files }, register),
@ -614,6 +671,23 @@ export function loadRegister(path = REGISTER_PATH) {
// ONE call. The org listing already carries description and topics; fetching
// per repo trips the rate limiter (HTTP 429). Reads anonymously — verified —
// so this works for any reader, not only for someone holding a token.
// The catalog's plugin list, read straight from the forge. One more call, and
// it is what turns "the install line is well-formed" into "the install line
// works". Null on any failure, which reads as SKIP rather than a pass.
async function fetchCatalogNames(register) {
const mkt = register.marketplace ?? {};
if (!mkt.name) return null;
const url = `${register.forge}/api/v1/repos/${register.org}/${mkt.name}/raw/.claude-plugin/marketplace.json`;
try {
const res = await fetch(url, { headers: { accept: 'application/json' } });
if (!res.ok) return null;
const json = JSON.parse(await res.text());
return (json.plugins ?? []).map((p) => p.name).filter(Boolean);
} catch {
return null;
}
}
async function fetchOrgListing(register) {
const url = `${register.forge}/api/v1/orgs/${register.org}/repos?limit=50`;
const res = await fetch(url, { headers: { accept: 'application/json' } });
@ -683,7 +757,7 @@ function gitTags(dir) {
}
}
export function inspectRepo(dir, name, register, description) {
export function inspectRepo(dir, name, register, description, catalogNames = null) {
const tracked = gitFiles(dir);
const present = (tracked ?? []).filter((f) => existsSync(join(dir, f)));
@ -710,6 +784,7 @@ export function inspectRepo(dir, name, register, description) {
readmeBadge: extractBadgeVersion(readme),
changelogTop: changelog === null ? null : extractChangelogTop(changelog),
tags: gitTags(dir),
catalogNames,
}, register);
}
@ -775,7 +850,9 @@ async function main(argv) {
const name = arg('--name', repoNameFrom(dir));
let description = null;
let catalogNames = null;
if (!argv.includes('--offline')) {
catalogNames = await fetchCatalogNames(register);
try {
const listing = await fetchOrgListing(register);
const row = listing.find((r) => r.name === name);
@ -786,7 +863,7 @@ async function main(argv) {
}
}
const result = inspectRepo(dir, name, register, description);
const result = inspectRepo(dir, name, register, description, catalogNames);
if (argv.includes('--json')) {
console.log(JSON.stringify(result, null, 2));