Compare commits

...

15 commits

Author SHA1 Message Date
45a2bf30de feat(engine): per-repo heading alias closes the Non-goals/nb-repo gap
The Non-goals contract is one literal English heading, matched
case-insensitively but never translated — a repo whose readers were
declared `nb` in `locales` could only go green by planting an English
heading inside an otherwise-Norwegian document. Measured on
ki-produktivitetsmodell (order, census 09): `## Virkeområde og
forbehold` already does the job Non-goals exists for, HEADING-MISSING
fired anyway. `heading_aliases` in the register is the same shape
`titles` already is for the README H1 — decision in the repo,
bookkeeping here — keyed per repo so two nb-repos need not share a
Norwegian phrasing. Satisfying a requirement through it is its own OK
(HEADING-ALIAS), never folded silently into the aggregate.

Also books two operator title decisions verified against the live
clones (not the coord messages that reported them): .profile's H1
became "From AI to Chitta — open" (org landing page rebuild, commit
9898a6e), and ki-produktivitetsmodell's H1 "Tre nivå av organisatorisk
produktivitet med KI" is deliberate, not drift.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gh6T6iHHkMvgeBbGaLmLQD
2026-08-20 23:14:05 +02:00
e061c1d219 docs(register): ki-produktivitetsmodell is nb — operator decision
README is written entirely in Norwegian, explicitly for "toppledere i norsk
offentlig sektor" — the same Norway-only-audience test that set okr's locale,
not a code trait. Clears the README-LANGUAGE WARN against the default `en`;
verified against the clone (13 passed vs. 12 before, WARN gone, the other
three findings there are that repo's own to fix — HEADING-MISSING,
README-H1, two real LINK-FILE-URL).

Operator approved on the session's recommendation; titles left open per the
same recommendation (lower priority, no specific title to record yet).
2026-08-18 17:44:30 +02:00
1ee6cc28c6 test(engine): pin inspectRepo's multi-file loading through the real I/O path
Closes a real gap the order (20260818T132719Z) correctly identified even
though its diagnosis was wrong: every LINK-FILE-URL test fed `files` to
checkInternalLinks by hand, so nothing pinned that inspectRepo's own loading
loop (`tracked.filter(p => p.endsWith('.md'))`) actually reaches files other
than README.md. A future narrowing of that filter back to README-only would
have kept all 247 tests green.

Two new tests go through inspectRepo itself (git init + git add in a temp
dir, no commit — avoids the gitleaks pre-commit hook and unnecessary git
config). Verified red-then-green: temporarily replaced the loading loop with
an empty one, confirmed the leak-detection test failed with the exact
"did not fire" message, reverted, confirmed all 249 pass.

This crosses the file's stated "pure classifiers only, inspectRepo is
exercised live" boundary deliberately and narrowly, for the one behaviour an
external report specifically asked to see pinned through the ordinary
loading path rather than by feeding `files` directly.
2026-08-18 17:11:58 +02:00
5fe1743105 docs(engine): record the file:// root-widening decision — order 20260818T132719Z
Answers the order's two asks, verified independently:

1. "sjekken leser bare README.md" — FALSE against the current codebase.
   inspectRepo has scanned every tracked .md file since 2026-07-27 (commit
   816ba97), three weeks before this order. Reproduced the order's exact
   scenario against a fresh clone of open/app-creator: CLAUDE.md:136 does not
   fire, but not from the diagnosed cause — the cited link uses the `/.../`
   elision this engine already treats as a documented placeholder convention
   (the same "18/22 split" the FILE_URL_LEAK comment already describes), not
   a leak. Injected a real (non-elided) file:// leak into the same CLAUDE.md
   in the same clone: LINK-FILE-URL fired immediately, correct file:line.
   No code change corresponds to this half of the order.

2. "bør regelen gjelde file://-lenker generelt, ikke bare /Users|home" — live,
   unmeasured, answered here. Grepped every tracked .md file in the 21 of 22
   registered repos with a local clone (llm-security-commons absent).
   Positive control: the two real /Users/ leaks in ki-produktivitetsmodell's
   CLAUDE.md were found, proving the query isn't silently empty. Result:
   zero occurrences of any other root (no C:\, /private/, /var/, /tmp/) in
   150 file:// hits across 12 repos — every other hit is a generic
   placeholder already declined by the Users|home anchor. Recorded as a
   RELEASE-ASSETS-shaped rejection in the code comment: no subject, not a
   low rate. Widen the day a real one appears.

Closing the order with --no-commit: this resolves both halves, it does not
hand anything back for the sender to act on.
2026-08-18 17:02:27 +02:00
aee0f28539 fix(engine): TAG-ANNOTATED never consulted the register for the newest tag
Reported by catalog (coord, 2026-08-17): register/repos.json has listed
ktg-plugin-marketplace v7.7.2 under tags_lightweight_accepted since the
0.11.2 release (2026-08-14), but `accepted` was only ever applied to
tags.slice(0, -1) — the newest tag is excluded from that slice by
construction, so the entry was dead weight from the day it was written.

checkTagIntegrity now checks the newest tag against `accepted` too, emitting
a distinct TAG-ANNOTATED-ACCEPTED-NEWEST OK instead of silently doing
nothing when the register names it exactly. Acceptance is per-tag-name, not
a standing exemption: a new lightweight tag that becomes newest afterwards
is still judged (tested).

TDD: two new tests written failing first (an existing test's title claimed
"cannot be accepted away" but never actually passed a matching `name`, so it
was accidentally still green either way — reworded to test what it actually
covers). Verified against a fresh clone of ktg-plugin-marketplace: the full
finding set now reads OK TAG-ANNOTATED-ACCEPTED-NEWEST + OK
TAG-ANNOTATED-ACCEPTED(7 older) instead of ERROR, with no knock-on effect on
TAG-SIGNED (all 9 tags predate the signing policy). Register comment updated
to record the three-day dead-weight window rather than silently correcting
it — a decision that turns out wrong is worse than no record.

247 tests pass (245 + 2 new).
2026-08-18 16:59:00 +02:00
4714f13959 docs(register): add ki-produktivitetsmodell as shared-asset
Reported unregistered by ki-leadership (REPO-UNREGISTERED SKIP). Verified
directly against the forge clone rather than trusting the report: no code,
no build chain, no install form — README + CHANGELOG + LICENSE (CC BY 4.0) +
a standalone HTML rendering + a PDF. Matches the other shared-asset entries
(playground-design-system, portfolio-optimiser-commons, llm-security-commons)
far better than standalone, which would demand a false `## Install` heading.

--refresh confirms this was the only drift: 22 registered, 23 on the forge,
exactly this one name missing.

First finding set against the clone: HEADING-MISSING (no `## Non-goals`),
README-H1 and README-LANGUAGE WARNs (title/locale — both operator calls per
this register's own convention, left unset here), and two real LINK-FILE-URL
ERRORs in CLAUDE.md that are not placeholders. That repo's own findings to
fix, not ours.
2026-08-18 16:54:05 +02:00
1a72f8dde5 fix(version): package.json drifted from the v0.11.2 tag it never got bumped to
Confirmed independently by ki-leadership's own gate run (cached engine, but
same root cause). v0.11.2 was tagged and released (f08fcc9) without bumping
package.json — no new tag or catalog ref changes here, just syncing the
version string to what was already published.
2026-08-18 16:53:57 +02:00
1b0a9a338e docs: add SECURITY.md
AAA+ program round 3 (C-axis, trust). No file existed before; new
vulnerability-reporting policy at repo root, matching the org-wide
10/10 template from the coordinated round.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VF8cJYs2vZazMisAd95nfM
2026-08-16 21:14:18 +02:00
198c95e5e5 docs: add Table of Contents to README
AAA+ B-axis round 2 (ORDRE 32). 311-line README with 6 H2 sections had
no navigation aid, matching the org-wide pattern (config-audit/README.md).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0187bUAyMzytxGyXcDQJkQKE
2026-08-16 16:18:12 +02:00
f08fcc9b80 chore(release): v0.11.2 — two register decisions and the stale prose counts
Register: ms-ai-architect removed from locales (org-ops D35 — the entry,
not the prose, was the wrong side); ktg-plugin-marketplace v7.7.2 accepted
as lightweight history (catalog's measured counter: monorepo-era tag, zero
consumers). README: the two stale prose counts made count-free.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzGk2pnq1N7J9H7xj2XAH1
2026-08-14 20:19:04 +02:00
e5cd1bfaaf fix(engine): FILE-MISSING named the class for a trait-sourced requirement
security -> SECURITY.md read as "missing required file for class
`standalone`", sending the operator to a class definition that never
listed the requirement. requirementsFor now carries each required
file's origin (class vs. trait) and checkRequiredFiles names whichever
actually required it. Failing test written first per the Iron Law.

No release forced by this alone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WuWwACwhun35j52AnTxRKV
2026-08-13 18:51:21 +02:00
f422b63bb3 fix(engine): the affirmative TAGS-SIGNED was suppressed by its own exemption OK
Gated on `findings.length === 0`, so a TAG-SIGNED-PREPOLICY OK silenced it —
and every repo in the org has pre-policy tags, which made the affirmative
verdict near-unreachable in practice.

Caught by dogfooding v0.11.0: this repo signed the first tag the check ever
judged, and the gate did not say so. Status was green either way, which is
exactly why it was worth fixing — a reader could not tell "signed its new tag"
from "has cut no tag since the policy". Two different facts wearing one
silence, which is the defect this engine already refuses to ship one check
over, where an exemption nobody can see reads like a check that stopped
running.

