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

@ -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),
];