feat(register): a decided YES about tag history, and app-creator registered

Two changes that belong together: both are the register learning to record a
decision the engine could otherwise only re-report forever.

`tags_lightweight_accepted` — TAG-ANNOTATED-HISTORY (WARN) fires on 13
historical lightweight tags in three repos. A lightweight tag is movable
without a trace and the catalog pins plugins by tag, so the finding is real.
But the only remedy for a published one is force-moving it — the exact act the
check warns about — so the WARN could never be cleared. That is the `titles`
defect one axis over: the gate could not tell "we decided this" from "nobody
looked".

Keyed on tag NAME, never a count: a count stays satisfied the moment one tag is
re-cut and a different, unaccepted one takes its place.

An accepted tag emits `TAG-ANNOTATED-ACCEPTED` at OK naming the tags — it is
not dropped. An exemption is a finding, the rule `readme_desc_match` already
follows; an exception nobody can see reads exactly like a check that silently
stopped running.

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, and an ERROR today. Pre-accepting it would
mean cutting v7.8.0 instead of fixing it makes the finding vanish silently.
The engine enforces this independently — the newest tag cannot be accepted
away even if named, and a test pins that.

`app-creator` → `standalone` — `--refresh` measured 22 on the forge against 21
registered. Unregistered meant zero checks against a repo published on `open/`.
The class is derived, not guessed: no `.claude-plugin/plugin.json`, absent from
the catalog, own remote on `open/` — identical in form to the four existing
`standalone`.

Measured before and after across all 21 clones, every finding code diffed:
- TAG-ANNOTATED-HISTORY 3 WARN -> 0, converted 1:1 to 3 OK. None disappeared.
- TAG-ANNOTATED unchanged at 1 ERROR (ktg-plugin-marketplace v7.7.2).
- app-creator: 1 SKIP -> 16 judged findings (3 ERROR, 2 WARN). Recorded, not
  fixed — it records, it does not fix.
- Nothing else moved.
- `--refresh`: 22/22, no divergence.

The accepted lists are claims about three OTHER repos, measured from LOCAL
clones — which is the gap REMOTE-SYNC exists to name. They go to those repos by
coord so a wrong name can be disputed.

187 tests (was 182).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015AkHEqTSr1k3HbeiHu1ggW
This commit is contained in:
Kjell Tore Guttormsen 2026-08-12 22:24:03 +02:00
commit fe0d039de2
3 changed files with 119 additions and 4 deletions

View file

