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
|
`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
|
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.
|
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
|
- **No hook until the rule is precise.** A blocking gate that fails a correct
|
||||||
repository is the mechanism that gets gates switched off.
|
repository is the mechanism that gets gates switched off.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```bash
|
```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 --dir "$PWD" # gate one repo
|
||||||
node scripts/repo-standard-check.mjs --offline # no network call
|
node scripts/repo-standard-check.mjs --offline # no network call
|
||||||
node scripts/repo-standard-check.mjs --json # machine output
|
node scripts/repo-standard-check.mjs --json # machine output
|
||||||
|
|
|
||||||
|
|
@ -549,6 +549,78 @@ export function checkVersionConsistency({ pluginVersion, readmeBadge, changelogT
|
||||||
return findings;
|
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.
|
// 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.
|
// 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
|
// 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(
|
export function classifyRepo(
|
||||||
{ name, files, present, description, pluginVersion, readmeBadge, changelogTop, tags, catalogNames },
|
{ name, files, present, description, pluginVersion, readmeBadge, changelogTop, tags, tagObjects, catalogNames },
|
||||||
register,
|
register,
|
||||||
) {
|
) {
|
||||||
const klass = register.repos?.[name];
|
const klass = register.repos?.[name];
|
||||||
|
|
@ -1016,6 +1088,7 @@ export function classifyRepo(
|
||||||
...checkReadmeLanguage({ readme, name }, register),
|
...checkReadmeLanguage({ readme, name }, register),
|
||||||
...checkBoilerplate({ files }),
|
...checkBoilerplate({ files }),
|
||||||
...checkVersionConsistency({ pluginVersion, readmeBadge, changelogTop, tags }),
|
...checkVersionConsistency({ pluginVersion, readmeBadge, changelogTop, tags }),
|
||||||
|
...checkTagIntegrity({ tagObjects }),
|
||||||
...checkDescription(description, register),
|
...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) {
|
export function inspectRepo(dir, name, register, description, catalogNames = null) {
|
||||||
const tracked = gitFiles(dir);
|
const tracked = gitFiles(dir);
|
||||||
const present = (tracked ?? []).filter((f) => existsSync(join(dir, f)));
|
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),
|
readmeBadge: extractBadgeVersion(readme),
|
||||||
changelogTop: changelog === null ? null : extractChangelogTop(changelog),
|
changelogTop: changelog === null ? null : extractChangelogTop(changelog),
|
||||||
tags: gitTags(dir),
|
tags: gitTags(dir),
|
||||||
|
tagObjects: gitTagObjects(dir),
|
||||||
catalogNames,
|
catalogNames,
|
||||||
}, register);
|
}, register);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import {
|
||||||
checkVersionConsistency,
|
checkVersionConsistency,
|
||||||
checkHeadings,
|
checkHeadings,
|
||||||
checkBadges,
|
checkBadges,
|
||||||
|
checkTagIntegrity,
|
||||||
checkReadmeLanguage,
|
checkReadmeLanguage,
|
||||||
checkBoilerplate,
|
checkBoilerplate,
|
||||||
checkLicenseClaim,
|
checkLicenseClaim,
|
||||||
|
|
@ -757,6 +758,67 @@ test('org-profile requires no headings at all', () => {
|
||||||
assert.equal(f.filter((x) => x.level === 'ERROR').length, 0);
|
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
|
// ------------------------------------------------------------ badge honesty
|
||||||
|
|
||||||
test('a static badge asserting test or build status is a finding', () => {
|
test('a static badge asserting test or build status is a finding', () => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue