Compare commits

...

9 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
7 changed files with 313 additions and 21 deletions

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

View file

@ -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 |
@ -180,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

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.11.1",
"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": [
@ -186,11 +200,45 @@
"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": {
"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": [
@ -222,6 +270,15 @@
"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",

View file

@ -575,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
@ -708,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));
@ -842,7 +870,7 @@ export function checkTagSigned({ tagObjects }, register) {
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 that finding; this check declines rather than report one defect twice.`,
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.`,
});
}
@ -1356,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
@ -1530,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 }),

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,
@ -47,6 +50,7 @@ import {
checkVerifyCommand,
countTestFiles,
codeLines,
inspectRepo,
} from './repo-standard-check.mjs';
const REGISTER = {
@ -766,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
@ -811,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
@ -903,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);