Skip to content

Commit e6a7e26

Browse files
chore(ci): ADR-0087 台账存量一次性回补审计口 --audit-stock (#6350) (#6500)
#6148 的门禁是**前向**的(#6129 的理由,本单不推翻它),代价是 v17 列车 已有的 238 条 breaking changeset 一条都没被同一套逻辑看过。本次给同一个 脚本加一个**算子调用**的一次性审计口,把门禁**自己的**判定函数 (breakingDeclaration / readDisposition / findMigrationPrescription / workspacePackagesAt)对着存量跑一遍 —— 没有第二份逻辑,只有换一批被判对象。 刻意不接 CI:`package.json` 的 `check:adr-0087-registration` 与 Check Changeset 工作流都是不带旗标地调门禁,`--audit-stock` 只能手敲。它是 `--list` 的邻居,不是第二道门。 分桶把 238 收窄成人可核的残差:answered / exempt-unpublished / exempt-no-prescription / RESIDUE(已发布的破坏 + 正文带改写指令 ⇒ 只剩 registered / already-registered,两者都要台账条目)。残差再叠一条机械 事实:动过这条 changeset 的 commit 里有没有哪个同时动了台账源。 浅克隆下该信号会**说谎**(嫁接 commit 携带整棵树、含两个台账源,实测 104 commit 的默认检出把 92 条残差里的 91 条报成「作者登记过」,条条错), 因此浅克隆时**抑制**而不是近似 —— 按 #4690 的口径,报不出来就说报不出来。 self-test 加 S1-S6 钉住分类器四个臂与其间的优先级;反向验证:删掉 `bumps.length > 0` 守卫后恰好 S5 转红(`[].every()` 真空为真)。 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3a1d9c7 commit e6a7e26

1 file changed

Lines changed: 228 additions & 0 deletions

File tree

scripts/check-adr-0087-registration.mjs

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -986,6 +986,194 @@ function list(cwd, head) {
986986
console.log('Every one of the above is EXEMPT for any PR that does not touch it -- this gate judges diffs, not stock.');
987987
}
988988

989+
// ---------------------------------------------------------------------------
990+
// `--audit-stock` -- the ONE-OFF backfill audit (#6350)
991+
//
992+
// ## What this is, and what it is deliberately NOT
993+
//
994+
// The gate above is FORWARD-ONLY: it judges the PR's own diff, for the #6129
995+
// reason its sibling `check-empty-changeset.mjs` documents at length. That
996+
// direction is correct and this mode does not overturn it -- judging the stock on
997+
// every PR would turn the whole repo red on adoption day and would hold the
998+
// current author answerable for changesets that landed on main months before their
999+
// branch existed.
1000+
//
1001+
// The cost of forward-only is that the STOCK was never compared even once. At the
1002+
// time #6350 was filed the v17 train carried 227 declared-breaking changesets, none
1003+
// of which any version of this logic had ever looked at, and hand-sampling had
1004+
// already turned up 2 suspected ledger misses of exactly the #6011 shape. This mode
1005+
// runs the gate's OWN judging functions -- `breakingDeclaration`, `readDisposition`,
1006+
// `findMigrationPrescription`, `workspacePackagesAt` -- once over that stock, so the
1007+
// audit cannot drift from the gate: there is no second copy of the logic here, only
1008+
// a different population fed to it.
1009+
//
1010+
// It is NOT wired into CI, and must not be. `package.json`'s
1011+
// `check:adr-0087-registration` and the Check Changeset workflow both invoke the
1012+
// gate with no flag; this mode is reachable only by typing it. It is `--list`'s
1013+
// neighbour -- a standing audit surface an operator runs on purpose -- not a
1014+
// second gate.
1015+
//
1016+
// ## How it narrows 238 rows to a hand-checkable residue
1017+
//
1018+
// A stock changeset carries no disposition marker (nothing ever asked it for one),
1019+
// so replaying the gate verbatim would report every one of them and say nothing.
1020+
// What is informative is which disposition the changeset WOULD have been ENTITLED
1021+
// to, judged mechanically:
1022+
//
1023+
// answered -- it already carries a marker (landed after the gate).
1024+
// exempt-unpublished -- every package it bumps is `private: true`, so nothing
1025+
// it breaks reaches a consumer. Fully mechanical.
1026+
// exempt-no-prescription-- its body frames no rewrite, so the catch-all is open
1027+
// to it. This is the gate's own contradiction check,
1028+
// run in the direction that grants rather than refuses.
1029+
// RESIDUE -- a published break whose body ships instructions for
1030+
// rewriting a consumer's code. Both mechanical
1031+
// exemptions are closed to it, so the only honest
1032+
// dispositions left are `registered` /
1033+
// `already-registered` -- and BOTH require a ledger
1034+
// entry that covers the surface. Whether one does is a
1035+
// judgment about meaning, which no script makes.
1036+
//
1037+
// For each residue row the audit adds the one further MECHANICAL fact that
1038+
// separates "the author registered it" from "nobody ever asked": did any commit
1039+
// that touched this changeset also touch an ADR-0087 registry source? That is the
1040+
// signal #6350's hand-sampling used, computed for the whole population instead of
1041+
// three spot checks.
1042+
//
1043+
// The output is a worklist for a human, not a verdict. It exits 0 whatever it
1044+
// finds -- a non-zero exit here would be a gate, and this is not one.
1045+
// ---------------------------------------------------------------------------
1046+
1047+
/**
1048+
* Did any commit that touched `path` also touch an ADR-0087 registry source?
1049+
*
1050+
* Every commit in the changeset's history is considered, not just the one that
1051+
* added it: a changeset can be edited into breaking after the fact, and the
1052+
* registration may ride either commit. Cheap because it only runs on the residue.
1053+
*
1054+
* ⚠️ Unusable in a SHALLOW clone, and the failure is silent-and-plausible rather
1055+
* than loud, so it is refused rather than approximated. In a graft-truncated
1056+
* history every pre-graft file reads as "added by the graft commit", and that
1057+
* commit contains the whole tree -- including both ledger sources. Measured on the
1058+
* default CI-shaped checkout of this repo (104 commits, grafted at 2bc187641,
1059+
* 5978 files in that one commit): 91 of 92 residue rows came back "the author
1060+
* registered something", every one of them wrong, and the answer looks entirely
1061+
* reasonable on the page. `git fetch --unshallow` (a `--filter=blob:none` partial
1062+
* fetch is enough -- this reads trees, never blobs) is the fix.
1063+
*
1064+
* @returns {{ available: boolean, shas: string[] }}
1065+
*/
1066+
function ledgerTouchingCommits(path, head, cwd) {
1067+
let shas;
1068+
try {
1069+
shas = git(['log', '--format=%h', head, '--', path], cwd).split('\n').map((s) => s.trim()).filter(Boolean);
1070+
} catch { return { available: true, shas: [] }; }
1071+
const hits = [];
1072+
for (const sha of shas) {
1073+
let names;
1074+
try { names = git(['show', '--name-only', '--format=', sha], cwd); } catch { continue; }
1075+
if (LEDGER_SOURCES.some((src) => names.includes(src))) hits.push(sha);
1076+
}
1077+
return { available: true, shas: hits };
1078+
}
1079+
1080+
/** Is this checkout shallow? A shallow history makes the ledger-touch signal a lie. */
1081+
function isShallow(cwd) {
1082+
try { return git(['rev-parse', '--is-shallow-repository'], cwd).trim() === 'true'; } catch { return false; }
1083+
}
1084+
1085+
/**
1086+
* Classify one stock changeset by which disposition it would be ENTITLED to.
1087+
*
1088+
* Exported so the self-test can pin it: this classifier decides which rows a human
1089+
* ever looks at, so a silent widening of `exempt-*` would shrink the worklist
1090+
* invisibly -- the #4690 failure mode wearing an audit's clothes.
1091+
*
1092+
* @param {ReturnType<typeof parseChangeset>} parsed
1093+
* @param {Map<string, {private: boolean, file: string}>} pkgs
1094+
* @returns {{ klass: 'answered'|'exempt-unpublished'|'exempt-no-prescription'|'residue', detail?: string, prescription?: ReturnType<typeof findMigrationPrescription> }}
1095+
*/
1096+
export function classifyStockChangeset(parsed, pkgs) {
1097+
const d = readDisposition(parsed.body);
1098+
if (d.ok) {
1099+
return { klass: 'answered', detail: d.verdict === 'registered' ? `registered ${d.ids.join(',')}` : `not-required (${d.category})` };
1100+
}
1101+
// `unpublished` first: a private package ships nothing, so the prescription in
1102+
// its body reaches no consumer of a published artifact and the question the
1103+
// ledger answers does not arise. A changeset declaring NO package cannot claim
1104+
// it -- the gate refuses that too ("nothing to verify").
1105+
if (parsed.bumps.length > 0 && parsed.bumps.every((b) => pkgs.get(b.pkg)?.private === true)) {
1106+
return { klass: 'exempt-unpublished', detail: parsed.bumps.map((b) => b.pkg).join(', ') };
1107+
}
1108+
const prescription = findMigrationPrescription(parsed.body);
1109+
if (!prescription) return { klass: 'exempt-no-prescription' };
1110+
return { klass: 'residue', prescription };
1111+
}
1112+
1113+
/** `--audit-stock`: run the gate's judging logic once over the whole stock (#6350). */
1114+
function auditStock(cwd, head) {
1115+
const stock = changesetsAt(head, cwd);
1116+
const texts = showManyOrNull(head, stock, cwd);
1117+
const pkgs = workspacePackagesAt(head, cwd);
1118+
1119+
const buckets = { answered: [], 'exempt-unpublished': [], 'exempt-no-prescription': [], residue: [] };
1120+
let breaking = 0;
1121+
for (const path of stock) {
1122+
const text = texts.get(path);
1123+
if (text === undefined) continue;
1124+
const parsed = parseChangeset(text);
1125+
const decl = breakingDeclaration(parsed);
1126+
if (!decl.breaking) continue;
1127+
breaking++;
1128+
const c = classifyStockChangeset(parsed, pkgs);
1129+
buckets[c.klass].push({ path, signals: decl.signals.join('+'), ...c });
1130+
}
1131+
1132+
console.log(`ADR-0087 stock backfill audit (#6350) -- ${stock.length} changeset(s) in stock, ${breaking} declared breaking.\n`);
1133+
for (const [klass, label] of [
1134+
['answered', 'ALREADY ANSWERED -- carries an adr-0087 marker (landed after the gate)'],
1135+
['exempt-unpublished', 'EXEMPT (unpublished) -- every bumped package is private; nothing ships'],
1136+
['exempt-no-prescription', 'EXEMPT (no-migration-prescription) -- the body frames no rewrite'],
1137+
]) {
1138+
console.log(`${label}: ${buckets[klass].length}`);
1139+
for (const r of buckets[klass]) {
1140+
if (klass !== 'exempt-no-prescription') console.log(` ${r.path}${r.detail ? ` [${r.detail}]` : ''}`);
1141+
}
1142+
console.log('');
1143+
}
1144+
1145+
console.log(`RESIDUE -- published break + migration prescription, so only \`registered\` /`);
1146+
console.log(`\`already-registered\` remain, and both need a ledger entry: ${buckets.residue.length}\n`);
1147+
const shallow = isShallow(cwd);
1148+
if (shallow) {
1149+
console.log(
1150+
' ⚠️ SHALLOW CLONE -- the ledger-touch signal is SUPPRESSED, not approximated. Every pre-graft\n' +
1151+
' file reads as "added by the graft commit", which carries the whole tree including both\n' +
1152+
' ledger sources, so the signal would report a plausible-looking "registered" for almost\n' +
1153+
' every row and be wrong on almost every row. Run `git fetch --unshallow --filter=blob:none`\n' +
1154+
' (trees are enough; no blobs are read) and re-run.\n',
1155+
);
1156+
}
1157+
for (const r of buckets.residue) {
1158+
const touching = shallow ? null : ledgerTouchingCommits(r.path, head, cwd);
1159+
const mark = touching === null ? '?' : touching.shas.length ? '~' : '!';
1160+
console.log(` ${mark} ${r.path} [${r.signals}]`);
1161+
console.log(` prescription (${r.prescription.branch}): ${r.prescription.line.slice(0, 120)}`);
1162+
if (touching === null) {
1163+
console.log(' ledger-touch: UNAVAILABLE (shallow clone)');
1164+
} else if (touching.shas.length) {
1165+
console.log(` ledger touched by: ${touching.shas.join(', ')} -- the author did register something; check it COVERS this surface`);
1166+
} else {
1167+
console.log(' ledger NEVER touched by any commit that touched this changeset -- the #6011 shape');
1168+
}
1169+
}
1170+
console.log(
1171+
'\nThis is a WORKLIST, not a verdict: `!` rows are candidates for a missing registration, and only a\n' +
1172+
'reader deciding what the surface means can say whether one is owed. This mode exits 0 whatever it\n' +
1173+
'finds -- it audits stock, which the gate deliberately never does (#6129).',
1174+
);
1175+
}
1176+
9891177
// ---------------------------------------------------------------------------
9901178
// Self-test -- pins the RED paths so the gate cannot rot into a no-op.
9911179
//
@@ -1321,6 +1509,44 @@ function selfTest() {
13211509
assert(findMigrationPrescription('### 迁移:FROM → TO\n')?.branch === 'from-to-label', 'P20: the placeholder label reports the label branch');
13221510
assert(findMigrationPrescription('nothing here\n') === null, 'P21: a body with no prescription reports null, not a shrug');
13231511

1512+
// ---- S1-S5: the `--audit-stock` classifier (#6350) ------------------------
1513+
//
1514+
// The stock audit's classifier decides which rows a human ever reads, so a
1515+
// silent widening of either `exempt-*` arm shrinks the worklist invisibly --
1516+
// #4690's failure mode wearing an audit's clothes. These pin all four arms plus
1517+
// the one precedence rule between them. They live in the self-test (which CI
1518+
// runs) even though the audit itself is operator-invoked, because the classifier
1519+
// reuses the gate's judging functions and would rot with them.
1520+
{
1521+
const PKGS = new Map([
1522+
['@objectstack/spec', { private: false, file: 'packages/spec/package.json' }],
1523+
['@objectstack/example-showcase', { private: true, file: 'examples/showcase/package.json' }],
1524+
]);
1525+
const cls = (text) => classifyStockChangeset(parseChangeset(text), PKGS).klass;
1526+
const PRESCRIPTION = '**BREAKING** x\n\n## 迁移\n\n- `a.b` → `a.c`\n';
1527+
assert(
1528+
cls(CS({ body: `${PRESCRIPTION}\n<!-- adr-0087: not-required (unpublished) whatever it says, a marker means the question was answered -->\n` })) === 'answered',
1529+
'S1: a changeset carrying a parseable marker is ANSWERED, whatever else it contains',
1530+
);
1531+
assert(
1532+
cls(CS({ bumps: [['@objectstack/example-showcase', 'major']], body: PRESCRIPTION })) === 'exempt-unpublished',
1533+
'S2: an all-private bump is exempt even carrying a prescription -- nothing it breaks ships',
1534+
);
1535+
assert(
1536+
cls(CS({ body: '**BREAKING** an internal error string changed; no key or symbol moves\n' })) === 'exempt-no-prescription',
1537+
'S3: a published break that frames no rewrite is exempt',
1538+
);
1539+
assert(cls(CS({ body: PRESCRIPTION })) === 'residue', 'S4: THE #6011 SHAPE -- published break + prescription -- is RESIDUE');
1540+
assert(
1541+
cls(`---\n---\n\n${PRESCRIPTION}`) === 'residue',
1542+
'S5: a changeset declaring NO package cannot buy the unpublished exemption (the gate refuses it too)',
1543+
);
1544+
assert(
1545+
cls(CS({ bumps: [['@objectstack/spec', 'major'], ['@objectstack/example-showcase', 'major']], body: PRESCRIPTION })) === 'residue',
1546+
'S6: ONE published package among the bumps is enough to close the unpublished exemption',
1547+
);
1548+
}
1549+
13241550
assert(breakingDeclaration(parseChangeset(CS({ body: 'feat(spec)!: x\n' }))).breaking, 'P6: a conventional-commit bang is a declaration');
13251551
assert(!breakingDeclaration(parseChangeset(CS({ bumps: [['a', 'patch']], body: 'plain\n' }))).breaking, 'P7: a plain patch is not');
13261552
assert(extractIds(" id: 'object-titleFormat-to-nameField',\n").length === 1, 'P8: an id with a capital letter must be extracted');
@@ -1349,6 +1575,8 @@ if (argv.includes('--self-test')) {
13491575
selfTest();
13501576
} else if (argv.includes('--list')) {
13511577
list(REPO_ROOT, 'HEAD');
1578+
} else if (argv.includes('--audit-stock')) {
1579+
auditStock(REPO_ROOT, readFlag('--head') ?? 'HEAD');
13521580
} else {
13531581
const head = readFlag('--head') ?? 'HEAD';
13541582
const requested = readFlag('--base');

0 commit comments

Comments
 (0)