Keys the affirmative on the JUDGED findings only. Measured after: 18 repos
emit both codes, 3 NONE, still 0 ERROR / 0 WARN. 243 tests, from 241.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsPsVsvhrSaejK3cLPmnN2
2026-08-13 10:54:35 +02:00
e6cd8983ca feat(engine): TAG-SIGNED — a tag that names a signer, not just write access
The check sat parked as "blocked on an operator decision" on the strength of
0 of 18 repos signing. That zero was never evidence against it: it is what a
practice nobody has adopted yet looks like, and reasoning from it makes the
gate ratify the status quo it exists to move. The rule that settled it asks
something else — would a public AAA+ repository do this? Yes, and unlike the
two conventions that killed VERSION-DRIFT, there is no second convention under
which release tags are better left unattributable.

The measurement then decided the SCOPE, not the yes. Across 20 clones: 158 v*
tags, 144 annotated, 14 lightweight, 0 signed, and not one dated after
2026-08-12. A boolean "this org signs" would have failed 20 correct
repositories on day one with force-moving 144 published refs as its only
remedy — the mechanism that gets gates switched off. So the policy is a DATE in
the register (never in the engine, the rule that keeps the org-profile
exemption a flag): nothing fires today, teeth at each repo's next release.

Verified both directions, because a sweep returning zero proves nothing on its
own — a dead check returns zero too. 21 repos: 18 PREPOLICY + 3 NONE, 0 ERROR,
0 WARN; then against real git objects an unsigned post-policy newest tag does
fire ERROR.

Two exclusions keep one defect from being counted twice: a lightweight tag has
no object to carry a signature (byDesign skip naming TAG-ANNOTATED as owner),
and a pre-policy tag was correct when cut (OK, said out loud, never silence).
It answers "is it signed", never "does the forge vouch for it".

The README row ships in this commit, not after it, and the stale test count in
CLAUDE.md (230 → 241) is corrected here rather than left for the next reader.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsPsVsvhrSaejK3cLPmnN2
2026-08-13 10:51:36 +02:00
9f08ebc5ed chore(release): v0.10.1 — deliver the corrected skill
The catalog pins by tag, so 009c911's SKILL.md fix reaches nobody until a
tag carries it. An installed reader would still get "the two network
calls" and no VERIFY-COMMAND guidance from the release that introduced
the check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwZeAZ8cHmGZofM9dryuT9
2026-08-12 23:28:36 +02:00
009c911f9e docs(skill): the skill drifted behind the engine in the commit that banned it
v0.10.0 canonized "a new check is not shipped until its row exists" and
shipped with SKILL.md still saying "the two network calls" — after the
third had been added — and with no mention of VERIFY-COMMAND at all. The
invariant was violated one file over, in the same commit that wrote it.

Three corrections, and one is a trap worth naming: the remedy for
VERIFY-MISSING is to document the command that already exists, after
running it. Writing `npm test` into a README because the finding
mentioned it, with no scripts.test behind it, turns a WARN about missing
documentation into a false claim on the first screen — a worse defect
than the one being fixed, and one this gate can never catch, because it
runs nothing. VERIFY-NONE needs no action at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwZeAZ8cHmGZofM9dryuT9
2026-08-12 23:28:06 +02:00
10 changed files with 911 additions and 56 deletions

View file

@ -1,6 +1,6 @@
{
"name": "repo-standard",
"version": "0.10.0",
"version": "0.11.2",
"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"

View file

@ -6,6 +6,129 @@ versioning is [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.11.2] — 2026-08-14
### Changed
- **Register: `ms-ai-architect` removed from `locales`.** Four sweeps in a row
(0407) held a correct `README-LANGUAGE` `WARN` against an entry that was
itself wrong: the repo's published README disavows a Norway-only audience in
its own words ("useful for any enterprise context", line 17, verified against
the forge), and its 24 Norway markers are traits — what the code does — not
audience. The register was the wrong side; the prose stands. Decided on
org-ops' measured case (their D35).
- **Register: `ktg-plugin-marketplace v7.7.2` accepted as lightweight
history.** Previously deliberately absent because the newest tag has the one
safe remedy. The catalog's measured counter: v7.7.2 is a monorepo-era
llm-security tag (2026-05-19, pre-split), not a catalog release, and zero
install paths consume it — force-moving a published ref for no consumer costs
more than the finding. The catalog committed to annotating every future tag,
which the check itself enforces.
- **README: the two prose counts that had gone stale are now count-free.**
"170 tests" (actual: 245) and "twelve checks" survived two releases
unchanged — the ungated copy of a number is the defect, so the copies are
removed rather than corrected. `npm test` prints the test count; the check
table (20 rows) is the canonical check list.
### Fixed
- **`FILE-MISSING` named the class even when a TRAIT required the file.**
`security``SECURITY.md` read as "missing required file for class
`standalone`", sending the operator looking for a requirement the class
never listed. `requirementsFor` now carries the source (`class` vs. `trait`)
alongside each required file, and the message names whichever actually
required it. 245 tests, from 243.
## [0.11.1] — 2026-08-13
### Fixed
- **`TAGS-SIGNED`, the affirmative verdict, was near-unreachable.** It was gated
on `findings.length === 0`, so the presence of a `TAG-SIGNED-PREPOLICY` `OK`
suppressed it — and every repo in the org has pre-policy tags. Caught by
dogfooding the v0.11.0 release: this repo signed the first tag the check ever
judged, and the gate did not say so.
The status was green either way, which is what made it worth fixing rather
than shrugging at: a reader could not tell *this repo signed its new tag* from
*this repo has cut no tag since the policy*. Two different facts wearing one
silence — the same defect as an exemption nobody can see, which this engine
already refuses to ship one check over. The gate now keys the affirmative on
the JUDGED findings only, and the two cases carry different sentences.
Measured after: 18 repos emit both `TAGS-SIGNED` and `TAG-SIGNED-PREPOLICY`,
3 `TAG-SIGNED-NONE`, still 0 ERROR and 0 WARN. 243 tests, from 241.
## [0.11.0] — 2026-08-13
### Added
- **`TAG-SIGNED` — a release tag that names a signer, not just whoever could
write to the forge.** The catalog pins plugins by tag, so this sits on the
same supply-chain axis as `TAG-ANNOTATED`: annotation makes a tag
immovable-without-a-trace, a signature makes it attributable.
The check had been parked for a release as "blocked on an operator decision",
on the strength of 0 of 18 repos signing. That zero was never evidence against
it — it is what a practice nobody has adopted yet looks like, and reasoning
from it makes the gate ratify the status quo it exists to move. The rule that
settled it (operator, 2026-08-13) asks something else entirely: *would a public
AAA+ repository do this?* Yes. Unlike the two legitimate conventions that
killed `VERSION-DRIFT`, there is no second convention under which release tags
are better left unattributable.
**The policy is a DATE, and the date is what made the check shippable.**
Measured across 20 clones before the rule was locked: 158 `v*` tags, 144
annotated, 14 lightweight, 0 signed, and not one tag dated after 2026-08-12. A
boolean "this org signs" would have failed 20 correct repositories the day it
landed, with force-moving 144 published refs as its only remedy — the mechanism
that gets gates switched off. `tags_signed_from` lives in the register, never
in the engine (the rule that keeps the org-profile exemption a flag rather than
a class name in a classifier); absent it, the check is a `notRun` SKIP, because
the gate never assumes a policy nobody recorded.
Verified both directions: a sweep of 21 repos yields 18 `TAG-SIGNED-PREPOLICY`
+ 3 `TAG-SIGNED-NONE`, 0 ERROR, 0 WARN — then, against real git objects, an
unsigned post-policy newest tag does fire `ERROR`. A sweep returning zero
proves nothing alone; a dead check returns zero too.
Two exclusions keep one defect from being counted twice under two codes: a
**lightweight** tag has no tag object to carry a signature at all
(`TAG-SIGNED-LIGHTWEIGHT`, a `byDesign` skip naming `TAG-ANNOTATED` as the
owner), and a **pre-policy** tag was correct when it was cut
(`TAG-SIGNED-PREPOLICY`, an `OK` — said out loud, never as silence).
It answers *is this tag signed* and never *does the forge vouch for it*: the
signature is read from the clone, while the forge's "Verified" badge needs a
key registered there.
### Changed
- The engine reads tag signature and creation date alongside the object type, in
the same single `for-each-ref`. The signature is read with
`%(if)%(contents:signature)%(then)…` rather than by printing the block, which
is multi-line and would break the line-oriented parser on exactly the tags the
check cares about. No new API call — this stays at three.
- 241 tests, from 230.
## [0.10.1] — 2026-08-12
### Fixed
- **The skill had drifted behind the engine in the release that banned exactly
that.** 0.10.0 canonized "a new check is not shipped until its row exists" and
shipped with `SKILL.md` still saying "the two network calls" after the third
had been added, and with no mention of `VERIFY-COMMAND` at all. The catalog
pins by tag, so the stale skill was the one an installed reader actually got —
which is why this is a release rather than a note.
It also now names the trap in fixing a `VERIFY-MISSING`: document the command
that already exists, after running it. Writing `npm test` into a README
because the finding mentioned it, with no `scripts.test` behind it, turns a
`WARN` about missing documentation into a false claim on the first screen — a
worse defect than the one being fixed, and one this gate can never catch,
because it runs nothing.
## [0.10.0] — 2026-08-12
### Added

View file

@ -225,6 +225,21 @@ would recreate, in data, exactly the drift this plugin exists to remove.
stands. What the gate must never do is make "we decided this" and "nobody
looked" the same output. The wanted side effect is exposure, not silence —
`ai-psychosis` is deliberately unregistered so it stands alone.
- **A fixed-language contract has a blind spot the `titles` pattern already
knows how to close.** `## Non-goals` is one literal English string, matched
case-insensitively but not translated, so it cannot be satisfied by a repo
whose readers were declared `nb` in `locales` — the only way to go green was
planting an English heading inside an otherwise-Norwegian document. Measured
on `ki-produktivitetsmodell` (order, 2026-08-18, census 09 on that repo):
`nb` locale, `## Virkeområde og forbehold` already doing the job Non-goals
exists for (a stranger sees what the repo does not try to be), `HEADING-
MISSING` `ERROR` regardless. `heading_aliases` in the register is the same
shape `titles` already is for the H1 — the decision is taken in the repo, the
bookkeeping happens here — and satisfying a requirement through it is its own
`OK` (`HEADING-ALIAS`), never folded silently into the aggregate. Keyed PER
REPO, not per locale: a locale-wide translation table would force every
nb-repo into the same Norwegian phrasing for the same section, exactly the
constraint `titles` already rejects for H1s one requirement over.
- **An exemption is a finding, not a deletion.** `readme_desc_match: false`
turns off README-DESC equality for a class, and the check still emits an `OK`
naming why. An exception nobody can see reads exactly like a check that
@ -282,16 +297,42 @@ would recreate, in data, exactly the drift this plugin exists to remove.
has no convention on either side because it has no subject. Do not re-derive
it from "18/18" alone — the fire rate is a proxy, and reasoning from the proxy
is what would resurrect this check the first time one repo publishes an asset.
- **`TAG-SIGNED` is BLOCKED ON AN OPERATOR DECISION, not rejected — and filing it
with the rejections is the error to avoid.** 0 of 18 repos sign their tags
today, which looks like the same measurement that killed VERSION-DRIFT and is
not: if the operator decides this org signs tags, the check acquires its whole
subject the same day. A rejection is permanent; this is one yes/no away.
Recorded here so the next session reads a pending question instead of
re-measuring a settled one. The scope is also fixed in advance: `git cat-file
tag` reads the signature block from the clone, so the check can answer *is it
signed* and must never claim *the forge vouches for it* — the forge's
"Verified" badge needs a key registered there.
- **The question is "would a public AAA+ repo do this", NOT "how many of ours
already do".** Operator rule, 2026-08-13, and it settled `TAG-SIGNED` — which
had been parked as "blocked on an operator decision" on the strength of 0 of
18 repos signing. That zero was never evidence against the check: it is what a
practice nobody has adopted yet looks like, and reasoning from it makes the
gate ratify the status quo it exists to move. The operator's word for being
asked at all was that it "should almost be self-evident". This is genuinely
different from the VERSION-DRIFT rejection, where two legitimate conventions
coexisted; there is no second convention under which release tags are better
left unattributable. **The test decides WHETHER; the corpus measurement then
decides only HOW TO SCOPE** so the rule does not fail correct repositories on
day one. Do not let a low adoption rate veto a yes.
This does not reopen `RELEASE-ASSETS`, which was rejected for the stronger
reason — no subject at all, 0 of 21 READMEs referencing an asset download.
- **`TAG-SIGNED` ships as a policy DATE, and the date is what made it
shippable.** Measured across 20 clones before the rule was locked (2026-08-13):
158 `v*` tags, 144 annotated, 14 lightweight, **0 signed**, and not one tag
dated after 2026-08-12. A boolean "this org signs" would have failed 20
correct repositories the day it landed, with force-moving 144 published refs
as its only remedy — the mechanism that gets gates switched off. The date
(`tags_signed_from` in the register, never in the engine, the same rule that
keeps the org-profile exemption a flag) fires nothing today and acquires teeth
at each repo's next release, which is how signing is adopted in public.
Verified by sweep: 21 repos, 18 `TAG-SIGNED-PREPOLICY` + 3 `TAG-SIGNED-NONE`,
0 ERROR, 0 WARN — then positively controlled against real git objects, where
an unsigned post-policy newest tag does fire `ERROR`. A sweep returning zero
proves nothing on its own; a dead check returns zero too.
Two exclusions keep one defect from being counted twice: a **lightweight** tag
has no tag object to carry a signature (`TAG-ANNOTATED` owns it, and the skip
names that owner), and a **pre-policy** tag was correct when it was cut — said
out loud as an `OK`, never as silence. Scope fixed in advance and held:
the signature is read from the clone, so the check answers *is it signed* and
never *does the forge vouch for it* — that badge needs a key registered there.
Read with `%(if)%(contents:signature)%(then)…` rather than by printing the
block, which is multi-line and would break the line-oriented parser on exactly
the tags that matter.
- **A gate whose subject varies per repo MEASURES the subject; it does not read
it off the class.** `VERIFY-COMMAND` fires only where something runnable
exists (`scripts.test`, or a tracked executable test file), and answers
@ -318,7 +359,7 @@ would recreate, in data, exactly the drift this plugin exists to remove.
## Commands
```bash
npm test # 230 tests
npm test # 245 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

View file

@ -10,7 +10,7 @@ checks that surface in one repository and reports what it finds.
*AI-generated: all code produced by Claude Code through dialog-driven development.*
![Version](https://img.shields.io/badge/version-0.10.0-blue)
![Version](https://img.shields.io/badge/version-0.11.2-blue)
![Platform](https://img.shields.io/badge/platform-Claude_Code_Plugin-purple)
![Skills](https://img.shields.io/badge/skills-1-orange)
![License](https://img.shields.io/badge/license-MIT-lightgrey)
@ -21,6 +21,15 @@ has no Actions runner — so the test claim is one you run yourself, in one
command, from a clean clone: `npm test`. A badge asserting it would be a claim
dressed as evidence.
## Table of Contents
- [Install](#install)
- [Requirements](#requirements)
- [What it does](#what-it-does)
- [Non-goals](#non-goals)
- [Tests](#tests)
- [Changelog](#changelog)
## Install
Use the `https://` form. The forge UI's clone button hands out an `ssh://` URL,
@ -69,7 +78,7 @@ The repository's **class** decides what each check means:
| Install block | the form for this class is missing, incomplete, shown over `ssh://`, or points at the wrong marketplace |
| Install truth | the plugin is not pinned in the catalog, so the documented command cannot succeed for anyone |
| Install pins | a `@v…` or `--branch v…` in an install command names a tag the forge does not have, so the command a stranger copies 404s |
| Required headings | `## Install`, `## Non-goals`, `## Changelog` — per class. Present at the wrong depth is its own finding |
| Required headings | `## Install`, `## Non-goals`, `## Changelog` — per class. Present at the wrong depth is its own finding. A registered per-repo heading alias (`register/repos.json`'s `heading_aliases`) satisfies it too, for a repo whose readers were declared to speak another language |
| Required files | a file this class (or trait) needs is absent |
| Repo references | an `open/<name>` in URL position resolves to nothing |
| Relative links | a link points at a file that is not tracked |
@ -80,6 +89,7 @@ The repository's **class** decides what each check means:
| Boilerplate | template text nobody filled in |
| Version consistency | manifest, README badge, newest CHANGELOG entry and the git tag disagree |
| Tag integrity | the newest tag is lightweight — movable without a trace, under a catalog that pins by tag. Older ones are exposed once as a count, because their only "fix" is force-moving a published ref |
| Tag signing | a tag cut on or after the org's signing date carries no signature, so nothing ties the released ref to a signer rather than to whoever could write to the forge. Tags predating the date are never judged — signing them now would mean force-moving published refs. Answers *is it signed*, never *does the forge vouch for it* |
| Release current | the newest release the forge publishes is older than the newest tag it lists, so the version page shows a version older than the code. `WARN` — publishing a release for an existing tag moves nothing |
| Remote sync | a tag exists only in this clone. The manifest can read as correctly tagged while no stranger can resolve the version |
| Verify command | the repo has something runnable and the README shows no command to run it. With no CI badge in this org, that command is the only evidence a stranger gets |
@ -97,7 +107,7 @@ severity — it is the absence of a verdict, printed apart from the judged
findings.
A repository's own `status` is the worst of the findings that were *judged*, and
`SKIP` only when nothing was. A skip cannot outrank twelve checks that did run.
`SKIP` only when nothing was. A skip cannot outrank the checks that did run.
Coverage rides beside it on its own axis, and since 0.8.0 that axis has two
values, because a skip is one of two unrelated facts:
@ -179,6 +189,26 @@ An H1 that matches neither the repo name nor a registered title is still a
warning, and the message names both — that is drift in one of the two, not a
title.
### Registered heading aliases — the same asymmetry, one requirement over
`## Non-goals` (and every other required heading) is one fixed English string,
matched case-insensitively but never translated. A repository whose readers
were declared `nb` in the register's `locales` cannot satisfy it honestly —
the only way to go green was planting an English heading inside an otherwise
Norwegian document, which is worse than the warning it silences.
A `heading_aliases` entry in the register is the fix, shaped exactly like
`titles`: the decision — this section, in this repo's own language, does the
job the English heading names — is made in the repo, and the bookkeeping
happens here. Set one, and a README carrying the aliased heading instead of
the literal one is an `OK` (`HEADING-ALIAS`) that names the alias, never a
silent pass. Leave it out, and `HEADING-MISSING` stands exactly as before.
It is keyed **per repository**, not per locale. A locale-wide translation
table would force every `nb` repository into the same Norwegian phrasing for
the same section — precisely the constraint `titles` already rejects for H1s,
where a human title is a valid choice, not a slot filled from a fixed list.
### When equality is the wrong demand
`README-DESC` requires the README's opening line to be the forge description
@ -296,7 +326,9 @@ distinct from "an older engine that never tried".
npm test
```
170 tests over the pure classifiers. The reference fixtures are measured false
The suite pins the pure classifiers; `npm test` prints the current count, which
is the only copy of that number this README keeps — a prose count here went
stale twice. The reference fixtures are measured false
positives, each with its expected verdict — the six that produced the
three-outcome reference rule, plus the noise sources found by running the gate
against a real repository: regexes inside code spans that are markdown links to

34
SECURITY.md Normal file
View file

@ -0,0 +1,34 @@
# Security policy
## Reporting a vulnerability
Report privately to <security@fromaitochitta.com> — do not open a
public issue.
Canonical repository: https://git.fromaitochitta.com/open/repo-standard
Please include the affected version or commit, a minimal reproduction,
and the impact you see. We acknowledge every report within 5 working
days, agree a fix and disclosure timeline with the reporter, and aim to
disclose within 90 days of the initial report.
## Response process
1. Acknowledge within 5 working days.
2. Triage and confirm severity within 10 working days.
3. Develop and test a fix.
4. Publish an advisory and credit the reporter unless they prefer
to remain anonymous.
## Supported versions
| Version | Supported |
| ------- | --------- |
| 0.11.x | :white_check_mark: |
| < 0.11 | :x: |
This project has not reached 1.0 yet; only the latest 0.x release line
receives security fixes. See `CHANGELOG.md` for release history.
## Advisories
No advisories have been published yet.

View file

@ -1,6 +1,6 @@
{
"name": "repo-standard",
"version": "0.10.0",
"version": "0.11.2",
"private": true,
"type": "module",
"engines": {

View file

@ -32,6 +32,7 @@
"playground-design-system": "shared-asset",
"portfolio-optimiser-commons": "shared-asset",
"llm-security-commons": "shared-asset",
"ki-produktivitetsmodell": "shared-asset",
".profile": "org-profile",
"portfolio-optimiser": "standalone",
"portfolio-optimiser-claude": "standalone",
@ -134,14 +135,27 @@
"",
"`ai-psychosis` (`# Interaction Awareness`) is DELIBERATELY ABSENT: it is the",
"one where a reader cannot connect the title to the repo. Leaving it standing",
"alone is the wanted effect of registering the others."
"alone is the wanted effect of registering the others.",
"",
"`.profile` UPDATED 2026-08-18 (org-ops coord, census 09): the org landing",
"page was rebuilt the same day (commit 9898a6e, 'rebuild org landing page,",
"add LICENSE and SECURITY.md', per that repo's own STATE.md — order from",
"`.claude`, AAA+ round 5). The H1 became `# From AI to Chitta — open`; the",
"old value here was the H1 it replaced, not drift. Verified against the",
"clone, not the coord message — the em-dash is U+2014, not a hyphen.",
"",
"`ki-produktivitetsmodell` ADDED 2026-08-18 (order from that repo, on its own",
"operator decision recorded in its STATE.md the same day): `# Tre nivå av",
"organisatorisk produktivitet med KI` is the document's real title, the repo",
"name is a slug. Verified against the clone before writing it down."
],
"titles": {
".profile": "fromaitochitta / open",
".profile": "From AI to Chitta — open",
"llm-ingestion-pipeline-security": "llm-ingestion-guard",
"linkedin-studio": "LinkedIn Studio Plugin for Claude Code",
"llm-security": "LLM Security Plugin for Claude Code",
"ms-ai-architect": "AI Architect Plugin for Claude Code"
"ms-ai-architect": "AI Architect Plugin for Claude Code",
"ki-produktivitetsmodell": "Tre nivå av organisatorisk produktivitet med KI"
},
"$comment_traits": [
@ -175,15 +189,56 @@
"Norwegian readership is `nb`, and is then WRONG in English, not right.",
"",
"PROPOSED BY THE OPERATOR, NOT MEASURED — like `traits`, this list is a",
"judgement and no remote can report it. Both entries below were named by",
"judgement and no remote can report it. Both original entries were named by",
"the operator on 2026-08-04 as Norway-only in their audience.",
"",
"`ms-ai-architect` was REMOVED 2026-08-14 (org-ops coord, their D35): its",
"published README disavows the exclusivity this axis requires — line 17,",
"'while remaining useful for any enterprise context' — verified against the",
"forge before removal. Its 24 Norway markers are TRAITS (what the code",
"does: Digdir, NSM, DPIA), which is the axis confusion this comment warns",
"about two paragraphs up. The register was the wrong side, not the prose.",
"`okr` stands: its README IS Norwegian, rewritten to close census 05.",
"",
"`ki-produktivitetsmodell` ADDED 2026-08-18 (operator decision, on the",
"session's recommendation after registering the repo as `shared-asset`):",
"its README is written entirely in Norwegian, explicitly for 'toppledere i",
"norsk offentlig sektor' — the same Norway-only-audience test that set",
"`okr`, not a code trait.",
"",
"Detection is a stopword-frequency comparison over prose with code stripped.",
"It answers WHICH language dominates, never whether the prose is any good."
],
"locales": {
"ms-ai-architect": "nb",
"okr": "nb"
"okr": "nb",
"ki-produktivitetsmodell": "nb"
},
"$comment_heading_aliases": [
"The Non-goals contract is one fixed English string, matched literally — it",
"cannot be satisfied by a repo whose readers were declared `nb` in `locales`",
"above, and the only way to go green was to plant an English heading inside",
"an otherwise-Norwegian document. Same job `titles` does for a README H1, one",
"requirement over: the decision is taken in the repo, the bookkeeping happens",
"here — a registered alias satisfies the requirement and is its own OK",
"(`HEADING-ALIAS`), never a silent pass.",
"",
"Keyed PER REPO, not per locale: a locale-wide translation table would force",
"every nb-repo into the same Norwegian phrasing for the same section, which",
"is exactly the constraint `titles` already rejects for H1s (a human title is",
"a valid choice, not a slot to fill from a fixed list).",
"",
"ADDED 2026-08-18 (order from ki-produktivitetsmodell, census 09 on that",
"repo): its README is `nb` (see `locales` above) and carries",
"`## Virkeområde og forbehold`, which does the job the Non-goals contract",
"exists for — a stranger sees what the repo does not try to be. The operator",
"decided the README stays as written; this entry is the mechanism, not an",
"exception for one repo. Verified against the clone before writing it down."
],
"heading_aliases": {
"ki-produktivitetsmodell": {
"## Non-goals": "## Virkeområde og forbehold"
}
},
"$comment_tags_lightweight_accepted": [
@ -201,19 +256,65 @@
"the very gap REMOTE-SYNC exists to name, so these are claims about three",
"OTHER repos and were sent to them by coord so a wrong name can be disputed.",
"",
"13 entries, not the 14 lightweight tags that exist. `ktg-plugin-marketplace",
"v7.7.2` is DELIBERATELY ABSENT: it is that repo's newest tag, the one",
"lightweight tag with a safe remedy (`git tag -a -f`), and it is an ERROR",
"today. Pre-accepting it would mean that cutting v7.8.0 instead of fixing it",
"makes the finding vanish silently — the cheap fix skipped and unrecorded.",
"If it ages into history un-remedied, a fresh WARN is the correct signal."
"`ktg-plugin-marketplace v7.7.2` was DELIBERATELY ABSENT until 2026-08-14:",
"as the newest tag it had the one safe remedy (`git tag -a -f`), and",
"pre-accepting it would have let cutting v7.8.0 skip the cheap fix",
"unrecorded. ACCEPTED 2026-08-14 on the catalog's measured counter (their",
"coord, catalog HEAD d0d5d70): v7.7.2 is a monorepo-era llm-security tag",
"(2026-05-19, pre-split), not a catalog release; their README install block",
"pins no ref and all 12 marketplace.json refs are plugin-repo tags, so zero",
"install paths consume it — 'newest tag' here sorts last without being the",
"newest RELEASE, which is the premise the safe-remedy rule rests on.",
"Force-pushing a published ref for zero consumers costs more than the",
"finding. The catalog committed to annotating every future tag; the check",
"itself enforces that, since a new lightweight newest tag is not on this",
"list and fires ERROR.",
"",
"This entry was DEAD WEIGHT from 2026-08-14 to 2026-08-18: `accepted` was",
"only ever consulted for tags OLDER than newest, so v7.7.2 kept firing",
"TAG-ANNOTATED ERROR the whole time — the exact 'we decided this' vs.",
"'nobody looked' collapse this axis exists to prevent, one level down.",
"Caught and reported by the catalog itself (coord, 2026-08-17), fixed in",
"`checkTagIntegrity` (repo-standard, 2026-08-18): the accepted set is now",
"checked against the newest tag too, emitting a distinct",
"`TAG-ANNOTATED-ACCEPTED-NEWEST` OK rather than silently doing nothing.",
"",
"The two slash-named `config-audit/v*` tags on that forge are OUT OF SCOPE",
"by construction, not omitted by a fetch gap: the engine reads",
"`refs/tags/v*` — the repo's own version line — and a namespaced",
"monorepo-era tag is not part of it."
],
"tags_lightweight_accepted": {
"ktg-plugin-marketplace": ["v3.4.1", "v5.0.0", "v5.0.1", "v5.0.2", "v5.0.3", "v7.7.0", "v7.7.1"],
"ktg-plugin-marketplace": ["v3.4.1", "v5.0.0", "v5.0.1", "v5.0.2", "v5.0.3", "v7.7.0", "v7.7.1", "v7.7.2"],
"llm-ingestion-okf": ["v0.2.0", "v0.3.0", "v0.3.1", "v0.3.2", "v0.4.0"],
"llm-ingestion-pipeline-security": ["v0.3.0"]
},
"$comment_tags_signed_from": [
"The date the org's tag-signing policy takes effect. Operator decision",
"2026-08-13, on the rule that decides these: would a public AAA+ repository",
"do this? Yes — a signed tag ties a released ref to a signer rather than to",
"whoever could write to the forge, and the catalog pins plugins by tag.",
"",
"It lives HERE and not in the engine for the reason the org-profile",
"exemption is a flag rather than `if (klass === 'org-profile')`: the engine",
"holds no org decision of its own. Remove the key and TAG-SIGNED goes back",
"to a notRun SKIP — the gate never assumes a policy nobody recorded.",
"",
"A DATE, not a boolean, and that is the whole reason the check is shippable.",
"MEASURED across 20 clones 2026-08-13: 158 `v*` tags, 144 annotated, 14",
"lightweight, 0 signed, and NOT ONE tag dated after 2026-08-12. A boolean",
"would have failed 20 correct repositories the day it landed — the mechanism",
"that gets gates switched off — and its only remedy would have been force-",
"moving 144 published refs. The date fires nothing today and acquires teeth",
"at each repo's next release, which is how signing is adopted in public.",
"",
"Do NOT backdate this to 'catch up' history. Every tag below it was correct",
"when it was cut, and the check says so out loud (TAG-SIGNED-PREPOLICY, OK)",
"rather than going silent about it."
],
"tags_signed_from": "2026-08-13",
"description_max_codepoints": 180,
"$comment_length": [
"180 codepoints, not bytes and not UTF-16 units. The same string measures 248",

View file

@ -538,12 +538,19 @@ export function checkInstallPins({ readme, forgeTagsByRepo }, register) {
// outsider who finds a hole, and being solo does not remove them.
function requirementsFor(klass, traits, register) {
const cls = register.classes?.[klass] ?? {};
const files = [...(cls.required_files ?? [])];
const classSource = `class \`${klass}\``;
const files = (cls.required_files ?? []).map((file) => ({ file, source: classSource }));
const headings = [...(cls.required_headings ?? [])];
const seenFiles = new Set(files.map((f) => f.file));
for (const t of traits ?? []) {
const tr = register.trait_requirements?.[t];
if (!tr) continue;
for (const f of tr.required_files ?? []) if (!files.includes(f)) files.push(f);
const traitSource = `trait \`${t}\``;
for (const f of tr.required_files ?? []) {
if (seenFiles.has(f)) continue;
seenFiles.add(f);
files.push({ file: f, source: traitSource });
}
for (const h of tr.required_headings ?? []) if (!headings.includes(h)) headings.push(h);
}
return { files, headings };
@ -553,9 +560,9 @@ export function checkRequiredFiles({ present, klass, traits }, register) {
const { files: required } = requirementsFor(klass, traits, register);
const have = new Set(present ?? []);
const findings = [];
for (const f of required) {
if (!have.has(f)) {
findings.push({ level: 'ERROR', code: 'FILE-MISSING', bucket: 'missing', msg: `missing required file for class \`${klass}\`: ${f}` });
for (const { file, source } of required) {
if (!have.has(file)) {
findings.push({ level: 'ERROR', code: 'FILE-MISSING', bucket: 'missing', msg: `missing required file for ${source}: ${file}` });
}
}
if (findings.length === 0 && required.length > 0) {
@ -568,16 +575,32 @@ export function checkRequiredFiles({ present, klass, traits }, register) {
// on a predictable heading is what agents pattern-match on, and `## Non-goals`
// is the cheapest trust-builder there is: it proves someone thought about the
// boundary, and it stops misuse before it starts.
export function checkHeadings({ readme, klass, traits }, register) {
export function checkHeadings({ readme, klass, traits, name }, register) {
const { headings: required } = requirementsFor(klass, traits, register);
const text = String(readme ?? '');
const present = new Set(
text.split('\n').map((l) => l.trim()).filter((l) => l.startsWith('#')),
);
const aliases = register.heading_aliases?.[name] ?? {};
const findings = [];
for (const h of required) {
if ([...present].some((p) => p.toLowerCase() === h.toLowerCase())) continue;
// Same job `titles` does for a README H1, one requirement over: the
// decision (this repo's readers were declared `nb`, so the contract's
// English wording is the wrong test) is taken in the repo, the bookkeeping
// happens here. Keyed per repo, not per locale — two nb-repos need not
// phrase the same section the same way.
const alias = aliases[h];
if (alias && [...present].some((p) => p.toLowerCase() === alias.toLowerCase())) {
findings.push({
level: 'OK',
code: 'HEADING-ALIAS',
msg: `\`${alias}\` satisfies \`${h}\` — the registered heading alias for \`${name}\``,
});
continue;
}
// Same title, wrong depth: say that, rather than "missing". The contract
// wants a predictable top-level heading because that is what an agent
// pattern-matches on — but the section does exist, and the fix is a
@ -701,15 +724,27 @@ export function checkTagIntegrity({ tagObjects, name }, register) {
const findings = [];
const newest = tags[tags.length - 1];
if (!newest.annotated) {
findings.push({
level: 'ERROR',
code: 'TAG-ANNOTATED',
bucket: 'broken',
msg: `newest tag \`${newest.name}\` is lightweight — it can be moved to another commit with no record that it ever pointed elsewhere, and the catalog pins releases by tag. Re-cut it annotated: \`git tag -a -f ${newest.name} ${newest.name}^{}\`.`,
});
}
const accepted = new Set(register?.tags_lightweight_accepted?.[name] ?? []);
if (!newest.annotated) {
if (accepted.has(newest.name)) {
// Named acceptance, not a standing exemption: only THIS exact tag is
// excused, so a later real release still fires ERROR the moment it
// becomes newest and isn't itself on the list (proven by the sibling
// test below).
findings.push({
level: 'OK',
code: 'TAG-ANNOTATED-ACCEPTED-NEWEST',
msg: `newest tag \`${newest.name}\` is lightweight, but the register accepts it by name as an exception to the newest-tag rule — a safe remedy exists (\`git tag -a -f\`) but costs more than the finding for this specific tag. Any OTHER tag that becomes newest is still judged.`,
});
} else {
findings.push({
level: 'ERROR',
code: 'TAG-ANNOTATED',
bucket: 'broken',
msg: `newest tag \`${newest.name}\` is lightweight — it can be moved to another commit with no record that it ever pointed elsewhere, and the catalog pins releases by tag. Re-cut it annotated: \`git tag -a -f ${newest.name} ${newest.name}^{}\`.`,
});
}
}
const olderLightweight = tags.slice(0, -1).filter((t) => !t.annotated);
const older = olderLightweight.filter((t) => !accepted.has(t.name));
const excused = olderLightweight.filter((t) => accepted.has(t.name));
@ -737,6 +772,122 @@ export function checkTagIntegrity({ tagObjects, name }, register) {
return findings;
}
// A signed tag is the one thing that ties a released ref to a person rather
// than to whoever could write to the forge. The catalog pins plugins by tag, so
// this sits on the same supply-chain axis as TAG-ANNOTATED: annotation makes a
// tag immovable-without-a-trace, a signature makes it attributable.
//
// WHY it exists is not the adoption rate. 0 of 158 tags in the org are signed —
// which is exactly what a practice nobody has adopted yet looks like, and
// reasoning from that zero would have the gate ratify the status quo it exists
// to move. The operator's rule (2026-08-13) is the one that decided it: would a
// public AAA+ repository do this? Yes. That is a different question from the one
// that rejected VERSION-DRIFT, where two legitimate conventions genuinely
// coexisted; there is no second convention under which release tags are better
// unattributable.
//
// The measurement decides the SCOPE. Across 20 clones: 158 `v*` tags, 144
// annotated, 14 lightweight, 0 signed, and not one tag dated later than
// 2026-08-12. So the policy is a DATE held in the register: zero findings on the
// day it lands, teeth at each repo's next release. That is how signing is
// adopted in public — never by rewriting published history, which is the same
// unsafe remedy TAG-ANNOTATED refuses to demand.
//
// The date lives in the register and NOT in this file, for the reason the
// org-profile exemption is a flag rather than `if (klass === 'org-profile')`:
// the engine holds no org decision of its own. Absent, the check is a notRun
// SKIP — an operator adding the key turns it into a verdict.
//
// Two exclusions keep one defect from being counted twice under two codes:
// - a LIGHTWEIGHT tag has no tag object to carry a signature, so there is no
// version of it that passes while staying lightweight — TAG-ANNOTATED owns
// it, and the skip names that owner instead of going quiet.
// - a tag cut BEFORE the policy was correct when it was cut.
//
// Read from local git objects, so it costs nothing against the API budget. It
// answers *is it signed* and never *does the forge vouch for it* — that badge
// needs a key registered there, which is a different subject on a different
// surface.
export function checkTagSigned({ tagObjects }, register) {
const from = register?.tags_signed_from;
if (!from) {
return [{
level: 'SKIP',
skip: 'notRun',
code: 'TAG-SIGNED-NOPOLICY',
msg: 'the register records no tag-signing policy (`tags_signed_from`) — whether this org signs its tags is an operator decision, not one the gate makes on its own',
}];
}
const tags = [...(tagObjects ?? [])].sort((a, b) => compareTags(a.name, b.name));
if (tags.length === 0) {
// The TAGS-NONE / VERSION-NONE shape: the check ran, saw every tag there is,
// and found no subject. That is a verdict, not an absent one.
return [{ level: 'OK', code: 'TAG-SIGNED-NONE', msg: 'repo has no version tags — no tag exists that could carry a signature' }];
}
const findings = [];
// ISO dates compare correctly as strings, which also keeps this free of any
// clock reading — the policy boundary is data, never "now".
const inScope = tags.filter((t) => t.annotated && t.date && t.date >= from);
const prePolicy = tags.filter((t) => t.date && t.date < from);
const lightweight = tags.filter((t) => !t.annotated && t.date && t.date >= from);
if (inScope.length > 0) {
const newest = inScope[inScope.length - 1];
if (!newest.signed) {
findings.push({
level: 'ERROR',
code: 'TAG-SIGNED',
bucket: 'weakening',
msg: `newest tag \`${newest.name}\` was cut under the signing policy (from ${from}) but carries no signature — nothing ties it to a signer rather than to whoever could write to the forge, and the catalog pins releases by tag. Re-cut it signed: \`git tag -s -f ${newest.name} ${newest.name}^{}\`.`,
});
}
const older = inScope.slice(0, -1).filter((t) => !t.signed);
if (older.length > 0) {
findings.push({
level: 'WARN',
code: 'TAG-SIGNED-HISTORY',
bucket: 'weakening',
msg: `${older.length} older unsigned tag(s) cut under the policy (${older.slice(0, 3).map((t) => t.name).join(', ')}${older.length > 3 ? ', …' : ''}). WARN, not ERROR: the only remedy is force-moving an already published ref, which is its own risk. Sign every NEW tag (\`git tag -s\`).`,
});
}
}
// An exemption is a finding, not a deletion — the rule `readme_desc_match`
// and TAG-ANNOTATED-ACCEPTED already follow. An exception nobody can see
// reads exactly like a check that silently stopped running.
if (prePolicy.length > 0) {
findings.push({
level: 'OK',
code: 'TAG-SIGNED-PREPOLICY',
msg: `${prePolicy.length} tag(s) predate the signing policy (from ${from}) and are not judged — they were correct when they were cut, and signing them now would mean force-moving published refs. Every tag from ${from} onward is judged.`,
});
}
if (lightweight.length > 0) {
findings.push({
level: 'SKIP',
skip: 'byDesign',
code: 'TAG-SIGNED-LIGHTWEIGHT',
msg: `${lightweight.length} tag(s) cut under the policy are lightweight (${lightweight.map((t) => t.name).join(', ')}) — a lightweight tag has no tag object to carry a signature, so signing is not a remedy it has. TAG-ANNOTATED owns the verdict on these — an ERROR, or an OK if the register names one as an accepted exception; this check declines rather than report one defect twice.`,
});
}
// The affirmative verdict is gated on the JUDGED findings only, never on
// `findings.length`. Gating on the whole list suppressed it the moment a
// pre-policy OK was present — which is every repo in the org, so this OK was
// near-unreachable in practice, and a reader could not tell "signed its new
// tag" from "has cut no tag since the policy". Two different facts, both green.
const judged = findings.some((f) => f.level === 'ERROR' || f.level === 'WARN');
if (!judged) {
findings.push(inScope.length > 0
? { level: 'OK', code: 'TAGS-SIGNED', msg: `all ${inScope.length} tag(s) cut under the policy (from ${from}) are signed` }
: { level: 'OK', code: 'TAGS-SIGNED', msg: `no tag has been cut since the signing policy took effect (${from}) — nothing to judge yet` });
}
return findings;
}
// A static image asserting "tests: 642 passing" is a claim dressed as evidence.
// Version, licence and platform badges assert no run, so they are fine static.
// Bare `status` used to be in this list and caught a self-declared maturity
@ -1233,6 +1384,20 @@ function isFixturePath(path) {
// A home directory is what makes a `file:` URL a leak rather than a scheme the
// gate declines to resolve. Anchored on the two roots a real machine path
// starts with; a bare `file:///abs/path.html` placeholder is not one.
//
// WIDENING TO OTHER ROOTS WAS CONSIDERED AND REJECTED FOR NOW — an order
// (`.claude`, 2026-08-18) asked whether `C:\`, `/private/`, `/var/`, `/tmp/`
// and similar roots deserved the same anchor, having measured only the
// `/Users/ktg` case. Measured here across every `.md` file in the 21 of 22
// registered repos with a local clone (`llm-security-commons` absent):
// exactly two REAL leaks exist in the entire corpus, both `/Users/...` in
// `ki-produktivitetsmodell` (already caught — the positive control that
// proves this grep can find one). Every other `file://` hit is a generic
// placeholder (`/abs/path`, `<abs...>`, `${...}`) that anchoring on Users|home
// already declines to flag. Zero occurrences of any other real root, in this
// corpus, on this date — a RELEASE-ASSETS-shaped rejection: not a low rate to
// veto later, an absent subject. Widen this the day a real one appears, not
// before; re-measure rather than trust this count if this comment goes stale.
const FILE_URL_LEAK = /^file:\/\/\/?(Users|home)\//i;
// Relative file links only. Anchor resolution depends on per-renderer heading
@ -1407,7 +1572,7 @@ export function classifyRepo(
...checkInstallBlock({ readme, name, klass }, register),
...checkInstallTruth({ name, klass, catalogNames }),
...checkInstallPins({ readme, forgeTagsByRepo }, register),
...checkHeadings({ readme, klass, traits }, register),
...checkHeadings({ readme, klass, traits, name }, register),
...checkRequiredFiles({ present, klass, traits }, register),
...checkLinks({ files }, register),
...checkInternalLinks({ files, present }),
@ -1417,6 +1582,7 @@ export function classifyRepo(
...checkBoilerplate({ files }),
...checkVersionConsistency({ pluginVersion, readmeBadge, changelogTop, tags }),
...checkTagIntegrity({ tagObjects, name }, register),
...checkTagSigned({ tagObjects }, register),
...checkReleaseCurrent({ forgeTagsSelf, releases }),
...checkRemoteSync({ tags, forgeTagsSelf }),
...checkVerifyCommand({ readme, testScript, testFileCount }),
@ -1678,13 +1844,21 @@ function forgeTags(register, repo) {
}
}
// The signature is read with `%(if)%(contents:signature)%(then)…`, not by
// printing the block itself: a PGP or SSH signature is multi-line, and emitting
// it would break a line-oriented parser on exactly the tags the check cares
// about. Git answers the yes/no question directly, so one process still covers
// every tag. `%(creatordate:short)` is the tag's own date for an annotated tag
// and the commit's for a lightweight one — ISO, so the policy comparison is a
// string compare with no clock reading anywhere.
function gitTagObjects(dir) {
try {
return execFileSync('git', ['-C', dir, 'for-each-ref', '--format=%(objecttype) %(refname:short)', 'refs/tags/v*'], { encoding: 'utf8' })
const format = '%(objecttype) %(if)%(contents:signature)%(then)signed%(else)unsigned%(end) %(creatordate:short) %(refname:short)';
return execFileSync('git', ['-C', dir, 'for-each-ref', `--format=${format}`, 'refs/tags/v*'], { encoding: 'utf8' })
.split('\n').map((s) => s.trim()).filter(Boolean)
.map((line) => {
const [type, ...rest] = line.split(' ');
return { name: rest.join(' '), annotated: type === 'tag' };
const [type, sig, date, ...rest] = line.split(' ');
return { name: rest.join(' '), annotated: type === 'tag', signed: sig === 'signed', date };
});
} catch {
return [];

View file

@ -7,7 +7,10 @@
// 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 { readFileSync } from 'node:fs';
import { readFileSync, mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
countCodepoints,
normalizeRepoRef,
@ -22,6 +25,7 @@ import {
checkHeadings,
checkBadges,
checkTagIntegrity,
checkTagSigned,
checkInstallPins,
extractInstallPins,
checkReadmeLanguage,
@ -46,6 +50,7 @@ import {
checkVerifyCommand,
countTestFiles,
codeLines,
inspectRepo,
} from './repo-standard-check.mjs';
const REGISTER = {
@ -765,6 +770,27 @@ test('org-profile requires no headings at all', () => {
assert.equal(f.filter((x) => x.level === 'ERROR').length, 0);
});
// The Non-goals contract is written in English and matched literally, which
// cannot be satisfied by a repo whose readers were declared `nb` — the fix is
// per-repo, not per-locale, because two nb-repos need not phrase the same
// section the same way. Same shape as `titles`: the decision is taken in the
// repo, the bookkeeping happens in the register.
test('a registered heading alias satisfies a required heading for a different-language reader', () => {
const aliasRegister = {
...REGISTER,
heading_aliases: { 'ki-produktivitetsmodell': { '## Non-goals': '## Virkeområde og forbehold' } },
};
const readme = '# x\n## Virkeområde og forbehold\n';
const f = checkHeadings({ readme, klass: 'shared-asset', name: 'ki-produktivitetsmodell' }, aliasRegister);
assert.equal(f.some((x) => x.level === 'ERROR'), false);
assert.equal(f.some((x) => x.code === 'HEADING-ALIAS'), true);
// The alias is keyed per-repo: a different repo with the same Norwegian
// heading still misses the literal `## Non-goals`.
const other = checkHeadings({ readme, klass: 'shared-asset', name: 'some-other-repo' }, aliasRegister);
assert.equal(other.some((x) => x.code === 'HEADING-MISSING'), true);
});
// ------------------------------------------------------------- file: URL links
// A `file:///Users/ktg/...` link is dead for every reader but its author, and
@ -810,6 +836,57 @@ test('other schemes stay somebody else\'s to resolve', () => {
assert.equal(checkInternalLinks({ files, present: ['README.md'] }).some((x) => x.code === 'LINK-FILE-URL'), false);
});
// ------------------------------------------------- I/O shell: file loading
//
// Every test above feeds `files` to checkInternalLinks directly — deliberate,
// per the file header: the I/O shell is exercised live, not unit-tested. One
// exception, here: an order (`.claude`, 2026-08-18) diagnosed the file://
// rule as dead because it believed inspectRepo only ever loads README.md —
// wrong (git blame: every tracked .md file, since 2026-07-27, 816ba97) — but
// its point about the TEST SUITE stood: feeding `files` by hand is exactly
// the shortcut that would let every test above stay green while a real
// narrowing of inspectRepo's `.filter((p) => p.endsWith('.md'))` silently
// killed the rule in production. These two go through the real loading path.
function tempGitRepo(files) {
const dir = mkdtempSync(join(tmpdir(), 'repo-standard-io-'));
execFileSync('git', ['init', '-q'], { cwd: dir });
for (const [name, content] of Object.entries(files)) {
const path = join(dir, name);
mkdirSync(join(path, '..'), { recursive: true });
writeFileSync(path, content);
}
execFileSync('git', ['add', '-A'], { cwd: dir });
return dir;
}
test('inspectRepo scans a file: leak in a non-README markdown file through the ordinary loading path', () => {
const dir = tempGitRepo({
'README.md': '# test\n',
'docs/plan.md': '[notes](file:///Users/ktg/repos/x/notes.md)\n',
});
try {
const result = inspectRepo(dir, 'llm-ingestion-pipeline-security', REGISTER, null, null, true);
const hit = result.findings.find((f) => f.code === 'LINK-FILE-URL');
assert.ok(hit, 'LINK-FILE-URL did not fire for a leak outside README.md');
assert.match(hit.msg, /docs\/plan\.md:1/);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('inspectRepo raises nothing when no file: leak exists anywhere', () => {
const dir = tempGitRepo({
'README.md': '# test\n',
'docs/plan.md': 'no links here\n',
});
try {
const result = inspectRepo(dir, 'llm-ingestion-pipeline-security', REGISTER, null, null, true);
assert.equal(result.findings.some((f) => f.code === 'LINK-FILE-URL'), false);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
// -------------------------------------------------------------- tag integrity
// A lightweight tag is a branch-like ref: it can be moved to a different commit
@ -902,15 +979,54 @@ test('an unaccepted lightweight tag still fires, and the WARN counts only the un
});
// The newest tag is what a consumer resolves today and what an operator can
// re-cut at no cost. It is the one lightweight tag with a safe remedy, so it
// is the one that cannot be accepted away.
test('the NEWEST lightweight tag is still an ERROR even when the register accepts its name', () => {
const reg = { tags_lightweight_accepted: { alpha: ['v2.0.0'] } };
const f = checkTagIntegrity({ tagObjects: [{ name: 'v1.0.0', annotated: true }, { name: 'v2.0.0', annotated: false }] }, reg);
// re-cut at no cost, so acceptance is never inherited from a repo merely
// having OTHER accepted entries — only an exact name match on THIS tag excuses
// it (the exception directly below).
test('an unaccepted newest lightweight tag is still an ERROR, even when the register has other entries for this repo', () => {
const reg = { tags_lightweight_accepted: { alpha: ['v9.9.9'] } };
const f = checkTagIntegrity({ tagObjects: [{ name: 'v1.0.0', annotated: true }, { name: 'v2.0.0', annotated: false }], name: 'alpha' }, reg);
const hit = f.find((x) => x.code === 'TAG-ANNOTATED');
assert.equal(hit.level, 'ERROR');
});
// A tag that sorts "newest" by version string without being the newest
// RELEASE — a monorepo-era tag predating a split, consumed by nothing — has a
// real safe remedy (`git tag -a -f`, same commit) that still costs more than
// the finding when nothing resolves it. The register names the tag by EXACT
// NAME, never "this repo's newest is always excused" — caught the moment
// `ktg-plugin-marketplace v7.7.2` was found dead weight: the register had
// accepted it since 2026-08-14 (repos.json `tags_lightweight_accepted`) but
// `accepted` was only ever consulted for tags OLDER than newest, so the
// recorded decision could never take effect. Reported by the catalog
// (coord, 2026-08-17): measured against the real register and real tag data,
// exactly two findings — ERROR TAG-ANNOTATED on the (then-)inert acceptance,
// OK TAG-ANNOTATED-ACCEPTED for the 7 older accepted tags.
test('the newest lightweight tag IS excused when the register names it exactly, with a distinct OK code', () => {
const reg = { tags_lightweight_accepted: { alpha: ['v2.0.0'] } };
const f = checkTagIntegrity({ tagObjects: [{ name: 'v1.0.0', annotated: true }, { name: 'v2.0.0', annotated: false }], name: 'alpha' }, reg);
assert.equal(f.some((x) => x.code === 'TAG-ANNOTATED'), false);
const ok = f.find((x) => x.code === 'TAG-ANNOTATED-ACCEPTED-NEWEST');
assert.equal(ok.level, 'OK');
assert.match(ok.msg, /v2\.0\.0/);
});
// Acceptance names ONE tag, not a standing exemption for "whatever is newest".
// A real new tag cut after the accepted one is still judged the moment it
// becomes newest and isn't itself on the list — exactly what the register's
// own comment promises: "a new lightweight newest tag is not on this list and
// fires ERROR".
test('a NEW lightweight tag cut after an accepted newest is still judged', () => {
const reg = { tags_lightweight_accepted: { alpha: ['v2.0.0'] } };
const f = checkTagIntegrity({ tagObjects: [
{ name: 'v1.0.0', annotated: true },
{ name: 'v2.0.0', annotated: false },
{ name: 'v3.0.0', annotated: false },
], name: 'alpha' }, reg);
const hit = f.find((x) => x.code === 'TAG-ANNOTATED');
assert.equal(hit.level, 'ERROR');
assert.match(hit.msg, /v3\.0\.0/);
});
test('acceptance is per repo — a name accepted for one repo does not excuse another', () => {
const tagObjects = [{ name: 'v0.1.0', annotated: false }, { name: 'v1.0.0', annotated: true }];
const f = checkTagIntegrity({ tagObjects, name: 'beta' }, ACCEPT_REG);
@ -934,6 +1050,177 @@ test('newest is the highest version, not the last element of an unsorted list',
assert.equal(f.some((x) => x.code === 'TAG-ANNOTATED-HISTORY'), true);
});
// ------------------------------------------------------------- tag signing
//
// Built on an operator decision (2026-08-13): the org signs tags FORWARD. The
// decision rule was not the adoption rate — it was "would a public AAA+ repo do
// this", which is a yes. 0 of 158 signed is what you expect of a practice not
// yet adopted, and letting that zero veto the check would make the gate ratify
// the status quo it exists to move.
//
// The measurement decides the SCOPE, not the yes. Across 20 clones: 158 `v*`
// tags, 144 annotated, 14 lightweight, 0 signed, and NOT ONE tag dated after the
// decision. So a policy DATE fires zero findings on day one and acquires teeth
// at each repo's next release — which is how signing is actually adopted, never
// by rewriting published history.
//
// Two exclusions keep this from double-counting a defect that already has an
// owner: a lightweight tag has no tag object to carry a signature at all
// (TAG-ANNOTATED owns it), and a tag predating the policy was correct when it
// was cut.
const SIGN_REG = { tags_signed_from: '2026-08-13' };
test('an unsigned newest tag cut under the policy is an ERROR', () => {
const f = checkTagSigned({ tagObjects: [
{ name: 'v1.0.0', annotated: true, signed: true, date: '2026-08-14' },
{ name: 'v1.1.0', annotated: true, signed: false, date: '2026-08-15' },
] }, SIGN_REG);
const hit = f.find((x) => x.code === 'TAG-SIGNED');
assert.equal(hit.level, 'ERROR');
assert.equal(hit.bucket, 'weakening');
assert.match(hit.msg, /v1\.1\.0/);
});
test('all in-scope tags signed is an OK', () => {
const f = checkTagSigned({ tagObjects: [
{ name: 'v1.0.0', annotated: true, signed: true, date: '2026-08-14' },
{ name: 'v1.1.0', annotated: true, signed: true, date: '2026-08-15' },
] }, SIGN_REG);
assert.equal(f.some((x) => x.level === 'ERROR' || x.level === 'WARN'), false);
assert.equal(f.some((x) => x.code === 'TAGS-SIGNED'), true);
});
// The same split TAG-ANNOTATED makes, for the same reason: the newest can be
// re-cut, the older ones can only be "fixed" by force-moving a published ref.
test('older unsigned in-scope tags are ONE aggregated WARN, never one per tag', () => {
const f = checkTagSigned({ tagObjects: [
{ name: 'v1.0.0', annotated: true, signed: false, date: '2026-08-14' },
{ name: 'v1.1.0', annotated: true, signed: false, date: '2026-08-15' },
{ name: 'v1.2.0', annotated: true, signed: true, date: '2026-08-16' },
] }, SIGN_REG);
const warns = f.filter((x) => x.code === 'TAG-SIGNED-HISTORY');
assert.equal(warns.length, 1);
assert.equal(warns[0].level, 'WARN');
assert.match(warns[0].msg, /2/);
assert.equal(f.some((x) => x.level === 'ERROR'), false);
});
// This is the test that keeps the check from failing 20 correct repositories on
// the day the policy lands. Every tag in the corpus predates it.
test('tags predating the policy are never judged — and say so as an OK, not silence', () => {
const f = checkTagSigned({ tagObjects: [
{ name: 'v0.9.0', annotated: true, signed: false, date: '2026-08-09' },
{ name: 'v1.0.0', annotated: true, signed: false, date: '2026-08-12' },
] }, SIGN_REG);
assert.equal(f.some((x) => x.level === 'ERROR' || x.level === 'WARN'), false);
const ok = f.find((x) => x.code === 'TAG-SIGNED-PREPOLICY');
assert.equal(ok.level, 'OK');
assert.match(ok.msg, /2/);
assert.match(ok.msg, /2026-08-13/);
});
// Caught by dogfooding the v0.11.0 release: the affirmative verdict was
// suppressed whenever ANY pre-policy tag existed — which is every repo in the
// org, so `TAGS-SIGNED` would have been near-unreachable in practice. The
// reader could then not tell "this repo signed its new tag" from "this repo has
// cut no tag since the policy". Both are OK-status, and they are not the same
// fact. The exemption OK must not silence the judged one.
test('the affirmative OK survives alongside the pre-policy OK — they are two different facts', () => {
const f = checkTagSigned({ tagObjects: [
{ name: 'v0.9.0', annotated: true, signed: false, date: '2026-08-09' },
{ name: 'v1.0.0', annotated: true, signed: true, date: '2026-08-14' },
] }, SIGN_REG);
assert.equal(f.some((x) => x.level === 'ERROR' || x.level === 'WARN'), false);
assert.equal(f.some((x) => x.code === 'TAG-SIGNED-PREPOLICY'), true);
const ok = f.find((x) => x.code === 'TAGS-SIGNED');
assert.equal(ok.level, 'OK');
assert.match(ok.msg, /\b1 tag/);
});
// The other half of the same distinction: nothing cut since the policy is a
// DIFFERENT sentence from "what was cut is signed", and it must not borrow the
// affirmative one's wording.
test('a repo with only pre-policy tags says nothing has been cut yet, not that anything passed', () => {
const f = checkTagSigned({ tagObjects: [
{ name: 'v0.9.0', annotated: true, signed: false, date: '2026-08-09' },
] }, SIGN_REG);
const ok = f.find((x) => x.code === 'TAGS-SIGNED');
assert.match(ok.msg, /no tag has been cut/);
});
test('a tag cut exactly ON the policy date is in scope — the policy starts that day', () => {
const f = checkTagSigned({ tagObjects: [
{ name: 'v1.0.0', annotated: true, signed: false, date: '2026-08-13' },
] }, SIGN_REG);
assert.equal(f.find((x) => x.code === 'TAG-SIGNED').level, 'ERROR');
});
// A lightweight tag has no tag object, so it cannot carry a signature — there
// is no version of it that passes this check while staying lightweight. Firing
// here would report ONE defect twice under two codes; TAG-ANNOTATED already
// owns it, and the skip names that owner rather than going quiet.
test('a lightweight tag is a byDesign SKIP that names TAG-ANNOTATED as the owner', () => {
const f = checkTagSigned({ tagObjects: [
{ name: 'v1.0.0', annotated: false, signed: false, date: '2026-08-14' },
] }, SIGN_REG);
assert.equal(f.some((x) => x.level === 'ERROR' || x.level === 'WARN'), false);
const skip = f.find((x) => x.code === 'TAG-SIGNED-LIGHTWEIGHT');
assert.equal(skip.level, 'SKIP');
assert.equal(skip.skip, 'byDesign');
assert.match(skip.msg, /TAG-ANNOTATED/);
});
// The engine must hold no org decision of its own — the same rule that keeps
// the org-profile exemption a register flag rather than `if (klass === …)`.
// notRun, not byDesign: an operator adding the key turns this into a verdict.
test('no policy in the register is a notRun SKIP — the engine never assumes the org signs', () => {
const f = checkTagSigned({ tagObjects: [
{ name: 'v1.0.0', annotated: true, signed: false, date: '2026-08-14' },
] }, {});
assert.equal(f.length, 1);
assert.equal(f[0].level, 'SKIP');
assert.equal(f[0].skip, 'notRun');
assert.equal(f[0].code, 'TAG-SIGNED-NOPOLICY');
});
test('a repo with no tags has nothing to judge — OK, not SKIP', () => {
const f = checkTagSigned({ tagObjects: [] }, SIGN_REG);
assert.equal(f.length, 1);
assert.equal(f[0].level, 'OK');
assert.equal(f[0].code, 'TAG-SIGNED-NONE');
});
// An undatable tag cannot be PROVEN to fall under the policy, and a gate that
// fails a correct repository on a missing field is the mechanism that gets
// gates switched off. Absence falls to the safe side here — unlike an untagged
// SKIP, where the loud side is the safe one.
test('a tag with no readable date is out of scope, never assumed post-policy', () => {
const f = checkTagSigned({ tagObjects: [
{ name: 'v1.0.0', annotated: true, signed: false },
] }, SIGN_REG);
assert.equal(f.some((x) => x.level === 'ERROR' || x.level === 'WARN'), false);
});
// CLAUDE.md fixes this scope in advance: `git cat-file tag` reads the signature
// block from the clone, so the check answers *is it signed* and must never
// claim the forge vouches for it — that needs a key registered there.
test('no finding claims the forge verified anything', () => {
const f = checkTagSigned({ tagObjects: [
{ name: 'v1.0.0', annotated: true, signed: false, date: '2026-08-14' },
{ name: 'v1.1.0', annotated: true, signed: false, date: '2026-08-15' },
] }, SIGN_REG);
for (const x of f) assert.equal(/verified|vouch|trusted|valid signature/i.test(x.msg), false);
});
test('newest in scope is the highest version, not the last element of an unsorted list', () => {
const f = checkTagSigned({ tagObjects: [
{ name: 'v10.0.0', annotated: true, signed: true, date: '2026-08-14' },
{ name: 'v9.0.0', annotated: true, signed: false, date: '2026-08-15' },
] }, SIGN_REG);
assert.equal(f.some((x) => x.code === 'TAG-SIGNED'), false);
assert.equal(f.some((x) => x.code === 'TAG-SIGNED-HISTORY'), true);
});
// ---------------------------------------------------------------- dead pins
//
// Reported by org-ops (census 08) and re-measured here against the FORGE, not
@ -1308,6 +1595,19 @@ test('a repo without the security trait owes no SECURITY.md', () => {
assert.equal(f.some((x) => x.msg.includes('SECURITY.md')), false);
});
test('a trait-required file is missing, FILE-MISSING names the trait, not the class', () => {
const f = checkRequiredFiles({ present: ['README.md', 'LICENSE'], klass: 'standalone', traits: ['security'] }, REGISTER);
const finding = f.find((x) => x.code === 'FILE-MISSING' && x.msg.includes('SECURITY.md'));
assert.equal(finding.msg.includes('trait `security`'), true);
assert.equal(finding.msg.includes('class `standalone`'), false);
});
test('a class-required file is missing, FILE-MISSING still names the class', () => {
const f = checkRequiredFiles({ present: [], klass: 'standalone' }, REGISTER);
const finding = f.find((x) => x.code === 'FILE-MISSING' && x.msg.includes('LICENSE'));
assert.equal(finding.msg.includes('class `standalone`'), true);
});
test('the security trait requires limitations to be stated', () => {
const f = checkHeadings({ readme: '# x\n## Install\n## Non-goals\n', klass: 'standalone', traits: ['security'] }, REGISTER);
assert.equal(f.some((x) => x.code === 'HEADING-MISSING' && x.msg.includes('Known limitations')), true);

View file

@ -26,9 +26,11 @@ the script cannot encode.
node "${CLAUDE_PLUGIN_ROOT}/scripts/repo-standard-check.mjs" --dir "$PWD"
Findings are `ERROR` (blocks), `WARN` (look, then decide), `SKIP` (not judged —
in one of two ways, see below), `OK`. Exit 1 on any ERROR. Add `--offline` to skip the two
network calls, `--json` for machine output, `--refresh` to compare the bundled
register against the live org listing.
in one of two ways, see below), `OK`. Exit 1 on any ERROR. Add `--offline` to skip the three
API calls — the org listing, the catalog manifest, and the repo's releases —
`--json` for machine output, `--refresh` to compare the bundled register against
the live org listing. Refs are read over the git protocol instead and cost
nothing against that budget.
**Never report a `SKIP` as a pass.** A SKIP means the gate did not judge the
thing. Say which, and say which KIND — since 0.8.0 there are two, and they are
@ -45,7 +47,8 @@ not interchangeable:
The inverse error is reporting an `OK` as an un-run check. A check that ran and
found **no subject** is a verdict, not a skip: `VERSION-NONE` ("no version
claimed anywhere") and `LANGUAGE` ("no running prose to judge") are `OK` and
claimed anywhere"), `VERIFY-NONE` ("nothing runnable, so no verification command
is owed") and `LANGUAGE` ("no running prose to judge") are `OK` and
carry no action. `VERSION-NONE` was `VERSION-UNAVAILABLE`/`notRun` before 0.9.0
— if you see the old code, the engine is stale; see the version note below.
@ -156,6 +159,53 @@ command that runs the whole suite from a clean clone, stated in the README along
with the fact that nothing runs it automatically. Verifiability is the goal; CI
is one mechanism for it, and it is not available here.
Since 0.10.0 that substitute is measured rather than assumed. `VERIFY-MISSING`
(`WARN`) means the repository has something runnable — a `scripts.test` entry, or
tracked executable test files — and its README shows no command in a code block.
Fixing it has exactly one honest move: **document the command that already
exists, after running it yourself.** Writing `npm test` into a README because
the finding mentioned it, without a `scripts.test` to back it, converts a `WARN`
about missing documentation into a false claim on the first screen — a worse
defect than the one you were sent to fix, and one this gate cannot catch,
because it never runs anything. A green `VERIFY-COMMAND` means *documented*,
never *passing*.
`VERIFY-NONE` needs no action at all. A repository of prose, output styles or
domain packs owes no command, and adding a hollow test script to clear a finding
that was never against it is the anti-pattern in its purest form.
## Tag signing — forward only, and never by rewriting history
Since 0.11.0 the gate judges whether a release tag is signed. The org's policy
date lives in the register (`tags_signed_from`); tags cut before it are reported
as `TAG-SIGNED-PREPOLICY` (`OK`) and are **not** findings. If the key is absent
the check is a `notRun` SKIP — the gate never assumes a policy nobody recorded.
`TAG-SIGNED` (`ERROR`) is the newest tag cut under the policy, unsigned. The
remedy is to re-cut that one tag signed. `TAG-SIGNED-HISTORY` (`WARN`) is a
count of older ones, and it is a `WARN` precisely because its only remedy is
force-moving an already published ref — the risk the check exists to name. Do
not clear a WARN by doing the dangerous thing.
Two moves look like fixes and are not:
- **Backdating `tags_signed_from` to "catch up".** Every tag below the date was
correct when it was cut. Backdating manufactures findings whose only remedy is
re-signing published history.
- **Re-signing the whole tag history.** Same act, one step further. The org
adopted signing forward for the same reason public projects do.
`TAG-SIGNED-LIGHTWEIGHT` is a `byDesign` skip, not work. A lightweight tag has
no tag object to hold a signature at all, so signing is not a remedy it has —
`TAG-ANNOTATED` owns that finding, and fixing it there is what puts the tag in
this check's scope.
The check reads the signature from the clone, so a green line means *this tag
carries a signature*. It does **not** mean the forge verified it — the forge's
"Verified" badge needs a key registered there, which is a different subject on a
different surface. Do not upgrade the claim when reporting.
## Diagrams — capability is not justification
Mermaid renders in a README on this forge. That was verified on the live