feat(repo-standard): v0.1.0 - per-repo gate for the open/ standard
Five checks a single repository can answer on its own: README first screen, install block, files required by its class, open/<name> references, description length. Pure classifiers with I/O resolved into their input, mirroring check-versions.mjs; ERROR/WARN/SKIP/OK, exit 1 on ERROR. 32 tests. The reference check has THREE outcomes: "matches no repo" (ERROR) is separate from "matches a known non-repo" (WARN). Sharing an outcome would let real dead links hide inside correct text. Only names in URL position count, and .git is normalised first - without that a raw scan turns 3 dead names into ~20. enabledPlugins is treated as a legitimate second install form; what the gate requires in addition is a CLI command. The JSON form is never reported as the defect. STATE.md is gitignored from this first commit - public remote. No hook yet: a blocking gate must first be precise enough not to fail a correct repository. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WYJ3FHLtVgzFXMZ6UF598h
This commit is contained in:
commit
816ba97c63
11 changed files with 1319 additions and 0 deletions
12
.claude-plugin/plugin.json
Normal file
12
.claude-plugin/plugin.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"name": "repo-standard",
|
||||
"version": "0.1.0",
|
||||
"description": "Per-repo gate for the open/ presentation standard: README first screen, install block, files required by the repo's class, and dead repo references.",
|
||||
"author": {
|
||||
"name": "Kjell Tore Guttormsen"
|
||||
},
|
||||
"auto_discover": true,
|
||||
"license": "MIT",
|
||||
"repository": "https://git.fromaitochitta.com/open/repo-standard",
|
||||
"keywords": ["repository-standard", "readme", "gate", "documentation", "multi-repo"]
|
||||
}
|
||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
# Operator-private continuity state. This repo has a PUBLIC remote, so STATE.md
|
||||
# is LOCAL-ONLY here — it must never reach the mirror. (Private-remote repos
|
||||
# track theirs; do not carry that habit across.)
|
||||
STATE.md
|
||||
*.local.md
|
||||
|
||||
node_modules/
|
||||
.DS_Store
|
||||
44
CHANGELOG.md
Normal file
44
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
# Changelog
|
||||
|
||||
All notable changes to this project are documented here.
|
||||
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
|
||||
versioning is [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.1.0] — 2026-07-27
|
||||
|
||||
First release. Covers the checks that a single repository can answer on its own.
|
||||
|
||||
### Added
|
||||
|
||||
- **The gate** (`scripts/repo-standard-check.mjs`) — pure classifiers with all
|
||||
I/O resolved into their input, findings tagged `ERROR`/`WARN`/`SKIP`/`OK`,
|
||||
exit 1 on `ERROR`. Five checks: README first screen, install block, files
|
||||
required by the repo's class, `open/<name>` references, description length.
|
||||
- **Taxonomy register** (`register/repos.json`) — one central file mapping each
|
||||
repository to its class, plus the per-class file and install requirements.
|
||||
`--refresh` compares it against the live org listing.
|
||||
- **Three-outcome reference check** — "matches no repository" (`ERROR`) is a
|
||||
separate finding from "matches something that is deliberately not a
|
||||
repository" (`WARN`). Sharing an outcome would let real dead links hide among
|
||||
correct text. Only names in URL position are treated as references; the `.git`
|
||||
suffix is normalised first.
|
||||
- **The skill** (`skills/repo-standard/`) — the judgement the script cannot
|
||||
encode: what a description should say, why the summary card must never be used
|
||||
to verify one, what not to retrofit, and where the per-repo boundary is.
|
||||
- 32 tests over the pure classifiers, using the measured false positives as
|
||||
reference fixtures.
|
||||
|
||||
### Notes
|
||||
|
||||
- Descriptions are measured in **codepoints** — not bytes, and not UTF-16 units.
|
||||
The same string measures 248 / 249 / 253 across those three yardsticks when it
|
||||
contains an astral character.
|
||||
- `enabledPlugins` in `settings.json` is treated as a legitimate second install
|
||||
form. The gate requires a CLI command *as well*, and never reports the JSON
|
||||
form as the defect.
|
||||
- The org listing is read in **one** call, anonymously. Per-repo fetching trips
|
||||
the forge's rate limiter.
|
||||
- No hook ships in this release. A blocking gate has to be precise enough not to
|
||||
fail a correct repository first.
|
||||
|
||||
[0.1.0]: https://git.fromaitochitta.com/open/repo-standard/src/tag/v0.1.0
|
||||
63
CLAUDE.md
Normal file
63
CLAUDE.md
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
# repo-standard
|
||||
|
||||
Per-repo gate for the `open/` presentation standard, packaged as a marketplace
|
||||
plugin.
|
||||
|
||||
## Context
|
||||
|
||||
Two components, one boundary:
|
||||
|
||||
- **Engine (`scripts/repo-standard-check.mjs`)** — pure classifiers with all I/O
|
||||
resolved into their input, mirroring the marketplace's `check-versions.mjs`.
|
||||
Findings are `ERROR`/`WARN`/`SKIP`/`OK`; exit 1 on `ERROR`. Pinned by
|
||||
`scripts/repo-standard-check.test.mjs` (`npm test`).
|
||||
- **Skill (`skills/repo-standard/`)** — the judgement the script cannot encode.
|
||||
No checking logic lives here; it calls the engine.
|
||||
|
||||
`register/repos.json` is the single taxonomy register (name → class, per-class
|
||||
requirements, and the known non-repo names). Central by design: per-repo copies
|
||||
would recreate, in data, exactly the drift this plugin exists to remove.
|
||||
|
||||
## Invariants
|
||||
|
||||
- **This repo has a PUBLIC remote.** `STATE.md` is LOCAL-ONLY and gitignored
|
||||
from the first commit. Repos on a private remote track theirs — do not carry
|
||||
that habit across in either direction.
|
||||
- **The gate sees ONE repo.** Anything needing a view across the whole org
|
||||
(topic coverage, competing install forms, catalog-vs-forge divergence) does
|
||||
not belong here. It is measured where the org is enumerated.
|
||||
- **It records, it does not fix.** Findings first, remediation afterwards.
|
||||
Patching while measuring is how the inconsistency it detects was produced.
|
||||
- **`SKIP` is never a pass.** A check that could not run says so and names why.
|
||||
- **Three outcomes on references.** "No match" and "match on a known non-repo"
|
||||
must stay distinct findings. Collapsing them hides real loss inside correct
|
||||
text — the exact defect class this gate exists to catch.
|
||||
- **One API call, anonymous.** The org listing carries description and topics
|
||||
already; per-repo fetching trips the rate limiter (HTTP 429). It reads without
|
||||
a token, so the gate works for any reader — a public plugin whose documented
|
||||
check only runs for its author is a broken plugin.
|
||||
- **Codepoints, not bytes, not UTF-16 units.** Use `[...s].length`. An em-dash
|
||||
exposes only the byte layer; astral characters expose the rest.
|
||||
- **No hook until the rule is precise.** A blocking gate that fails a correct
|
||||
repository is the mechanism that gets gates switched off.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm test # 32 tests
|
||||
node scripts/repo-standard-check.mjs --dir "$PWD" # gate one repo
|
||||
node scripts/repo-standard-check.mjs --offline # no network call
|
||||
node scripts/repo-standard-check.mjs --json # machine output
|
||||
node scripts/repo-standard-check.mjs --refresh # register vs. forge
|
||||
```
|
||||
|
||||
## Release
|
||||
|
||||
Polyrepo rule: a version bump is not finished until the tag `vX.Y.Z` is pushed
|
||||
**and** the catalog `ref` is bumped to it. Use `release-plugin.mjs`, never a
|
||||
hand-edited `ref`.
|
||||
|
||||
`check-versions.mjs` reads the catalog README's per-plugin label, and a **missing**
|
||||
entry is a silent `null` rather than an error — `release-plugin.mjs` rewrites an
|
||||
existing heading but cannot create one. A new plugin's catalog README entry has
|
||||
to be added by hand once.
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 Kjell Tore Guttormsen
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
121
README.md
Normal file
121
README.md
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
# repo-standard
|
||||
Per-repo gate for the open/ presentation standard: README first screen, install block, files required by the repo's class, and dead repo references.
|
||||
|
||||
A repository can be substantially good and still read as abandoned. The gap is
|
||||
almost never the code — it is the first screen, an install path that stops
|
||||
halfway, and references to a name that was retired two renames ago. This plugin
|
||||
checks that surface in one repository and reports what it finds.
|
||||
|
||||
> **Solo-maintained, fork-and-own.** This plugin is a starting point, not a vendor product. Issues are welcome as signals; pull requests are not accepted. See the [marketplace governance](https://git.fromaitochitta.com/open/ktg-plugin-marketplace/src/branch/main/GOVERNANCE.md) for the full model.
|
||||
|
||||
*AI-generated: all code produced by Claude Code through dialog-driven development.*
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## Install
|
||||
|
||||
Use the `https://` form. The forge UI's clone button hands out an `ssh://` URL,
|
||||
and `marketplace add` answers it with `Invalid git URL` — a message that never
|
||||
mentions the protocol.
|
||||
|
||||
```bash
|
||||
claude plugin marketplace add https://git.fromaitochitta.com/open/ktg-plugin-marketplace.git
|
||||
claude plugin install repo-standard@ktg-plugin-marketplace
|
||||
```
|
||||
|
||||
Or enable it directly in `~/.claude/settings.json` — a working second path, not
|
||||
a replacement for the two commands above:
|
||||
|
||||
```json
|
||||
{ "enabledPlugins": { "repo-standard@ktg-plugin-marketplace": true } }
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
Node 18 or newer. No dependencies. The one network call — the org listing, used
|
||||
to read a repo's published description — reads anonymously, so it needs no
|
||||
token; `--offline` skips it.
|
||||
|
||||
## What it does
|
||||
|
||||
Run it inside a repository:
|
||||
|
||||
```bash
|
||||
node scripts/repo-standard-check.mjs --dir "$PWD"
|
||||
```
|
||||
|
||||
It checks five things, and the repository's **class** decides what each one means:
|
||||
|
||||
| Check | What fails it |
|
||||
| --- | --- |
|
||||
| First screen | `# <name>` is not line 1, or the line under it is not the published description |
|
||||
| Install block | the form for this class is missing, incomplete, or shown over `ssh://` |
|
||||
| Required files | a file this class needs is absent |
|
||||
| References | an `open/<name>` in URL position resolves to nothing |
|
||||
| Description | empty, or past the length bound |
|
||||
|
||||
Findings are `ERROR`, `WARN`, `SKIP` or `OK`; the process exits 1 on any
|
||||
`ERROR`. A `SKIP` means the check could not run — an unreachable forge, a repo
|
||||
that is not in the register. It is not a pass, and the output says which.
|
||||
|
||||
### The class decides what is required
|
||||
|
||||
The class is read off the catalog and the remotes; it is structural, not a
|
||||
judgement. It lives in `register/repos.json`.
|
||||
|
||||
| Class | Install form | Requires |
|
||||
| --- | --- | --- |
|
||||
| plugin | `marketplace add` **and** a CLI install command | README, LICENSE, CHANGELOG, plugin manifest |
|
||||
| catalog | `marketplace add` only — it *is* the marketplace | + GOVERNANCE, CONVENTIONS |
|
||||
| shared-asset | how to vendor it; never a plugin install line | README, LICENSE |
|
||||
| standalone | pip/uv | README, LICENSE |
|
||||
| org-profile | none | README |
|
||||
|
||||
A flat standard across every class would demand a CONTRIBUTING from a CSS
|
||||
library that accepts no contributions, and a roadmap from a five-line profile.
|
||||
That is how a gate teaches people to switch it off.
|
||||
|
||||
### Three outcomes on references, not two
|
||||
|
||||
"Matches no repository" and "matches something that is deliberately not a
|
||||
repository" are different findings. The second class is real and it is common:
|
||||
a retired name kept alive on purpose in prose, a reserved namespace occupying a
|
||||
repo-shaped path, a published package name that was never a repo. If those share
|
||||
an outcome with genuine dead links, the genuine ones hide inside a pile of
|
||||
correct text.
|
||||
|
||||
Only names in **URL position** are treated as references, which excludes prose,
|
||||
paths and directory names in one move. The `.git` suffix is normalised first —
|
||||
without that, a raw scan turns three dead names into about twenty.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- **Anything requiring a view across every repository at once.** This gate sees
|
||||
one repo. A README can look correct from the inside and only turn out wrong
|
||||
when a dozen are compared. Org-wide divergence is measured where the org is
|
||||
enumerated, not here.
|
||||
- **Fixing while measuring.** It records findings; you fix them afterwards.
|
||||
Patching under measurement is how inconsistency accumulates unnoticed.
|
||||
- **Deciding whether to rename a repo, or whether a roadmap is real.** Those are
|
||||
operator calls. The gate brings evidence to them.
|
||||
- **Blocking commits.** There is no hook. A gate that fails a correct repository
|
||||
is the mechanism that gets gates switched off, so this one earns that role
|
||||
before it takes it.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
32 tests over the pure classifiers. The reference fixtures are the measured
|
||||
false positives that produced the three-outcome rule, each with its expected
|
||||
verdict.
|
||||
|
||||
## Changelog
|
||||
|
||||
See [CHANGELOG.md](CHANGELOG.md).
|
||||
12
package.json
Normal file
12
package.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"name": "repo-standard",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "node --test scripts/*.test.mjs"
|
||||
}
|
||||
}
|
||||
94
register/repos.json
Normal file
94
register/repos.json
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
{
|
||||
"$comment": [
|
||||
"Taxonomy register for the `open/` organisation (D4: central, one file).",
|
||||
"The class is READ OFF the catalog and the remotes — it is structural, not a judgement.",
|
||||
"Refresh the `repos` name set against ground truth with:",
|
||||
" node scripts/repo-standard-check.mjs --refresh",
|
||||
"which enumerates /api/v1/orgs/open/repos (ONE call — the listing carries",
|
||||
"description and topics too; per-repo fetching trips the rate limiter).",
|
||||
"Enumerate, never glob: 12 of these sit at depth 2 locally, one has a",
|
||||
"basename that differs from its repo name, and `.profile` is hidden."
|
||||
],
|
||||
"org": "open",
|
||||
"forge": "https://git.fromaitochitta.com",
|
||||
"marketplace": {
|
||||
"name": "ktg-plugin-marketplace",
|
||||
"url": "https://git.fromaitochitta.com/open/ktg-plugin-marketplace.git"
|
||||
},
|
||||
"repos": {
|
||||
"llm-security": "plugin",
|
||||
"config-audit": "plugin",
|
||||
"voyage": "plugin",
|
||||
"linkedin-studio": "plugin",
|
||||
"graceful-handoff": "plugin",
|
||||
"ai-psychosis": "plugin",
|
||||
"ms-ai-architect": "plugin",
|
||||
"okr": "plugin",
|
||||
"human-friendly-style": "plugin",
|
||||
"claude-design": "plugin",
|
||||
"repo-mailbox": "plugin",
|
||||
"repo-standard": "plugin",
|
||||
"ktg-plugin-marketplace": "catalog",
|
||||
"playground-design-system": "shared-asset",
|
||||
".profile": "org-profile",
|
||||
"portfolio-optimiser": "standalone",
|
||||
"portfolio-optimiser-claude": "standalone",
|
||||
"llm-ingestion-pipeline-security": "standalone",
|
||||
"llm-ingestion-okf": "standalone"
|
||||
},
|
||||
|
||||
"$comment_non_repos": [
|
||||
"Names that LOOK like repo names and are not. These exist so that",
|
||||
"'no match' and 'match on something that is not a repo' are DIFFERENT",
|
||||
"outcomes — if they share an outcome, the loss goes silent, which is the",
|
||||
"defect class this whole standard exists to catch.",
|
||||
"Each entry is a measured false positive, not a guess."
|
||||
],
|
||||
"non_repos": {
|
||||
"coord": "Retired repo name, deliberately still alive in prose: the CLI (coord-send), the mailbox root (~/.claude/coord/) and CLAUDE_COORD_DIR kept it — they are the transport protocol, not the product. The repo has been `repo-mailbox` since v0.3.0.",
|
||||
"_broadcast": "Reserved engine namespace in the coord mailbox (`~/.claude/coord/_broadcast/`). Occupies a repo-shaped PATH position; `_` prefixed names are refused as repo identities.",
|
||||
"llm-ingestion-guard": "Package name published by `llm-ingestion-pipeline-security`. A package, not a repo.",
|
||||
"claude-code-llm-security": "Pre-split name of `llm-security`. This one IS dead — the org's only rename produced every dead reference we found. Listed so the finding names the successor instead of just failing."
|
||||
},
|
||||
|
||||
"$comment_classes": [
|
||||
"Per class: which files are required, and which install form the README must",
|
||||
"carry. A flat standard across all classes would demand a CONTRIBUTING from a",
|
||||
"CSS library that takes no contributions, and a ROADMAP from a 5-line profile.",
|
||||
"ROADMAP is deliberately absent everywhere: it is 0/18 today and is step 8,",
|
||||
"drafted from STATE by a human. A gate that fails every repo teaches people to",
|
||||
"switch the gate off."
|
||||
],
|
||||
"classes": {
|
||||
"plugin": {
|
||||
"required_files": ["README.md", "LICENSE", "CHANGELOG.md", ".claude-plugin/plugin.json"],
|
||||
"install": "plugin"
|
||||
},
|
||||
"catalog": {
|
||||
"required_files": ["README.md", "LICENSE", "GOVERNANCE.md", "CONVENTIONS.md", ".claude-plugin/marketplace.json"],
|
||||
"install": "catalog"
|
||||
},
|
||||
"shared-asset": {
|
||||
"required_files": ["README.md", "LICENSE"],
|
||||
"install": "vendor"
|
||||
},
|
||||
"org-profile": {
|
||||
"required_files": ["README.md"],
|
||||
"install": "none"
|
||||
},
|
||||
"standalone": {
|
||||
"required_files": ["README.md", "LICENSE"],
|
||||
"install": "package"
|
||||
}
|
||||
},
|
||||
|
||||
"description_max_codepoints": 180,
|
||||
"$comment_length": [
|
||||
"180 codepoints, not bytes and not UTF-16 units. The same string measures 248",
|
||||
"/ 249 / 253 across the three yardsticks (graceful-handoff: `👉` is astral).",
|
||||
"An em-dash costs 3 bytes but 1 codepoint AND 1 UTF-16 unit, so it exposes",
|
||||
"only the outer layer and hides the inner one. JS-based tooling reads one",
|
||||
"higher per astral character. Upper bound: 207 nearly filled the card's text",
|
||||
"field; 220 is untested and may overflow."
|
||||
]
|
||||
}
|
||||
409
scripts/repo-standard-check.mjs
Normal file
409
scripts/repo-standard-check.mjs
Normal file
|
|
@ -0,0 +1,409 @@
|
|||
#!/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() !== '');
|
||||
|
||||
if (firstIdx === -1 || lines[firstIdx].trim() !== `# ${name}`) {
|
||||
findings.push({
|
||||
level: 'ERROR',
|
||||
code: 'README-H1',
|
||||
msg: `README line 1 must be \`# ${name}\` (found: ${firstIdx === -1 ? '<empty file>' : `\`${lines[firstIdx].trim()}\``})`,
|
||||
});
|
||||
return findings;
|
||||
}
|
||||
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));
|
||||
}
|
||||
370
scripts/repo-standard-check.test.mjs
Normal file
370
scripts/repo-standard-check.test.mjs
Normal file
|
|
@ -0,0 +1,370 @@
|
|||
// Tests for the repo-standard gate.
|
||||
//
|
||||
// The pure classifiers are the unit under test — the I/O shell (inspectRepo/runGate)
|
||||
// is exercised against a live checkout by the CLI, not here.
|
||||
//
|
||||
// The link-check fixtures are the six measured false positives from the census.
|
||||
// They are the reason this gate has three outcomes instead of a boolean.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
countCodepoints,
|
||||
normalizeRepoRef,
|
||||
extractOpenRefs,
|
||||
classifyRef,
|
||||
checkLinks,
|
||||
checkDescription,
|
||||
checkFirstScreen,
|
||||
checkInstallBlock,
|
||||
checkRequiredFiles,
|
||||
classifyRepo,
|
||||
levelOf,
|
||||
} from './repo-standard-check.mjs';
|
||||
|
||||
const REGISTER = {
|
||||
org: 'open',
|
||||
marketplace: {
|
||||
name: 'ktg-plugin-marketplace',
|
||||
url: 'https://git.fromaitochitta.com/open/ktg-plugin-marketplace.git',
|
||||
},
|
||||
repos: {
|
||||
'llm-security': 'plugin',
|
||||
'repo-mailbox': 'plugin',
|
||||
'repo-standard': 'plugin',
|
||||
'ktg-plugin-marketplace': 'catalog',
|
||||
'playground-design-system': 'shared-asset',
|
||||
'.profile': 'org-profile',
|
||||
'llm-ingestion-pipeline-security': 'standalone',
|
||||
},
|
||||
non_repos: {
|
||||
coord: 'Retired repo name, deliberately still alive in prose: the CLI, the mailbox root and CLAUDE_COORD_DIR kept it — they are the transport protocol, not the product.',
|
||||
_broadcast: 'reserved engine namespace',
|
||||
'llm-ingestion-guard': 'package name, not a repo',
|
||||
'claude-code-llm-security': 'pre-split name of llm-security',
|
||||
},
|
||||
classes: {
|
||||
plugin: {
|
||||
required_files: ['README.md', 'LICENSE', 'CHANGELOG.md', '.claude-plugin/plugin.json'],
|
||||
install: 'plugin',
|
||||
},
|
||||
catalog: {
|
||||
required_files: ['README.md', 'LICENSE', 'GOVERNANCE.md', 'CONVENTIONS.md', '.claude-plugin/marketplace.json'],
|
||||
install: 'catalog',
|
||||
},
|
||||
'shared-asset': { required_files: ['README.md', 'LICENSE'], install: 'vendor' },
|
||||
'org-profile': { required_files: ['README.md'], install: 'none' },
|
||||
standalone: { required_files: ['README.md', 'LICENSE'], install: 'package' },
|
||||
},
|
||||
description_max_codepoints: 180,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- measurement
|
||||
|
||||
test('countCodepoints measures codepoints, not bytes and not UTF-16 units', () => {
|
||||
// The em-dash exposes only the byte layer: 3 bytes, 1 codepoint, 1 UTF-16 unit.
|
||||
assert.equal(countCodepoints('a—b'), 3);
|
||||
assert.equal(Buffer.byteLength('a—b', 'utf8'), 5);
|
||||
// 👉 is astral: 1 codepoint but 2 UTF-16 units. This is the layer the em-dash hides.
|
||||
assert.equal(countCodepoints('👉'), 1);
|
||||
assert.equal('👉'.length, 2);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------- ref extraction
|
||||
|
||||
test('normalizeRepoRef strips a .git suffix and a trailing slash', () => {
|
||||
// ~20 "dead" names collapsed to 3 real ones once .git was normalised.
|
||||
assert.equal(normalizeRepoRef('llm-security.git'), 'llm-security');
|
||||
assert.equal(normalizeRepoRef('llm-security/'), 'llm-security');
|
||||
assert.equal(normalizeRepoRef('llm-security'), 'llm-security');
|
||||
});
|
||||
|
||||
test('extractOpenRefs finds names in URL position only', () => {
|
||||
const text = [
|
||||
'clone https://git.fromaitochitta.com/open/llm-security.git today',
|
||||
'see https://git.fromaitochitta.com/open/repo-mailbox/src/branch/main/README.md',
|
||||
].join('\n');
|
||||
const names = extractOpenRefs(text).map((r) => r.name);
|
||||
assert.deepEqual(names, ['llm-security', 'repo-mailbox']);
|
||||
});
|
||||
|
||||
test('extractOpenRefs ignores path position, prose and bare directory names', () => {
|
||||
// False positives #4, #5, #6 — the text is correct and will STAY correct.
|
||||
const text = [
|
||||
'the mailbox root is ~/.claude/coord/_broadcast/inbox/',
|
||||
'coord is the transport protocol, not the product',
|
||||
'the catalog lives in ktg-plugin-marketplace/catalog',
|
||||
'the package llm-ingestion-guard is published from that repo',
|
||||
].join('\n');
|
||||
assert.deepEqual(extractOpenRefs(text), []);
|
||||
});
|
||||
|
||||
test('extractOpenRefs handles the ssh scp-style form', () => {
|
||||
const refs = extractOpenRefs('git@git.fromaitochitta.com:open/llm-security.git');
|
||||
assert.deepEqual(refs.map((r) => r.name), ['llm-security']);
|
||||
});
|
||||
|
||||
test('extractOpenRefs reports the 1-indexed line of each hit', () => {
|
||||
const text = 'line one\nline two\nhttps://git.fromaitochitta.com/open/llm-security';
|
||||
assert.equal(extractOpenRefs(text)[0].line, 3);
|
||||
});
|
||||
|
||||
// ---------------------------------------------- three outcomes, not a boolean
|
||||
|
||||
test('classifyRef separates repo, non-repo and unknown', () => {
|
||||
assert.equal(classifyRef('llm-security', REGISTER), 'repo');
|
||||
assert.equal(classifyRef('.profile', REGISTER), 'repo');
|
||||
assert.equal(classifyRef('coord', REGISTER), 'non-repo');
|
||||
assert.equal(classifyRef('nonesuch', REGISTER), 'unknown');
|
||||
});
|
||||
|
||||
test('a URL-position ref to a known non-repo is a DISTINCT outcome from no match', () => {
|
||||
// The specification requirement: "no match" and "match on something that is
|
||||
// not a repo" must never share an outcome, or the loss goes silent.
|
||||
const bad = checkLinks({ files: { 'README.md': 'https://git.fromaitochitta.com/open/nonesuch' } }, REGISTER);
|
||||
const odd = checkLinks({ files: { 'README.md': 'https://git.fromaitochitta.com/open/coord' } }, REGISTER);
|
||||
|
||||
assert.equal(bad[0].level, 'ERROR');
|
||||
assert.equal(bad[0].code, 'LINK-DEAD');
|
||||
|
||||
assert.equal(odd[0].level, 'WARN');
|
||||
assert.equal(odd[0].code, 'LINK-NON-REPO');
|
||||
assert.notEqual(bad[0].code, odd[0].code);
|
||||
// The reason travels with the finding, so the reader is not sent measuring again.
|
||||
assert.match(odd[0].msg, /transport protocol/);
|
||||
});
|
||||
|
||||
test('a dead ref names its successor when the register knows one', () => {
|
||||
const f = checkLinks(
|
||||
{ files: { 'README.md': 'https://git.fromaitochitta.com/open/claude-code-llm-security' } },
|
||||
REGISTER,
|
||||
);
|
||||
assert.equal(f[0].level, 'WARN');
|
||||
assert.match(f[0].msg, /pre-split name/);
|
||||
});
|
||||
|
||||
test('the .git suffix does not manufacture a dead reference', () => {
|
||||
const f = checkLinks(
|
||||
{ files: { 'README.md': 'https://git.fromaitochitta.com/open/llm-security.git' } },
|
||||
REGISTER,
|
||||
);
|
||||
assert.equal(f.filter((x) => x.level === 'ERROR').length, 0);
|
||||
});
|
||||
|
||||
test('the hidden .profile resolves — the enumerator sees what a glob misses', () => {
|
||||
const f = checkLinks({ files: { 'README.md': 'https://git.fromaitochitta.com/open/.profile' } }, REGISTER);
|
||||
assert.equal(f.filter((x) => x.level === 'ERROR').length, 0);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------- description
|
||||
|
||||
test('description length is bounded, measured in codepoints', () => {
|
||||
assert.equal(checkDescription('a fine description', REGISTER)[0].level, 'OK');
|
||||
assert.equal(checkDescription('', REGISTER)[0].level, 'ERROR');
|
||||
assert.equal(checkDescription('x'.repeat(181), REGISTER)[0].level, 'ERROR');
|
||||
assert.equal(checkDescription('x'.repeat(180), REGISTER)[0].level, 'OK');
|
||||
});
|
||||
|
||||
test('an unavailable description is SKIP, never a pass', () => {
|
||||
// Offline is not compliance. A check that could not run says so.
|
||||
const f = checkDescription(null, REGISTER);
|
||||
assert.equal(f[0].level, 'SKIP');
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------- first screen
|
||||
|
||||
test('README line 1 must be the H1, and the description line must match the forge', () => {
|
||||
const readme = '# repo-mailbox\nA local mailbox for coordination.\n';
|
||||
assert.equal(
|
||||
checkFirstScreen({ readme, name: 'repo-mailbox', description: 'A local mailbox for coordination.' })
|
||||
.every((f) => f.level === 'OK'),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('a README whose opening line diverges from the description is an ERROR', () => {
|
||||
const readme = '# repo-mailbox\nSomething else entirely.\n';
|
||||
const f = checkFirstScreen({ readme, name: 'repo-mailbox', description: 'A local mailbox for coordination.' });
|
||||
assert.equal(f.some((x) => x.level === 'ERROR' && x.code === 'README-DESC'), true);
|
||||
});
|
||||
|
||||
test('first-screen description match is SKIP when the forge text is unavailable', () => {
|
||||
const f = checkFirstScreen({ readme: '# x\nbody\n', name: 'x', description: null });
|
||||
assert.equal(f.some((x) => x.code === 'README-DESC' && x.level === 'SKIP'), true);
|
||||
});
|
||||
|
||||
test('a wrong or missing H1 is an ERROR', () => {
|
||||
const f = checkFirstScreen({ readme: 'no heading here\n', name: 'x', description: null });
|
||||
assert.equal(f.some((x) => x.level === 'ERROR' && x.code === 'README-H1'), true);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ install block
|
||||
|
||||
const MKT = REGISTER.marketplace;
|
||||
|
||||
test('plugin install needs BOTH lines: marketplace add and a CLI install command', () => {
|
||||
const readme = [
|
||||
'## Install',
|
||||
'```',
|
||||
`claude plugin marketplace add ${MKT.url}`,
|
||||
`claude plugin install repo-mailbox@${MKT.name}`,
|
||||
'```',
|
||||
].join('\n');
|
||||
const f = checkInstallBlock({ readme, name: 'repo-mailbox', klass: 'plugin' }, REGISTER);
|
||||
assert.equal(f.filter((x) => x.level === 'ERROR').length, 0);
|
||||
});
|
||||
|
||||
test('the slash form counts as the CLI command', () => {
|
||||
const readme = [
|
||||
'## Install',
|
||||
`claude plugin marketplace add ${MKT.url}`,
|
||||
`/plugin install claude-design@${MKT.name}`,
|
||||
].join('\n');
|
||||
const f = checkInstallBlock({ readme, name: 'claude-design', klass: 'plugin' }, REGISTER);
|
||||
assert.equal(f.filter((x) => x.level === 'ERROR').length, 0);
|
||||
});
|
||||
|
||||
test('enabledPlugins JSON is an ALLOWED ADDITION, never a replacement for the CLI command', () => {
|
||||
// This is the corrected defect A: 7 of 11 plugin READMEs stop after
|
||||
// `marketplace add`. The JSON form works and stands in 10 of 11 — what is
|
||||
// missing is a CLI command, so the contract requires the command and permits
|
||||
// the JSON alongside it.
|
||||
const jsonOnly = [
|
||||
'## Install',
|
||||
`claude plugin marketplace add ${MKT.url}`,
|
||||
'Or enable directly in `~/.claude/settings.json`:',
|
||||
`"enabledPlugins": { "llm-security@${MKT.name}": true }`,
|
||||
].join('\n');
|
||||
const f = checkInstallBlock({ readme: jsonOnly, name: 'llm-security', klass: 'plugin' }, REGISTER);
|
||||
assert.equal(f.some((x) => x.level === 'ERROR' && x.code === 'INSTALL-NO-CLI'), true);
|
||||
|
||||
const both = jsonOnly + `\nclaude plugin install llm-security@${MKT.name}\n`;
|
||||
const g = checkInstallBlock({ readme: both, name: 'llm-security', klass: 'plugin' }, REGISTER);
|
||||
assert.equal(g.filter((x) => x.level === 'ERROR').length, 0);
|
||||
});
|
||||
|
||||
test('a missing marketplace add is its own finding — okr and claude-design are opposite halves', () => {
|
||||
const noAdd = ['## Install', `/plugin install claude-design@${MKT.name}`].join('\n');
|
||||
const f = checkInstallBlock({ readme: noAdd, name: 'claude-design', klass: 'plugin' }, REGISTER);
|
||||
assert.equal(f.some((x) => x.level === 'ERROR' && x.code === 'INSTALL-NO-MARKETPLACE'), true);
|
||||
|
||||
// okr: neither line. Both findings fire — a rule saying "both lines" must not
|
||||
// hit this repo blind.
|
||||
const neither = ['## Install', `"enabledPlugins": { "okr@${MKT.name}": true }`].join('\n');
|
||||
const g = checkInstallBlock({ readme: neither, name: 'okr', klass: 'plugin' }, REGISTER);
|
||||
assert.equal(g.some((x) => x.code === 'INSTALL-NO-MARKETPLACE'), true);
|
||||
assert.equal(g.some((x) => x.code === 'INSTALL-NO-CLI'), true);
|
||||
});
|
||||
|
||||
test('the install target must name THIS repo, not another plugin', () => {
|
||||
const readme = [
|
||||
'## Install',
|
||||
`claude plugin marketplace add ${MKT.url}`,
|
||||
`claude plugin install some-other-plugin@${MKT.name}`,
|
||||
].join('\n');
|
||||
const f = checkInstallBlock({ readme, name: 'repo-standard', klass: 'plugin' }, REGISTER);
|
||||
assert.equal(f.some((x) => x.code === 'INSTALL-NO-CLI'), true);
|
||||
});
|
||||
|
||||
test('ssh in the marketplace add line is an ERROR — marketplace add rejects it', () => {
|
||||
// Measured end-to-end: `marketplace add ssh://...` → "Invalid git URL".
|
||||
// The forge UI's clone button hands you exactly that URL.
|
||||
const readme = [
|
||||
'## Install',
|
||||
'claude plugin marketplace add ssh://git@git.fromaitochitta.com/open/ktg-plugin-marketplace.git',
|
||||
`claude plugin install repo-standard@${MKT.name}`,
|
||||
].join('\n');
|
||||
const f = checkInstallBlock({ readme, name: 'repo-standard', klass: 'plugin' }, REGISTER);
|
||||
assert.equal(f.some((x) => x.level === 'ERROR' && x.code === 'INSTALL-SSH'), true);
|
||||
});
|
||||
|
||||
test('the install block is parametric — a different marketplace passes on its own values', () => {
|
||||
// wiki-advise lives on the private ktg/ namespace and is distributed via
|
||||
// `ktg-privat`. A skill that hardcodes the public marketplace produces an
|
||||
// install line that does not work there — and being public itself, this skill
|
||||
// cannot carry private marketplace names.
|
||||
const priv = {
|
||||
...REGISTER,
|
||||
marketplace: { name: 'ktg-privat', url: 'https://git.fromaitochitta.com/ktg/ktg-privat.git' },
|
||||
};
|
||||
const readme = [
|
||||
'## Install',
|
||||
'claude plugin marketplace add https://git.fromaitochitta.com/ktg/ktg-privat.git',
|
||||
'claude plugin install wiki-advise@ktg-privat',
|
||||
].join('\n');
|
||||
const f = checkInstallBlock({ readme, name: 'wiki-advise', klass: 'plugin' }, priv);
|
||||
assert.equal(f.filter((x) => x.level === 'ERROR').length, 0);
|
||||
});
|
||||
|
||||
test('the catalog needs only marketplace add — it IS the marketplace', () => {
|
||||
const readme = `## Install\nclaude plugin marketplace add ${MKT.url}`;
|
||||
const f = checkInstallBlock({ readme, name: 'ktg-plugin-marketplace', klass: 'catalog' }, REGISTER);
|
||||
assert.equal(f.filter((x) => x.level === 'ERROR').length, 0);
|
||||
});
|
||||
|
||||
test('a shared asset is vendored, not installed — a plugin install line is wrong there', () => {
|
||||
const asPlugin = `## Install\nclaude plugin install playground-design-system@${MKT.name}`;
|
||||
const f = checkInstallBlock(
|
||||
{ readme: asPlugin, name: 'playground-design-system', klass: 'shared-asset' },
|
||||
REGISTER,
|
||||
);
|
||||
assert.equal(f.some((x) => x.level === 'ERROR' && x.code === 'INSTALL-WRONG-FORM'), true);
|
||||
});
|
||||
|
||||
test('.profile needs no install section at all', () => {
|
||||
const f = checkInstallBlock({ readme: '# .profile\nOrg profile.\n', name: '.profile', klass: 'org-profile' }, REGISTER);
|
||||
assert.equal(f.filter((x) => x.level === 'ERROR').length, 0);
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------- required files
|
||||
|
||||
test('required files are per class, not flat across the org', () => {
|
||||
const ok = checkRequiredFiles({ present: ['README.md'], klass: 'org-profile' }, REGISTER);
|
||||
assert.equal(ok.filter((f) => f.level === 'ERROR').length, 0);
|
||||
|
||||
const missing = checkRequiredFiles({ present: ['README.md'], klass: 'plugin' }, REGISTER);
|
||||
assert.equal(missing.some((f) => f.code === 'FILE-MISSING' && f.msg.includes('LICENSE')), true);
|
||||
});
|
||||
|
||||
test('no class requires a ROADMAP — it is 0/18 and belongs to a later step', () => {
|
||||
for (const klass of Object.keys(REGISTER.classes)) {
|
||||
assert.equal(REGISTER.classes[klass].required_files.includes('ROADMAP.md'), false);
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------- aggregation
|
||||
|
||||
test('levelOf ranks ERROR above WARN above SKIP above OK', () => {
|
||||
assert.equal(levelOf([{ level: 'OK' }, { level: 'WARN' }, { level: 'ERROR' }]), 'ERROR');
|
||||
assert.equal(levelOf([{ level: 'OK' }, { level: 'WARN' }]), 'WARN');
|
||||
assert.equal(levelOf([{ level: 'OK' }, { level: 'SKIP' }]), 'SKIP');
|
||||
assert.equal(levelOf([{ level: 'OK' }]), 'OK');
|
||||
assert.equal(levelOf([]), 'OK');
|
||||
});
|
||||
|
||||
test('an unregistered repo is SKIP, not a pass — the gate refuses to guess a class', () => {
|
||||
const r = classifyRepo({ name: 'stranger', files: {}, present: [], description: null }, REGISTER);
|
||||
assert.equal(r.status, 'SKIP');
|
||||
});
|
||||
|
||||
test('a fully compliant plugin repo classifies OK', () => {
|
||||
const readme = [
|
||||
'# repo-mailbox',
|
||||
'A local mailbox for coordination.',
|
||||
'',
|
||||
'Body text.',
|
||||
'',
|
||||
'## Install',
|
||||
`claude plugin marketplace add ${MKT.url}`,
|
||||
`claude plugin install repo-mailbox@${MKT.name}`,
|
||||
].join('\n');
|
||||
const r = classifyRepo(
|
||||
{
|
||||
name: 'repo-mailbox',
|
||||
files: { 'README.md': readme },
|
||||
present: ['README.md', 'LICENSE', 'CHANGELOG.md', '.claude-plugin/plugin.json'],
|
||||
description: 'A local mailbox for coordination.',
|
||||
},
|
||||
REGISTER,
|
||||
);
|
||||
assert.equal(r.status, 'OK');
|
||||
});
|
||||
165
skills/repo-standard/SKILL.md
Normal file
165
skills/repo-standard/SKILL.md
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
---
|
||||
name: repo-standard
|
||||
description: >-
|
||||
Check and fix one repository against the org's presentation standard — README
|
||||
first screen, install block, required files for its class, dead `open/<name>`
|
||||
references, and description length. Use when the user asks to "check this repo
|
||||
against the standard", "run repo-standard", "is this repo compliant", "fix the
|
||||
README first screen", "the install instructions are incomplete", "check for
|
||||
dead repo references", "why does this repo look abandoned", or before
|
||||
publishing/releasing a repo to the org. Also triggers on Norwegian phrasings:
|
||||
"sjekk repoet mot standarden", "er dette repoet i orden", "rydd opp i README",
|
||||
"fiks install-blokka", "finn døde repo-referanser", "gjør repoet presentabelt".
|
||||
Trigger when someone is about to release, publish, or hand over a repository
|
||||
and wants its public surface to hold up.
|
||||
version: "0.1.0"
|
||||
---
|
||||
|
||||
# repo-standard — the per-repo gate
|
||||
|
||||
This skill checks **one repository** against the standard for its class, and
|
||||
helps fix what it finds. The script is the engine; this file is the judgement
|
||||
the script cannot encode.
|
||||
|
||||
## Run the gate first, always
|
||||
|
||||
node "${CLAUDE_PLUGIN_ROOT}/scripts/repo-standard-check.mjs" --dir "$PWD"
|
||||
|
||||
Findings are `ERROR` (blocks), `WARN` (look, then decide), `SKIP` (the check
|
||||
could not run), `OK`. Exit 1 on any ERROR. Add `--offline` to skip the one
|
||||
network call, `--json` for machine output, `--refresh` to compare the bundled
|
||||
register against the live org listing.
|
||||
|
||||
**Never report a `SKIP` as a pass.** A SKIP means the gate could not see enough
|
||||
to judge — an unreachable forge, a repo missing from the register. Say which.
|
||||
|
||||
## What it measures, and what it cannot
|
||||
|
||||
The gate sees one repo. Every finding in the census that mattered came from
|
||||
measuring across eighteen. `llm-security`'s README looks correct from the
|
||||
inside: it has a Quick Start, an Install heading, a code block. It is only wrong
|
||||
once eleven repos are compared and one of them turns out to do install
|
||||
completely.
|
||||
|
||||
So: **divergence across the org is not this skill's job.** Zero topics across
|
||||
all repos, three competing install forms, README release notes duplicating a
|
||||
CHANGELOG that most repos have — none of that is visible from in here. Those
|
||||
checks live where the org is enumerated, not in a per-repo gate. If you suspect
|
||||
a cross-repo problem, say so and stop; do not approximate it from one repo.
|
||||
|
||||
## The class decides what is required
|
||||
|
||||
The class is read off the catalog and the remotes — it is structural, not a
|
||||
judgement call. `register/repos.json` holds it.
|
||||
|
||||
| Class | Install form |
|
||||
| --- | --- |
|
||||
| plugin | `marketplace add` **plus** a CLI install command |
|
||||
| catalog | `marketplace add` only — it *is* the marketplace |
|
||||
| shared-asset | how to vendor it; never a plugin install line |
|
||||
| standalone | pip/uv |
|
||||
| org-profile | no install section |
|
||||
|
||||
A flat standard across all classes would demand a CONTRIBUTING from a CSS
|
||||
library that takes no contributions and a ROADMAP from a five-line profile.
|
||||
That is how a gate teaches people to switch it off.
|
||||
|
||||
## Two things about the install block worth knowing before you edit one
|
||||
|
||||
**`enabledPlugins` is a legitimate second form, not a defect.** Most plugin
|
||||
READMEs offer it ("Or enable directly in `~/.claude/settings.json`"), and a
|
||||
reader who scrolls to it has a complete, working path. What the gate requires is
|
||||
a **CLI command** as well, because an agent handed "install this" reaches for the
|
||||
CLI and otherwise finds `marketplace add` and nothing after it. Require the
|
||||
command; welcome the JSON beside it. Never treat the JSON as the failure.
|
||||
|
||||
**`marketplace add` rejects `ssh://`.** It answers `Invalid git URL`, and the
|
||||
message never mentions the protocol. The forge UI's clone button hands out
|
||||
exactly that URL, so this is a trap people walk into rather than a mistake they
|
||||
make. Always write the `https://` form.
|
||||
|
||||
**Keep the block parametric.** The marketplace name and URL come from the
|
||||
register, not from a hardcoded string. A repo distributed through a different
|
||||
marketplace — including a private one — needs its own values, and a public skill
|
||||
cannot carry private marketplace names in the first place.
|
||||
|
||||
## Fixing the first screen
|
||||
|
||||
Only lines 1–25. Nobody rewrites a 930-line body, and the gate does not ask you to.
|
||||
|
||||
# <name>
|
||||
<one line — identical to the forge description>
|
||||
|
||||
<2–4 lines: which problem it solves, who it is for>
|
||||
|
||||
## Install
|
||||
<the block for this class>
|
||||
|
||||
## Requirements
|
||||
## What it does
|
||||
## Non-goals
|
||||
…rest unchanged…
|
||||
## Changelog
|
||||
See [CHANGELOG.md](CHANGELOG.md).
|
||||
|
||||
The opening line is not decoration: it makes description == catalog == README,
|
||||
which is the only place a machine can check that the three agree. `## Install`
|
||||
on a fixed heading is what agents pattern-match on — position and predictability
|
||||
beat brevity for that reader. And **Non-goals answers "is this for me?"** better
|
||||
than any feature table; almost no README answers it at all.
|
||||
|
||||
**Release notes do not belong on the first screen.** A blockquote packing five
|
||||
versions before the reader knows what the thing *is* both duplicates the
|
||||
changelog and points at it. Move it under `## Changelog` as a link.
|
||||
|
||||
**What not to retrofit:** inline version annotations inside feature tables —
|
||||
`(v4.1.0)`, "since v2.4.0" — stay. They are real work and they help a reader who
|
||||
has already installed. The rule applies to new ones: do not add more.
|
||||
|
||||
## Dead references — three outcomes, never two
|
||||
|
||||
The link check separates "matches no repo" (ERROR) from "matches something that
|
||||
is deliberately not a repo" (WARN). Keep them separate when you report, too. If
|
||||
they collapse into one number, a real loss hides inside a pile of correct text.
|
||||
|
||||
Names in **URL position only** are references. A repo-shaped string in a path, in
|
||||
running prose, or as a directory name is not a broken link — and in the measured
|
||||
cases that text was correct and is *supposed* to stay correct. Do not "fix" it.
|
||||
|
||||
Before calling a reference dead, normalise the `.git` suffix. A raw grep once
|
||||
turned three real dead names into about twenty.
|
||||
|
||||
## Descriptions
|
||||
|
||||
Written for someone who has never seen the repo, saying what it does and who
|
||||
it is for. No sales language: "fully", "without exception", "everything from
|
||||
the terminal" are red flags in a technical text.
|
||||
|
||||
Length is bounded and measured in **codepoints** — not bytes, not UTF-16 units.
|
||||
The same string can measure 248 / 249 / 253 across those three yardsticks when
|
||||
it contains an astral character like `👉`. An em-dash costs three bytes but one
|
||||
codepoint, so it only ever exposed the outer layer.
|
||||
|
||||
**Never verify a description against the repo summary card.** It is server-side
|
||||
cached with an undocumented TTL, measured unchanged for more than six hours
|
||||
after a write. Check the API, or `og:description` in the HTML. A card that still
|
||||
shows the old text is not a failed write, and re-writing it is how you turn a
|
||||
cache into an outage of your own making.
|
||||
|
||||
## ROADMAP — not required, and not automatic
|
||||
|
||||
No class requires one. They are absent everywhere today, and a roadmap is a real
|
||||
judgement about where a repo is going, not a template.
|
||||
|
||||
When one is written, it is the **published, sanitised** derivation of the repo's
|
||||
own next-step block. **`STATE.md` must never reach a public surface.** Draft
|
||||
from it, have a human approve, then publish. Never automatically.
|
||||
|
||||
## The boundary this skill will not cross
|
||||
|
||||
Whether to rename a repo, and whether a given roadmap is real or ceremonial, are
|
||||
operator decisions. Bring the evidence; do not decide.
|
||||
|
||||
And **record findings before fixing them**. Patching while measuring is how the
|
||||
inconsistency being cleaned up got there in the first place. Run the gate, report
|
||||
what it says, then fix — in that order.
|
||||
Loading…
Add table
Add a link
Reference in a new issue