@ -36,7 +36,8 @@
"portfolio-optimiser": "standalone",
"portfolio-optimiser-claude": "standalone",
"llm-ingestion-pipeline-security": "standalone",
"llm-ingestion-okf": "standalone"
"llm-ingestion-okf": "standalone",
"app-creator": "standalone"
},
"$comment_non_repos": [
@ -185,6 +186,34 @@
"okr": "nb"
},
"$comment_tags_lightweight_accepted": [
"Where a decided YES about lightweight tag HISTORY gets written down — the",
"same job `titles` does for a README H1, one axis over. A lightweight tag is",
"movable without a trace and the catalog pins plugins by tag, so it is a",
"supply-chain property; but the only remedy for a PUBLISHED one is force-",
"moving it, which is the act the check warns about. Without this list the",
"WARN can never be cleared, and the gate says the same thing forever.",
"",
"Keyed on tag NAME, never on a count. A count stays satisfied the moment one",
"tag is re-cut and a different, unaccepted one takes its place.",
"",
"MEASURED 2026-08-12 from the LOCAL clones, not from the forge — which is",
"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."
],
"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"],
"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"]
},
"description_max_codepoints": 180,
"$comment_length": [
"180 codepoints, not bytes and not UTF-16 units. The same string measures 248",

View file

@ -587,7 +587,17 @@ function compareTags(a, b) {
//
// Read entirely from local git objects: `git for-each-ref` reports the object
// type with no network call, so this costs nothing against the two-call budget.
export function checkTagIntegrity({ tagObjects }) {
//
// `tags_lightweight_accepted` is where a decided YES about tag HISTORY lives.
// Without it the WARN below can never be cleared — the only remedy is force-
// moving a published ref, the act the check exists to warn about — so the gate
// would report the same thing forever and make "we decided this" and "nobody
// looked" the same output. That is the `titles` defect, one axis over.
// Keyed on tag NAME, never a count: a count stays satisfied the moment one tag
// is re-cut and a different, unaccepted one takes its place.
// Acceptance reaches history ONLY. The newest tag is the one lightweight tag
// with a safe remedy (`git tag -a -f`), so it cannot be accepted away.
export function checkTagIntegrity({ tagObjects, name }, register) {
const tags = [...(tagObjects ?? [])].sort((a, b) => compareTags(a.name, b.name));
if (tags.length === 0) {
// The VERSION-NONE shape: the check ran, saw every tag there is, and found
@ -606,7 +616,10 @@ export function checkTagIntegrity({ tagObjects }) {
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 older = tags.slice(0, -1).filter((t) => !t.annotated);
const accepted = new Set(register?.tags_lightweight_accepted?.[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));
if (older.length > 0) {
findings.push({
level: 'WARN',
@ -615,6 +628,16 @@ export function checkTagIntegrity({ tagObjects }) {
msg: `${older.length} older lightweight tag(s) (${older.slice(0, 3).map((t) => t.name).join(', ')}${older.length > 3 ? ', …' : ''}) — each is movable without a trace. WARN, not ERROR: the only remedy is force-moving an already published ref, which is the risk itself. Cut every NEW tag annotated (\`git tag -a\`).`,
});
}
// An exemption is a finding, not a deletion — the same rule `readme_desc_match`
// follows. An exception nobody can see reads exactly like a check that
// silently stopped running.
if (excused.length > 0) {
findings.push({
level: 'OK',
code: 'TAG-ANNOTATED-ACCEPTED',
msg: `${excused.length} older lightweight tag(s) (${excused.map((t) => t.name).join(', ')}) are recorded in the register as accepted history — force-moving a published ref is the only remedy, so the operator accepted them rather than rewrite them. Any NEW lightweight tag, and the newest tag, are still judged.`,
});
}
if (findings.length === 0) {
findings.push({ level: 'OK', code: 'TAGS', msg: `all ${tags.length} version tag(s) are annotated — none can be moved without a record` });
}
@ -1112,7 +1135,7 @@ export function classifyRepo(
...checkReadmeLanguage({ readme, name }, register),
...checkBoilerplate({ files }),
...checkVersionConsistency({ pluginVersion, readmeBadge, changelogTop, tags }),
...checkTagIntegrity({ tagObjects }),
...checkTagIntegrity({ tagObjects, name }, register),
...checkDescription(description, register),
];

View file

@ -854,6 +854,69 @@ test('a repo with no tags has nothing to judge — OK, not SKIP', () => {
assert.equal(f[0].code, 'TAGS-NONE');
});
// ---------------------------------------- accepted lightweight tag history
//
// The WARN above can never be cleared: the only remedy is force-moving an
// already published ref, which is the act the check warns about. Left there,
// it is the `titles` defect again — the gate cannot tell "we decided this" from
// "nobody has looked". The register records the decision, keyed on TAG NAME
// (a count goes silently wrong the moment one tag is re-cut and another takes
// its place).
const ACCEPT_REG = { tags_lightweight_accepted: { alpha: ['v0.1.0', 'v0.2.0'] } };
test('an accepted lightweight tag emits an OK naming it — an exemption is a finding, not a deletion', () => {
const tagObjects = [
{ name: 'v0.1.0', annotated: false },
{ name: 'v0.2.0', annotated: false },
{ name: 'v1.0.0', annotated: true },
];
const f = checkTagIntegrity({ tagObjects, name: 'alpha' }, ACCEPT_REG);
assert.equal(f.some((x) => x.code === 'TAG-ANNOTATED-HISTORY'), false);
const ok = f.find((x) => x.code === 'TAG-ANNOTATED-ACCEPTED');
assert.equal(ok.level, 'OK');
assert.match(ok.msg, /v0\.1\.0/);
assert.match(ok.msg, /v0\.2\.0/);
});
test('an unaccepted lightweight tag still fires, and the WARN counts only the unaccepted', () => {
const tagObjects = [
{ name: 'v0.1.0', annotated: false }, // accepted
{ name: 'v0.2.0', annotated: false }, // accepted
{ name: 'v0.3.0', annotated: false }, // NOT accepted
{ name: 'v1.0.0', annotated: true },
];
const f = checkTagIntegrity({ tagObjects, name: 'alpha' }, ACCEPT_REG);
const warn = f.find((x) => x.code === 'TAG-ANNOTATED-HISTORY');
assert.equal(warn.level, 'WARN');
assert.match(warn.msg, /v0\.3\.0/);
assert.equal(/v0\.1\.0/.test(warn.msg), false);
assert.match(warn.msg, /\b1 older/);
assert.equal(f.some((x) => x.code === 'TAG-ANNOTATED-ACCEPTED'), true);
});
// 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);
const hit = f.find((x) => x.code === 'TAG-ANNOTATED');
assert.equal(hit.level, 'ERROR');
});
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);
assert.equal(f.some((x) => x.code === 'TAG-ANNOTATED-HISTORY'), true);
assert.equal(f.some((x) => x.code === 'TAG-ANNOTATED-ACCEPTED'), false);
});
test('no register entry leaves the behaviour exactly as it was', () => {
const tagObjects = [{ name: 'v0.1.0', annotated: false }, { name: 'v1.0.0', annotated: true }];
const f = checkTagIntegrity({ tagObjects, name: 'alpha' }, {});
assert.equal(f.some((x) => x.code === 'TAG-ANNOTATED-HISTORY'), true);
});
// The newest tag is decided by version order, not by the order git happened to
// hand them over. `git tag --list` sorts lexically, where v10.0.0 sorts BEFORE
// v9.0.0 — reading "newest" off an unsorted list would judge the wrong tag on