feat(engine): TAG-ANNOTATED — a movable tag is a movable pin

First of the approved §5 checks. A lightweight tag is a branch-like ref:
it can be moved to another commit with nothing recorded that it ever
pointed elsewhere. The catalog pins every plugin to `ref: vX.Y.Z`, so
this is a supply-chain property, not tidiness.

The two levels come from a measurement, not from taste. Across all 19
clones: 155 tags, 14 lightweight, but only ONE repo whose NEWEST tag is
lightweight. The newest is what a consumer resolves today and what an
operator can re-cut at no cost -> ERROR. The older ones can only be
"fixed" by force-moving an already published ref, which is the exact risk
the check exists to name -> exposed once as a count, WARN, never as
fourteen findings. A gate that demands an unsafe remedy gets switched off.

No tags at all is the VERSION-NONE shape: the check ran, saw every tag
there is, and found no subject. TAGS-NONE is an OK, not a skip.

Newest is decided by version order, not by the order git returns.
`git tag --list` sorts lexically, where v10.0.0 lands before v9.0.0 —
which would misjudge exactly the repos with the longest history
(repo-mailbox has 27 tags). Pinned in test.

Read from local git objects via `for-each-ref %(objecttype)` — zero
network, so the two-call budget is untouched.

Measured on the corpus, and it matches the census exactly: 1 ERROR
(ktg-plugin-marketplace v7.7.2), 3 WARN (catalog 7, okf 5, guard 1),
15 OK, 2 TAGS-NONE. No other repo moved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lb7XmJGLnFSX9U7tgS7fKk
This commit is contained in:
Kjell Tore Guttormsen 2026-08-12 21:24:09 +02:00
commit ddfc628761
3 changed files with 164 additions and 2 deletions

View file

@ -158,13 +158,23 @@ would recreate, in data, exactly the drift this plugin exists to remove.
`0.5.0`. `engineCommit` closes that, derived from the same checkout with no
network call. It is present-and-`null` when underivable, never absent — an
absent key means an older engine, `null` means this one ran without a HEAD.
- **A finding must name a remedy the operator can safely perform.** A
lightweight tag is movable without a trace, and the catalog pins plugins by
tag — so it is a supply-chain property, not tidiness. But the levels come
from a measurement: 155 tags across 19 clones, 14 lightweight, and only ONE
repo whose *newest* tag is lightweight. The newest can be re-cut at no cost
(`ERROR`); the older ones can only be "fixed" by force-moving an already
published ref — the very act the check warns about — so they are exposed
once, as a count (`WARN`), never as fourteen findings. Left unrecorded, that
`WARN` can never be cleared, which is the `titles` problem again; no
acceptance record is built until a repo actually needs one.
- **No hook until the rule is precise.** A blocking gate that fails a correct
repository is the mechanism that gets gates switched off.
## Commands
```bash
npm test # 170 tests
npm test # 177 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

@ -549,6 +549,78 @@ export function checkVersionConsistency({ pluginVersion, readmeBadge, changelogT
return findings;
}
// Version order, not the order git handed the tags over. `git tag --list` sorts
// lexically, where v10.0.0 lands BEFORE v9.0.0 — so reading "newest" off an
// unsorted list picks the wrong tag on precisely the repos with the longest
// release history (repo-mailbox has 27). Numeric triple first; a pre-release
// suffix sorts BELOW the bare release, as semver has it, which is what keeps
// `v0.5.0a2` from outranking `v0.5.0`.
function compareTags(a, b) {
const parts = (s) => {
const m = /^v?(\d+)\.(\d+)\.(\d+)(.*)$/.exec(String(s));
return m ? [Number(m[1]), Number(m[2]), Number(m[3]), m[4]] : [0, 0, 0, String(s)];
};
const [aM, aN, aP, aRest] = parts(a);
const [bM, bN, bP, bRest] = parts(b);
if (aM !== bM) return aM - bM;
if (aN !== bN) return aN - bN;
if (aP !== bP) return aP - bP;
if (aRest === bRest) return 0;
if (aRest === '') return 1;
if (bRest === '') return -1;
return aRest < bRest ? -1 : 1;
}
// A lightweight tag is a branch-like ref: it can be moved to another commit
// with nothing recorded that it ever pointed elsewhere. The catalog pins every
// plugin to `ref: vX.Y.Z`, so a movable tag is a movable pin — this is a supply
// chain property, not a tidiness one.
//
// The two levels come from a measurement, not from taste. Across all 19 clones:
// 155 tags, 14 of them lightweight, but only ONE repo whose NEWEST tag is
// lightweight. The newest is what a consumer resolves today and what an
// operator can re-cut at no cost, so it is an ERROR. The older ones can only be
// "fixed" by force-moving an already published ref — the exact act this check
// exists to warn about — so they are exposed once, as a count, and never as
// fourteen separate findings. A gate that demands an unsafe remedy is a gate
// that gets switched off.
//
// 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 }) {
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
// no subject. A repo with no tags has no ref that could be moved — there is
// nothing here to be wrong, which is a verdict, not an absent one.
return [{ level: 'OK', code: 'TAGS-NONE', msg: 'repo has no version tags — no tag exists that could be moved' }];
}
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 older = tags.slice(0, -1).filter((t) => !t.annotated);
if (older.length > 0) {
findings.push({
level: 'WARN',
code: 'TAG-ANNOTATED-HISTORY',
bucket: 'weakening',
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\`).`,
});
}
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` });
}
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
@ -979,7 +1051,7 @@ export function bucketsOf(findings) {
}
export function classifyRepo(
{ name, files, present, description, pluginVersion, readmeBadge, changelogTop, tags, catalogNames },
{ name, files, present, description, pluginVersion, readmeBadge, changelogTop, tags, tagObjects, catalogNames },
register,
) {
const klass = register.repos?.[name];
@ -1016,6 +1088,7 @@ export function classifyRepo(
...checkReadmeLanguage({ readme, name }, register),
...checkBoilerplate({ files }),
...checkVersionConsistency({ pluginVersion, readmeBadge, changelogTop, tags }),
...checkTagIntegrity({ tagObjects }),
...checkDescription(description, register),
];
@ -1193,6 +1266,22 @@ function gitTags(dir) {
}
}
// `%(objecttype)` is `tag` for an annotated tag and `commit` for a lightweight
// one — the distinction read straight off the local object database, with no
// network call, so tag integrity costs nothing against the two-call budget.
function gitTagObjects(dir) {
try {
return execFileSync('git', ['-C', dir, 'for-each-ref', '--format=%(objecttype) %(refname:short)', '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' };
});
} catch {
return [];
}
}
export function inspectRepo(dir, name, register, description, catalogNames = null) {
const tracked = gitFiles(dir);
const present = (tracked ?? []).filter((f) => existsSync(join(dir, f)));
@ -1220,6 +1309,7 @@ export function inspectRepo(dir, name, register, description, catalogNames = nul
readmeBadge: extractBadgeVersion(readme),
changelogTop: changelog === null ? null : extractChangelogTop(changelog),
tags: gitTags(dir),
tagObjects: gitTagObjects(dir),
catalogNames,
}, register);
}

View file

@ -21,6 +21,7 @@ import {
checkVersionConsistency,
checkHeadings,
checkBadges,
checkTagIntegrity,
checkReadmeLanguage,
checkBoilerplate,
checkLicenseClaim,
@ -757,6 +758,67 @@ test('org-profile requires no headings at all', () => {
assert.equal(f.filter((x) => x.level === 'ERROR').length, 0);
});
// -------------------------------------------------------------- tag integrity
// A lightweight tag is a branch-like ref: it can be moved to a different commit
// with no record that it ever pointed elsewhere. The catalog pins every plugin
// to `ref: vX.Y.Z`, so a movable tag is a movable pin.
//
// Measured across all 19 clones before writing the rule: 155 tags, of which 14
// are lightweight — 8 in catalog, 5 in okf, 1 in guard. Exactly ONE repo has a
// lightweight NEWEST tag. That split is the rule: the newest tag is what a
// consumer resolves today and what an operator can re-cut, so it is an ERROR;
// the older ones can only be "fixed" by force-moving a published ref, which is
// the very risk this check exists to name, so they are exposed as one WARN and
// never as fourteen.
test('a lightweight newest tag is an ERROR — a movable tag is a movable pin', () => {
const f = checkTagIntegrity({ tagObjects: [{ name: 'v1.0.0', annotated: true }, { name: 'v1.1.0', annotated: false }] });
const hit = f.find((x) => x.code === 'TAG-ANNOTATED');
assert.equal(hit.level, 'ERROR');
assert.equal(hit.bucket, 'broken');
assert.match(hit.msg, /v1\.1\.0/);
});
test('all-annotated tags are OK', () => {
const f = checkTagIntegrity({ tagObjects: [{ name: 'v1.0.0', annotated: true }, { name: 'v1.1.0', annotated: true }] });
assert.equal(f.some((x) => x.level === 'ERROR' || x.level === 'WARN'), false);
assert.equal(f.some((x) => x.code === 'TAGS'), true);
});
test('older lightweight tags are ONE aggregated WARN, never one finding per tag', () => {
const tagObjects = [
{ name: 'v0.1.0', annotated: false },
{ name: 'v0.2.0', annotated: false },
{ name: 'v0.3.0', annotated: false },
{ name: 'v1.0.0', annotated: true },
];
const f = checkTagIntegrity({ tagObjects });
const warns = f.filter((x) => x.code === 'TAG-ANNOTATED-HISTORY');
assert.equal(warns.length, 1);
assert.equal(warns[0].level, 'WARN');
assert.match(warns[0].msg, /3/);
assert.equal(f.some((x) => x.level === 'ERROR'), false);
});
// Same shape as VERSION-NONE: the check ran, saw every tag there is, and found
// no subject. Nothing here can be wrong, so it is a verdict — not a skip.
test('a repo with no tags has nothing to judge — OK, not SKIP', () => {
const f = checkTagIntegrity({ tagObjects: [] });
assert.equal(f.length, 1);
assert.equal(f[0].level, 'OK');
assert.equal(f[0].code, 'TAGS-NONE');
});
// 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
// exactly the repos with the longest release history.
test('newest is the highest version, not the last element of an unsorted list', () => {
const f = checkTagIntegrity({ tagObjects: [{ name: 'v10.0.0', annotated: true }, { name: 'v9.0.0', annotated: false }] });
assert.equal(f.some((x) => x.code === 'TAG-ANNOTATED'), false);
assert.equal(f.some((x) => x.code === 'TAG-ANNOTATED-HISTORY'), true);
});
// ------------------------------------------------------------ badge honesty
test('a static badge asserting test or build status is a finding', () => {