repo-standard/scripts/repo-standard-check.mjs
Kjell Tore Guttormsen 2357771587 fix(gate): a differing README H1 is a WARN, not an ERROR
Measured against the live repos: okr opens `# OKR for Public Sector`
and claude-design `# Claude Design Facilitator`. Neither breaks the
thread the contract exists to protect - description == catalog ==
opening line - because the H1 is none of those three. Failing them
would be the gate that stops a correct repo, which is what teaches
people to switch gates off.

A missing H1 stays an ERROR, and a differing one no longer short-
circuits the description check.

Validation against three cases the census measured by hand, all
reproduced independently: okr (neither install line), claude-design
(slash form, no marketplace add), repo-mailbox (install correct,
first screen wrong). 34 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WYJ3FHLtVgzFXMZ6UF598h
2026-07-27 09:14:33 +02:00

425 lines
16 KiB
JavaScript

#!/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/<name>` 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 <path>] [--name <repo>] [--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 ? '<empty file>' : `\`${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));
}