#!/usr/bin/env node // repo-standard โ€” the per-repo gate. // // Checks ONE repository against the standard for its class: // - README first screen: H1 is the repo name, next line IS the forge description // - Install block complete, in the form its class actually uses // - Required files present for its class // - Every `open/` reference in URL position resolves // - Description within the length bound, measured in codepoints // // What it deliberately does NOT do: anything that needs to see all repos at once. // Divergence across the org (0/18 topics, three competing install forms, README // release notes duplicating a CHANGELOG that 16 of 18 repos have) is invisible // from inside one repo. Those checks live in org-ops, not here. // // Structure mirrors the marketplace's check-versions.mjs on purpose: pure // classifiers with all I/O resolved into their input, findings tagged // ERROR/WARN/SKIP/OK, exit 1 on ERROR. This is a gate, not a checklist โ€” // the catalog's eleven descriptions are good because a gate runs on them; the // forge's nine were empty. Same care, different outcome. // // Usage: // node scripts/repo-standard-check.mjs [--dir ] [--name ] [--offline] [--json] // node scripts/repo-standard-check.mjs --refresh # register vs. live org listing import { readFileSync, existsSync } from 'node:fs'; import { execFileSync } from 'node:child_process'; import { join, dirname, basename } from 'node:path'; import { fileURLToPath } from 'node:url'; const HERE = dirname(fileURLToPath(import.meta.url)); const REGISTER_PATH = join(HERE, '..', 'register', 'repos.json'); const LEVELS = ['OK', 'SKIP', 'WARN', 'ERROR']; // ------------------------------------------------------------ pure helpers // Codepoints. Not bytes (an em-dash costs 3) and not UTF-16 units (`๐Ÿ‘‰` costs 2). // The em-dash exposes only the outer layer, which is why "characters, not bytes" // was not enough on its own. export function countCodepoints(s) { return [...String(s ?? '')].length; } // ~20 "dead" repo names collapsed to 3 real ones once this ran. A clone URL // ending in .git is a legitimate reference, not a broken one. export function normalizeRepoRef(raw) { return String(raw ?? '') .replace(/\/+$/, '') .replace(/\.git$/, ''); } // Only names in URL position are resolvable references. That single rule // excludes all three of the measured "correct text that looks broken" cases at // once: a path position (`~/.claude/coord/_broadcast/`), running prose (`coord` // is still the transport protocol's name), and a bare directory name. const URL_REF = /(?::\/\/[^\s)\]"'`]*\/|@[^\s:]+:)open\/([A-Za-z0-9._-]+)/g; export function extractOpenRefs(text) { const out = []; const lines = String(text ?? '').split('\n'); lines.forEach((line, i) => { for (const m of line.matchAll(URL_REF)) { out.push({ name: normalizeRepoRef(m[1]), line: i + 1, raw: m[0] }); } }); return out; } // Three outcomes, never two. "No match" and "match on something that is not a // repo" must stay distinguishable โ€” if they share an outcome, the loss goes // silent, and silent loss is the defect class this standard exists to catch. export function classifyRef(name, register) { if (Object.prototype.hasOwnProperty.call(register.repos ?? {}, name)) return 'repo'; if (Object.prototype.hasOwnProperty.call(register.non_repos ?? {}, name)) return 'non-repo'; return 'unknown'; } export function checkLinks({ files }, register) { const findings = []; for (const [path, text] of Object.entries(files ?? {})) { for (const ref of extractOpenRefs(text)) { const kind = classifyRef(ref.name, register); if (kind === 'repo') continue; if (kind === 'non-repo') { findings.push({ level: 'WARN', code: 'LINK-NON-REPO', msg: `${path}:${ref.line} โ€” \`open/${ref.name}\` resolves to a known non-repo: ${register.non_repos[ref.name]}`, }); } else { findings.push({ level: 'ERROR', code: 'LINK-DEAD', msg: `${path}:${ref.line} โ€” \`open/${ref.name}\` matches no repo in the register (dead reference)`, }); } } } return findings; } export function checkDescription(description, register) { if (description === null || description === undefined) { return [{ level: 'SKIP', code: 'DESC-UNAVAILABLE', msg: 'forge description not available โ€” check not run (offline, or the listing failed)' }]; } const max = register.description_max_codepoints ?? 180; const n = countCodepoints(description); if (n === 0) return [{ level: 'ERROR', code: 'DESC-EMPTY', msg: 'forge description is empty' }]; if (n > max) { return [{ level: 'ERROR', code: 'DESC-TOO-LONG', msg: `forge description is ${n} codepoints, bound is ${max}` }]; } return [{ level: 'OK', code: 'DESC', msg: `description ${n}/${max} codepoints` }]; } // The opening line makes description == catalog == README: the same thread on a // third surface, and the only one of the three a machine can check from inside // the repo. export function checkFirstScreen({ readme, name, description }) { const findings = []; const lines = String(readme ?? '').split('\n'); const firstIdx = lines.findIndex((l) => l.trim() !== ''); const heading = firstIdx === -1 ? null : lines[firstIdx].trim(); // No heading at all is broken. A heading that merely differs from the repo // name is not: the thread that has to hold is description == catalog == // opening line, and the H1 is none of those three. A human title like // `# OKR for Public Sector` is a naming choice the operator owns, so it is // surfaced and left to them โ€” a gate that fails a correct repo is the // mechanism that gets gates switched off. if (heading === null || !heading.startsWith('# ')) { findings.push({ level: 'ERROR', code: 'README-H1', msg: `README must open with an H1 (expected \`# ${name}\`, found: ${heading === null ? '' : `\`${heading}\``})`, }); return findings; } if (heading !== `# ${name}`) { findings.push({ level: 'WARN', code: 'README-H1', msg: `H1 is \`${heading}\`, not \`# ${name}\` โ€” deliberate title, or drift? Operator's call.`, }); } else { findings.push({ level: 'OK', code: 'README-H1', msg: `H1 is \`# ${name}\`` }); } if (description === null || description === undefined) { findings.push({ level: 'SKIP', code: 'README-DESC', msg: 'forge description not available โ€” opening-line match not checked' }); return findings; } const restIdx = lines.findIndex((l, i) => i > firstIdx && l.trim() !== ''); const opening = restIdx === -1 ? '' : lines[restIdx].trim(); if (opening !== String(description).trim()) { findings.push({ level: 'ERROR', code: 'README-DESC', msg: `README opening line does not match the forge description\n README: ${opening}\n forge: ${description}`, }); } else { findings.push({ level: 'OK', code: 'README-DESC', msg: 'opening line matches the forge description' }); } return findings; } // `claude plugin install x@mkt` or `/plugin install x@mkt` โ€” the two CLI forms. function hasCliInstall(readme, name, mkt) { const esc = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); return new RegExp(`(?:claude\\s+plugin|/plugin)\\s+install\\s+${esc(name)}@${esc(mkt)}\\b`).test(readme); } function hasAnyPluginInstall(readme) { return /(?:claude\s+plugin|\/plugin)\s+install\s+\S+@\S+/.test(readme); } export function checkInstallBlock({ readme, name, klass }, register) { const form = register.classes?.[klass]?.install ?? 'none'; const text = String(readme ?? ''); const mkt = register.marketplace ?? {}; const findings = []; if (form === 'none') return findings; const addLines = text.split('\n').filter((l) => /plugin\s+marketplace\s+add/.test(l)); const hasAdd = addLines.length > 0; // The forge UI's clone button hands out the ssh URL, and `marketplace add` // answers it with "Invalid git URL" โ€” a message that never mentions the // protocol. Measured end-to-end 2026-07-25. if (addLines.some((l) => /ssh:\/\//.test(l))) { findings.push({ level: 'ERROR', code: 'INSTALL-SSH', msg: '`marketplace add` is shown with an ssh:// URL โ€” it rejects those ("Invalid git URL"). Use the https form.', }); } if (form === 'plugin' || form === 'catalog') { if (!hasAdd) { findings.push({ level: 'ERROR', code: 'INSTALL-NO-MARKETPLACE', msg: `no \`plugin marketplace add\` line โ€” the reader is never told to add \`${mkt.name}\` (${mkt.url})`, }); } else { findings.push({ level: 'OK', code: 'INSTALL-MARKETPLACE', msg: '`marketplace add` present' }); } } if (form === 'plugin') { // The corrected defect A. `enabledPlugins` in settings.json is a LEGITIMATE // second form and it stands in 10 of 11 plugin READMEs โ€” what is missing in // 7 of them is a CLI command. So the contract requires the command and // permits the JSON alongside it; it never accepts the JSON as a substitute. // A reader who scrolls to the JSON block has a complete path; an agent told // "install this" reaches for the CLI and finds `marketplace add` and nothing else. if (!hasCliInstall(text, name, mkt.name)) { findings.push({ level: 'ERROR', code: 'INSTALL-NO-CLI', msg: `no CLI install command for this repo โ€” expected \`claude plugin install ${name}@${mkt.name}\` (or the \`/plugin install\` form). An \`enabledPlugins\` block is a welcome addition, but it is not a CLI command.`, }); } else { findings.push({ level: 'OK', code: 'INSTALL-CLI', msg: `CLI install command names ${name}@${mkt.name}` }); } } if (form === 'vendor' && hasAnyPluginInstall(text)) { findings.push({ level: 'ERROR', code: 'INSTALL-WRONG-FORM', msg: 'shared asset shows a plugin install line โ€” it is vendored into consumers, not installed. Document how to vendor it.', }); } if (form === 'package') { if (hasAnyPluginInstall(text)) { findings.push({ level: 'ERROR', code: 'INSTALL-WRONG-FORM', msg: 'standalone project shows a plugin install line โ€” use the pip/uv form.', }); } else if (!/\b(pip\s+install|uv\s+(?:pip\s+)?(?:add|sync|install|run)|uvx)\b/.test(text)) { findings.push({ level: 'WARN', code: 'INSTALL-NO-PACKAGE-FORM', msg: 'no pip/uv install form found โ€” expected for a standalone project', }); } } return findings; } // 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. export function checkRequiredFiles({ present, klass }, register) { const required = register.classes?.[klass]?.required_files ?? []; const have = new Set(present ?? []); const findings = []; for (const f of required) { if (!have.has(f)) { findings.push({ level: 'ERROR', code: 'FILE-MISSING', msg: `missing required file for class \`${klass}\`: ${f}` }); } } if (findings.length === 0 && required.length > 0) { findings.push({ level: 'OK', code: 'FILES', msg: `all ${required.length} required files present` }); } return findings; } export function levelOf(findings) { let worst = 'OK'; for (const f of findings ?? []) { if (LEVELS.indexOf(f.level) > LEVELS.indexOf(worst)) worst = f.level; } return worst; } export function classifyRepo({ name, files, present, description }, register) { const klass = register.repos?.[name]; if (!klass) { return { name, klass: null, status: 'SKIP', findings: [{ level: 'SKIP', code: 'REPO-UNREGISTERED', msg: `\`${name}\` is not in the register โ€” class unknown, so no class-specific rule can be applied. Add it to register/repos.json (or run --refresh).`, }], }; } const readme = (files ?? {})['README.md'] ?? ''; const findings = [ ...checkFirstScreen({ readme, name, description }), ...checkInstallBlock({ readme, name, klass }, register), ...checkRequiredFiles({ present, klass }, register), ...checkLinks({ files }, register), ...checkDescription(description, register), ]; return { name, klass, status: levelOf(findings), findings }; } // ---------------------------------------------------------------- I/O shell export function loadRegister(path = REGISTER_PATH) { return JSON.parse(readFileSync(path, 'utf8')); } // 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. 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' } }); if (!res.ok) throw new Error(`org listing returned HTTP ${res.status}`); return res.json(); } function gitFiles(dir) { try { return execFileSync('git', ['-C', dir, 'ls-files'], { encoding: 'utf8' }) .split('\n') .map((s) => s.trim()) .filter(Boolean); } catch { return null; } } function repoNameFrom(dir) { try { return basename(execFileSync('git', ['-C', dir, 'rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim()); } catch { return basename(dir); } } export function inspectRepo(dir, name, register, description) { const tracked = gitFiles(dir); const present = (tracked ?? []).filter((f) => existsSync(join(dir, f))); // Link scanning covers every tracked Markdown file โ€” a dead reference in a // doc is as broken as one in the README. const files = {}; for (const f of (tracked ?? []).filter((p) => p.endsWith('.md'))) { try { files[f] = readFileSync(join(dir, f), 'utf8'); } catch { /* unreadable โ€” skip */ } } if (!files['README.md'] && existsSync(join(dir, 'README.md'))) { files['README.md'] = readFileSync(join(dir, 'README.md'), 'utf8'); } return classifyRepo({ name, files, present, description }, register); } function render(result) { const mark = { OK: 'โœ“', WARN: '!', ERROR: 'โœ—', SKIP: 'ยท' }; const klass = result.klass ? ` [${result.klass}]` : ''; console.log(`\n${mark[result.status]} ${result.name}${klass} โ€” ${result.status}`); for (const f of result.findings) { if (f.level === 'OK') continue; console.log(` ${mark[f.level]} ${f.level} ${f.code}: ${f.msg}`); } const okCount = result.findings.filter((f) => f.level === 'OK').length; if (okCount) console.log(` ${mark.OK} ${okCount} check(s) passed`); } async function refresh(register) { const live = await fetchOrgListing(register); const liveNames = new Set(live.map((r) => r.name)); const known = new Set(Object.keys(register.repos ?? {})); const added = [...liveNames].filter((n) => !known.has(n)).sort(); const gone = [...known].filter((n) => !liveNames.has(n)).sort(); console.log(`register: ${known.size} repos ยท forge: ${liveNames.size} repos`); if (added.length) console.log(`\n on the forge, not in the register (add with a class):\n ${added.join('\n ')}`); if (gone.length) console.log(`\n in the register, not on the forge:\n ${gone.join('\n ')}`); if (!added.length && !gone.length) console.log('\n โœ“ register matches the forge'); return added.length + gone.length === 0 ? 0 : 1; } async function main(argv) { const arg = (flag, fallback = null) => { const i = argv.indexOf(flag); return i === -1 ? fallback : argv[i + 1]; }; const register = loadRegister(); if (argv.includes('--refresh')) { process.exit(await refresh(register)); } const dir = arg('--dir', process.cwd()); const name = arg('--name', repoNameFrom(dir)); let description = null; if (!argv.includes('--offline')) { try { const listing = await fetchOrgListing(register); const row = listing.find((r) => r.name === name); description = row ? (row.description ?? '') : null; } catch { // Unreachable forge leaves description null, which reads as SKIP โ€” never // as a pass. A check that could not run says so. } } const result = inspectRepo(dir, name, register, description); if (argv.includes('--json')) { console.log(JSON.stringify(result, null, 2)); } else { render(result); } process.exit(result.status === 'ERROR' ? 1 : 0); } if (process.argv[1] && process.argv[1].endsWith('repo-standard-check.mjs')) { main(process.argv.slice(2)); }