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:
parent
fa0cfe07d5
commit
fe0d039de2
3 changed files with 119 additions and 4 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue