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:
parent
3955b10c4d
commit
ddfc628761
3 changed files with 164 additions and 2 deletions
12
CLAUDE.md
12
CLAUDE.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue