Skip to content

Commit 71a4b4e

Browse files
fix(release): the duplicate-Release audit counts distinct ids, so a Release the moving list serves twice is one (#26559)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi92PbDQRKbS6saKBBMa9Dg8A)_ Fixes #26552 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:发版脚本的「重复 Release」审计改成按 Release id 去重,同一个 id 被翻页列两次只算一个;列表漏掉的 tag 先按 tag 重读一次再判「未核实」。 - 为什么改:18.2.0 补发 run 40464600678 里 `@object-ui/fields@18.2.0` 的同一个 id 425135424 被列了两次,审计报「2 个 Release」、surplus 列表为空,发布本身完整却红了。 - 风险与回滚:只改 `scripts/release-github-releases.mjs` 一个文件,不动工作流;真正两个不同 id 的重复照旧报错(自测有控制行)。回滚即 revert 本 PR。 - 先看:`scripts/release-github-releases.mjs` 的 `auditDuplicateReleases`(按 id 记账与 by-tag 重读),其次 `describeDuplicates`,再看自测第 16 组。 - 两分钟验证:`node scripts/release-github-releases.mjs --self-test`,预期一行 `✓ … 114 assertions`。 - 需要你做的:无,席位落地。 ## What changes `scripts/release-github-releases.mjs` only, plus its `--self-test`. - `auditDuplicateReleases` keys each tag's rows by `rel.id`. A tag is a duplicate only when it carries two distinct ids. The same id served on two offset pages is one Release, counted in a new `rowsServedTwice` field. - When the walk completed, a tag no page showed is re-read through `findByTag` before it is called unseen. A Release the by-tag endpoint answers goes into a new `foundByTag` field instead of `unseen`. A walk that stopped at its page cap re-reads nothing, because its unseen tags may sit, twins included, on pages it never read. - `describeDuplicates` collapses repeated ids itself and skips an entry with fewer than two distinct ids, so it can never print an empty surplus list or a repeated "This run created" id. It also returns `notes`: a plain log line (not a warning) when the list moved under the walk. `main` prints the notes before the warnings. - The stub client gains a `listMoves` hook that reshapes the newest-first list just before a page is cut from it. ## Why Release run 40464600678 (job 122058101982, the 18.2.0 `force: true` repair) published 110 of 110 packages and still exited 1: ``` ##[error]@object-ui/fields@18.2.0 has 2 GitHub Releases: 425135424 (no assets), 425135424 (no assets). … the other(s), , are the surplus. This run created 425135424, 425135424 … ##[warning]… 1 tag(s) this run released never appeared in it (@object-ui/types@18.2.0) … ``` All of one version's Releases carry the same tag date, so their order among themselves is not stable from one list request to the next, and a create pushes every row down. Between two offset page reads one row was served twice and another on no page. The audit pushed every matching row without keying on the id. ## Tests New battery `16. A list that moves between page reads is one Release per id` (11 cases, pinned in `SELF_TEST_BATTERIES`; the roster floor is raised 15 to 16): - Positive control: the swap fixture really serves one id on pages 1 and 2 and one id on neither. - A tie-order swap at the page boundary gives 0 duplicates and 0 unseen. `rowsServedTwice` is 1, `foundByTag` names the hidden tag, there are no errors or warnings, and there is one note. - A create landing at the head between two reads gives 0 duplicates, 0 unseen, 0 found by tag and 1 repeated row. - Control: a real two-id pair under the same swap is still one ERROR, listing `9003 (no assets), 9004 (no assets)`, with the non-empty surplus `9004` and `This run created 9004:`. - The literal 40464600678 audit shape (one id twice, created twice) handed to `describeDuplicates` gives no error, no warning and no empty-surplus text. - A capped walk sends no by-tag read for its unread tags, and they stay UNVERIFIED. Batteries 1–15 are unchanged. Battery 14's real-pair, pre-existing-pair, capped and never-appeared cases all still hold. No ablation: `.claude/agents/os-dev.md` excludes scripts from one-time ablations. Battery 16's positive control shows the fixture really moves. ## Verification <details><summary>check:release-body at the final head</summary> ``` $ pnpm check:release-body ✓ release-github-releases --self-test: 114 assertions (real packages/spec/CHANGELOG.md 17.0.0-rc.2 section = 344008 chars -> 123909, limit 125000) ``` Before the change, on `origin/main` ab3c776: 103 assertions. </details> <details><summary>Derived gate families at head 0765c51: 31 run, 31 exit 0</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 31 commands. Each exit code was captured before any pipe and with stdin closed. ``` node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-declaration-mirrors.mjs :: exit 0 node scripts/check-declaration-mirrors.mjs --self-test :: exit 0 node scripts/check-self-test-wired.mjs :: exit 0 node scripts/check-self-test-wired.mjs --self-test :: exit 0 node scripts/check-self-test-workflow-commands.mjs :: exit 0 node scripts/check-self-test-workflow-commands.mjs --self-test :: exit 0 node scripts/check-whole-set-label-write.mjs :: exit 0 node scripts/check-whole-set-label-write.mjs --self-test :: exit 0 node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0 node scripts/pm/bare-root-worklist.mjs --self-test :: exit 0 pnpm check:agent-test-spelling :: exit 0 pnpm check:bash32-floor :: exit 0 pnpm check:ci-filter-parity :: exit 0 pnpm check:cli-command-ids :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:entry-guard :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:parse-guard :: exit 0 pnpm check:pnpm-filter-targets :: exit 0 pnpm check:pnpm-flag-spellings :: exit 0 pnpm check:ratchet-remedy-authority :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:release-body :: exit 0 pnpm check:scripts-symbol-anchors :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:pm-dispatch-gates :: exit 0 ``` `dispatch-gates --ran`: `31 derived famil(ies) accounted for — 31 run, 0 NOT-MEASURED (a DERIVED zero — all 31 recorded an exit code and none of them is 3).` Not run locally, and left to CI: the type-check lanes and the `Plan Test Core shards` step, which `--commands` lists as CI-only. The script is plain `.mjs` and no package `typecheck` program reads it. </details> ## Acceptance notes - The by-tag re-read proves that a hidden tag's Release exists. It cannot prove that no second Release on that tag was hidden by the same move. That limit belongs to any walk over a list whose tie order is unstable, and the note line says the list moved. - `skip-changeset`: the diff touches only a repo-internal release tool. No package ships it. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (not touched) - [x] 2. Every name I added uses the casing for its kind. - [ ] 3. No formatter ran; `pnpm lint` passes. No formatter ran; `pnpm lint` is CI's run (Lint & Repo Gates), not run locally. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. (publishes nothing: `skip-changeset`; both lines are above) - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (not touched) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [ ] The *Before Submitting* list in CONTRIBUTING.md is done. The full `pnpm test` belongs to CI; this card's local subset is the derived gate list above. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01ADHkmT8PswRoP8QDevDFuR --- _Generated by [Claude Code](https://claude.ai/code/session_01ADHkmT8PswRoP8QDevDFuR)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f9e8401 commit 71a4b4e

1 file changed

Lines changed: 226 additions & 25 deletions

File tree

‎scripts/release-github-releases.mjs‎

Lines changed: 226 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,9 @@
6464
* the API will ACCEPT two creates for one tag inside one second: 17.6.0
6565
* has five tags carrying two Release objects each. No request sequence on
6666
* this side can prevent that, so after its writes the script reads the
67-
* Releases list and names every tag it released that has more than one.
67+
* Releases list and names every tag it released that has more than one
68+
* distinct Release id — the list's offset pages can serve one Release
69+
* twice while it moves, and that is one Release.
6870
* A duplicate this run CREATED one of fails the run (`::error::`) — the
6971
* race was this run's, and without the failure the convergence above
7072
* would turn a red race into a green run with a duplicate in it. A
@@ -137,12 +139,14 @@ const SELF_TEST_BATTERIES = Object.freeze({
137139
'13. Any other 422 still fails; a racer the read cannot see fails loudly, bounded': 8,
138140
'14. Duplicate releases for one tag are reported, never deleted': 14,
139141
'15. The Release body is grouped by what the reader must do (ruling A)': 14,
142+
'16. A list that moves between page reads is one Release per id': 11,
140143
});
141144

142145
// DELETING an entry silences that battery's floor exactly as effectively as
143146
// zeroing it, so the roster's own size is pinned too. Raised 14 → 15 with the
144-
// grouping battery (maintainer ruling A, 2026-10-21, objectstack#25105).
145-
const SELF_TEST_BATTERY_FLOOR = 15;
147+
// grouping battery (maintainer ruling A, 2026-10-21, objectstack#25105), and
148+
// 15 → 16 with the moving-list battery.
149+
const SELF_TEST_BATTERY_FLOOR = 16;
146150

147151
// The key an assertion is filed under when no battery is open. It is not a
148152
// declared battery, so it reds by the same set difference rather than silently
@@ -898,8 +902,21 @@ export const AUDIT_MAX_PAGES = 10;
898902
* contiguous run of that list (see `listPage`), so the walk stops at the first
899903
* page holding none of `tagNames` after one that did, or at the end of the
900904
* list, and is `complete` only when it stopped for one of those two reasons.
901-
* Every tag it never saw is returned in `unseen`: the audit cannot vouch for
902-
* those. Read-only — it never deletes, and nothing in this file does.
905+
* Read-only — it never deletes, and nothing in this file does.
906+
*
907+
* The pages are offset slices of a list that can move while it is read: one
908+
* version's Releases all carry the same tag date, so their order among
909+
* themselves is not stable from one request to the next, and a Release created
910+
* between two reads pushes every row down. Either way a row can be served on
911+
* two pages and another on none — release run 40464600678 listed one
912+
* `@object-ui/fields@18.2.0` Release (id 425135424) twice and never showed
913+
* `@object-ui/types@18.2.0`. So a duplicate is two DISTINCT ids on one tag; the
914+
* same id served twice is one Release, counted in `rowsServedTwice`. And a tag
915+
* a complete walk never saw is re-read by tag before it is called unseen: a
916+
* Release the by-tag endpoint answers exists, and is returned in `foundByTag`.
917+
* Every tag still missing after that is returned in `unseen`: the audit cannot
918+
* vouch for those. A walk that stopped at its page cap re-reads nothing — its
919+
* unseen tags may sit, twins and all, on the pages it never read.
903920
*
904921
* @param {object} opts
905922
* @param {ReturnType<typeof createReleasesClient>} opts.client
@@ -910,6 +927,8 @@ export const AUDIT_MAX_PAGES = 10;
910927
* @returns {Promise<{
911928
* duplicates: { tagName: string; releases: { id: number; assets: string[] }[]; byTagId: number | null; createdByThisRun: number[] }[];
912929
* unseen: string[];
930+
* foundByTag: string[];
931+
* rowsServedTwice: number;
913932
* pagesRead: number;
914933
* complete: boolean;
915934
* }>}
@@ -922,11 +941,12 @@ export async function auditDuplicateReleases({
922941
maxPages = AUDIT_MAX_PAGES,
923942
}) {
924943
const wanted = new Set(tagNames);
925-
/** @type {Map<string, { id: number; assets: string[] }[]>} */
944+
/** @type {Map<string, Map<number, { id: number; assets: string[] }>>} keyed by release id */
926945
const byTag = new Map();
927946
let seenAny = false;
928947
let complete = false;
929948
let pagesRead = 0;
949+
let rowsServedTwice = 0;
930950

931951
for (let page = 1; page <= maxPages; page += 1) {
932952
const releases = await client.listPage({ page, perPage });
@@ -935,9 +955,10 @@ export async function auditDuplicateReleases({
935955
for (const rel of Array.isArray(releases) ? releases : []) {
936956
if (!wanted.has(rel.tag_name)) continue;
937957
hits += 1;
938-
const list = byTag.get(rel.tag_name) ?? [];
939-
list.push({ id: rel.id, assets: (rel.assets ?? []).map((a) => a.name) });
940-
byTag.set(rel.tag_name, list);
958+
const ids = byTag.get(rel.tag_name) ?? new Map();
959+
if (ids.has(rel.id)) rowsServedTwice += 1; // the list moved between two page reads
960+
else ids.set(rel.id, { id: rel.id, assets: (rel.assets ?? []).map((a) => a.name) });
961+
byTag.set(rel.tag_name, ids);
941962
}
942963
if (hits > 0) seenAny = true;
943964
else if (seenAny) {
@@ -953,9 +974,9 @@ export async function auditDuplicateReleases({
953974
const ours = new Set(createdIds);
954975
const duplicates = [];
955976
for (const tagName of tagNames) {
956-
const releases = byTag.get(tagName);
957-
if (!releases || releases.length < 2) continue;
958-
releases.sort((a, b) => a.id - b.id);
977+
const ids = byTag.get(tagName);
978+
if (!ids || ids.size < 2) continue;
979+
const releases = [...ids.values()].sort((a, b) => a.id - b.id);
959980
// Which one `gh release view` / `gh release upload` resolve the tag to,
960981
// and so where the ADR-0087 D4 asset lands: the report's cleanup anchor.
961982
let byTagId = null;
@@ -972,37 +993,61 @@ export async function auditDuplicateReleases({
972993
});
973994
}
974995

975-
return { duplicates, unseen: tagNames.filter((t) => !byTag.has(t)), pagesRead, complete };
996+
const unseen = [];
997+
const foundByTag = [];
998+
for (const tagName of tagNames) {
999+
if (byTag.has(tagName)) continue;
1000+
let found = null;
1001+
if (complete) {
1002+
try {
1003+
found = await client.findByTag(tagName);
1004+
} catch {
1005+
found = null; // an unreadable by-tag answer vouches for nothing
1006+
}
1007+
}
1008+
(found ? foundByTag : unseen).push(tagName);
1009+
}
1010+
1011+
return { duplicates, unseen, foundByTag, rowsServedTwice, pagesRead, complete };
9761012
}
9771013

9781014
/**
9791015
* Turn an audit into the lines `main` prints: `errors` fail the run,
980-
* `warnings` do not. A duplicate this run created one of is an error; one that
981-
* predates this run's writes is a warning (the header says why).
1016+
* `warnings` do not, and `notes` are plain log lines. A duplicate this run
1017+
* created one of is an error; one that predates this run's writes is a
1018+
* warning (the header says why). A list that moved under the walk is a note:
1019+
* it is the API's paging, not a fact about any tag.
9821020
*
9831021
* @param {Awaited<ReturnType<typeof auditDuplicateReleases>>} audit
984-
* @returns {{ errors: string[]; warnings: string[] }}
1022+
* @returns {{ errors: string[]; warnings: string[]; notes: string[] }}
9851023
*/
9861024
export function describeDuplicates(audit) {
9871025
const errors = [];
9881026
const warnings = [];
1027+
const notes = [];
9891028
for (const d of audit.duplicates) {
990-
const list = d.releases
1029+
// One id listed twice is one Release, whatever handed it over: a duplicate
1030+
// is two distinct ids, so the surplus below is never an empty list.
1031+
const releases = [...new Map(d.releases.map((r) => [r.id, r])).values()];
1032+
if (releases.length < 2) continue;
1033+
const surplus = releases.filter((r) => r.id !== d.byTagId).map((r) => r.id);
1034+
const list = releases
9911035
.map((r) => `${r.id} (${r.assets.length ? `assets: ${r.assets.join(', ')}` : 'no assets'})`)
9921036
.join(', ');
9931037
const anchor =
9941038
d.byTagId === null
9951039
? 'The by-tag endpoint could not be read, so which one `gh release view` resolves the tag to is unknown.'
9961040
: `The by-tag endpoint — what \`gh release view\` and \`gh release upload\` resolve the tag to, and so ` +
9971041
`where the ADR-0087 D4 asset is attached — answers ${d.byTagId}; the other(s), ` +
998-
`${d.releases.filter((r) => r.id !== d.byTagId).map((r) => r.id).join(', ')}, are the surplus.`;
999-
const head = `${d.tagName} has ${d.releases.length} GitHub Releases: ${list}. ${anchor}`;
1042+
`${surplus.join(', ')}, are the surplus.`;
1043+
const head = `${d.tagName} has ${releases.length} GitHub Releases: ${list}. ${anchor}`;
10001044
const tail =
10011045
'This script never deletes a Release; deleting the surplus is a release act and the maintainer\'s call ' +
10021046
'(AGENTS.md Prime Directive 15).';
1003-
if (d.createdByThisRun.length > 0) {
1047+
const createdByThisRun = [...new Set(d.createdByThisRun)];
1048+
if (createdByThisRun.length > 0) {
10041049
errors.push(
1005-
`${head} This run created ${d.createdByThisRun.join(', ')}: the Releases API accepted a second create ` +
1050+
`${head} This run created ${createdByThisRun.join(', ')}: the Releases API accepted a second create ` +
10061051
`for one tag from a concurrent writer. ${tail}`,
10071052
);
10081053
} else {
@@ -1022,7 +1067,19 @@ export function describeDuplicates(audit) {
10221067
'whether those tags carry more than one Release is UNVERIFIED by this run.',
10231068
);
10241069
}
1025-
return { errors, warnings };
1070+
const foundByTag = audit.foundByTag ?? [];
1071+
if ((audit.rowsServedTwice ?? 0) > 0 || foundByTag.length > 0) {
1072+
notes.push(
1073+
`the Releases list moved while the duplicate audit read it: ${audit.rowsServedTwice ?? 0} row(s) were ` +
1074+
`served on two pages and counted once` +
1075+
(foundByTag.length
1076+
? `, and ${foundByTag.length} tag(s) no page showed were read by tag and exist ` +
1077+
`(${foundByTag.slice(0, 5).join(', ')}${foundByTag.length > 5 ? ', …' : ''})`
1078+
: '') +
1079+
'.',
1080+
);
1081+
}
1082+
return { errors, warnings, notes };
10261083
}
10271084

10281085
// ─────────────────────────────────────────────────────────────────────────────
@@ -1091,8 +1148,8 @@ async function main({ dryRun = false } = {}) {
10911148
// After the writes, never instead of them: a duplicate is made by a
10921149
// concurrent writer's create, and this run's last POST may be the one that
10931150
// completed the pair.
1094-
/** @type {{ errors: string[]; warnings: string[] }} */
1095-
let duplicateReport = { errors: [], warnings: [] };
1151+
/** @type {{ errors: string[]; warnings: string[]; notes: string[] }} */
1152+
let duplicateReport = { errors: [], warnings: [], notes: [] };
10961153
try {
10971154
const audit = await auditDuplicateReleases({ client, tagNames: plans.map((p) => p.tagName), createdIds });
10981155
duplicateReport = describeDuplicates(audit);
@@ -1103,6 +1160,7 @@ async function main({ dryRun = false } = {}) {
11031160
'more than one Release is UNVERIFIED by this run.',
11041161
);
11051162
}
1163+
for (const n of duplicateReport.notes) console.log(n);
11061164
for (const w of duplicateReport.warnings) console.log(`::warning::${w}`);
11071165
for (const e of duplicateReport.errors) console.log(`::error::${e}`);
11081166

@@ -1174,6 +1232,8 @@ const STUB_TAG_DATE = '2026-10-02T02:49:42Z';
11741232
* @param {{ id: number; tag_name: string; created_at: string; assets?: { name: string }[] }[]} [opts.seed]
11751233
* further Releases already in the store (older versions, pre-existing duplicates)
11761234
* @param {boolean} [opts.tick] yield a macrotask per request, so two concurrent runs interleave
1235+
* @param {(ordered: any[], page: number, perPage: number) => any[]} [opts.listMoves] reshapes the
1236+
* newest-first list just before a page is cut from it — the list moving between two page reads
11771237
*/
11781238
function stubFetch({
11791239
existing = {},
@@ -1183,6 +1243,7 @@ function stubFetch({
11831243
hideAfterRace = new Map(),
11841244
seed = [],
11851245
tick = false,
1246+
listMoves = (ordered) => ordered,
11861247
} = {}) {
11871248
/** @type {{ method: string; url: string; body: any }[]} */
11881249
const calls = [];
@@ -1225,7 +1286,7 @@ function stubFetch({
12251286
const perPage = Number(list[1]);
12261287
const page = Number(list[2]);
12271288
const ordered = [...releases].sort((a, b) => b.created_at.localeCompare(a.created_at) || b.id - a.id);
1228-
return stubResponse(200, ordered.slice((page - 1) * perPage, page * perPage));
1289+
return stubResponse(200, listMoves(ordered, page, perPage).slice((page - 1) * perPage, page * perPage));
12291290
}
12301291
return stubResponse(404, { message: 'Not Found' });
12311292
}
@@ -1851,6 +1912,146 @@ async function selfTest() {
18511912
'a tag the list never shows is reported unseen, not silently cleared',
18521913
);
18531914

1915+
// ── 16. A list that moves between page reads is one Release per id ───────
1916+
// Release run 40464600678: one version's Releases share their tag date, so
1917+
// their order among themselves is not stable across requests, and a create
1918+
// pushes every row down. The audit listed id 425135424 twice — "2 GitHub
1919+
// Releases" with an empty surplus — and never saw another tag at all.
1920+
battery('16. A list that moves between page reads is one Release per id');
1921+
{
1922+
const PER = 4;
1923+
const version = Array.from({ length: 6 }, (_, i) => ({
1924+
id: 9001 + i,
1925+
tag_name: `@object-ui/pkg-${i}@18.2.0`,
1926+
created_at: STUB_TAG_DATE,
1927+
}));
1928+
const tagsOf = (rels) => rels.map((r) => r.tag_name);
1929+
// The tie order flips at the page boundary once page 1 is served: the last
1930+
// row of page 1 is served again at the top of page 2, and the row it swapped
1931+
// with is served on no page.
1932+
const swapAtBoundary = (ordered, page, perPage) => {
1933+
if (page < 2) return ordered;
1934+
const moved = [...ordered];
1935+
[moved[perPage - 1], moved[perPage]] = [moved[perPage], moved[perPage - 1]];
1936+
return moved;
1937+
};
1938+
// A Release created between the two reads lands at the head of the list.
1939+
const newer = { id: 9999, tag_name: '@objectstack/elsewhere@99.0.0', created_at: '2026-10-03T00:00:00Z', assets: [] };
1940+
const createdAtHead = (ordered, page) => (page < 2 ? ordered : [newer, ...ordered]);
1941+
1942+
const swapped = stubFetch({ seed: [...version, ...olderVersion], listMoves: swapAtBoundary });
1943+
const swappedClient = clientOver(swapped);
1944+
const p1 = (await swappedClient.listPage({ page: 1, perPage: PER })).map((r) => r.id);
1945+
const p2 = (await swappedClient.listPage({ page: 2, perPage: PER })).map((r) => r.id);
1946+
assert(
1947+
p1.filter((id) => p2.includes(id)).length === 1 && ![...p1, ...p2].includes(9002),
1948+
`the fixture really moves: one id on both pages and one on neither (page 1 ${p1}, page 2 ${p2})`,
1949+
);
1950+
const swapAudit = await auditDuplicateReleases({
1951+
client: swappedClient,
1952+
tagNames: tagsOf(version),
1953+
createdIds: version.map((r) => r.id),
1954+
perPage: PER,
1955+
});
1956+
assert(
1957+
swapAudit.duplicates.length === 0 && swapAudit.unseen.length === 0 && swapAudit.complete,
1958+
`a row served on two pages and a row served on none yield 0 duplicates and 0 unseen (got ${swapAudit.duplicates.length} / ${JSON.stringify(swapAudit.unseen)})`,
1959+
);
1960+
assert(
1961+
swapAudit.rowsServedTwice === 1 && JSON.stringify(swapAudit.foundByTag) === JSON.stringify(['@object-ui/pkg-1@18.2.0']),
1962+
`the repeated row is counted once, and the tag no page showed is found by tag (got ${swapAudit.rowsServedTwice}, ${JSON.stringify(swapAudit.foundByTag)})`,
1963+
);
1964+
const swapReport = describeDuplicates(swapAudit);
1965+
assert(
1966+
swapReport.errors.length === 0 && swapReport.warnings.length === 0,
1967+
`the moved list fails nothing and warns nothing (got ${JSON.stringify(swapReport)})`,
1968+
);
1969+
assert(
1970+
swapReport.notes.length === 1 && swapReport.notes[0].includes('@object-ui/pkg-1@18.2.0') && swapReport.notes[0].includes('1 row(s)'),
1971+
'the move is logged as a note naming the repeated-row count and the tag read by tag',
1972+
);
1973+
1974+
const shifted = stubFetch({ seed: [...version, ...olderVersion], listMoves: createdAtHead });
1975+
const shiftAudit = await auditDuplicateReleases({
1976+
client: clientOver(shifted),
1977+
tagNames: tagsOf(version),
1978+
createdIds: version.map((r) => r.id),
1979+
perPage: PER,
1980+
});
1981+
assert(
1982+
shiftAudit.duplicates.length === 0 && shiftAudit.unseen.length === 0 && shiftAudit.foundByTag.length === 0 &&
1983+
shiftAudit.rowsServedTwice === 1 && describeDuplicates(shiftAudit).errors.length === 0,
1984+
`a create landing at the head between two reads repeats one row and yields 0 duplicates and 0 unseen (got ${JSON.stringify(shiftAudit)})`,
1985+
);
1986+
1987+
// Control: a REAL pair — two distinct ids on one tag — still reds under the
1988+
// same move, and the move does not list either id twice.
1989+
const dupTag = '@object-ui/pkg-2@18.2.0';
1990+
const realPair = version.map((r) => (r.id === 9004 ? { ...r, tag_name: dupTag } : r));
1991+
const pairStub = stubFetch({ seed: [...realPair, ...olderVersion], listMoves: swapAtBoundary });
1992+
const pairAudit = await auditDuplicateReleases({
1993+
client: clientOver(pairStub),
1994+
tagNames: [...new Set(tagsOf(realPair))],
1995+
createdIds: [9004],
1996+
perPage: PER,
1997+
});
1998+
assert(
1999+
pairAudit.duplicates.length === 1 &&
2000+
JSON.stringify(pairAudit.duplicates[0].releases.map((r) => r.id)) === JSON.stringify([9003, 9004]),
2001+
`a real two-id duplicate under the move is still found, each id once (got ${JSON.stringify(pairAudit.duplicates)})`,
2002+
);
2003+
const pairReport = describeDuplicates(pairAudit);
2004+
assert(
2005+
pairReport.errors.length === 1 &&
2006+
pairReport.errors[0].includes(`${dupTag} has 2 GitHub Releases: 9003 (no assets), 9004 (no assets)`) &&
2007+
pairReport.errors[0].includes('the other(s), 9004, are the surplus') &&
2008+
pairReport.errors[0].includes('This run created 9004:'),
2009+
`the real pair is an ERROR naming both ids and a non-empty surplus (got ${JSON.stringify(pairReport.errors)})`,
2010+
);
2011+
2012+
// The 40464600678 audit handed straight to the report: one id twice is no
2013+
// duplicate, so there is nothing to print — least of all an empty surplus.
2014+
const sameIdTwice = describeDuplicates({
2015+
duplicates: [
2016+
{
2017+
tagName: '@object-ui/fields@18.2.0',
2018+
releases: [{ id: 425135424, assets: [] }, { id: 425135424, assets: [] }],
2019+
byTagId: 425135424,
2020+
createdByThisRun: [425135424, 425135424],
2021+
},
2022+
],
2023+
unseen: [],
2024+
foundByTag: [],
2025+
rowsServedTwice: 0,
2026+
pagesRead: 3,
2027+
complete: true,
2028+
});
2029+
assert(
2030+
sameIdTwice.errors.length === 0 && sameIdTwice.warnings.length === 0 &&
2031+
![...sameIdTwice.errors, ...sameIdTwice.warnings].some((l) => /, ,|the other\(s\), ,/.test(l)),
2032+
`one id listed twice is reported as nothing, never as a pair with an empty surplus (got ${JSON.stringify(sameIdTwice)})`,
2033+
);
2034+
2035+
// A walk that stopped at its cap re-reads nothing: its unseen tags may sit,
2036+
// twins and all, on the pages it never read.
2037+
const cappedStub = stubFetch({ seed: [...version, ...olderVersion] });
2038+
const cappedAudit = await auditDuplicateReleases({
2039+
client: clientOver(cappedStub),
2040+
tagNames: tagsOf(version),
2041+
perPage: 2,
2042+
maxPages: 1,
2043+
});
2044+
assert(
2045+
!cappedAudit.complete && cappedAudit.unseen.length === 4 && cappedAudit.foundByTag.length === 0 &&
2046+
!cappedStub.calls.some((c) => c.url.includes('/releases/tags/')),
2047+
`a capped walk calls its unread tags unseen without a by-tag read (got ${JSON.stringify(cappedAudit.unseen)})`,
2048+
);
2049+
assert(
2050+
describeDuplicates(cappedAudit).warnings.some((w) => w.includes('UNVERIFIED')),
2051+
'and those tags stay UNVERIFIED in the report',
2052+
);
2053+
}
2054+
18542055
// ── 15. The Release body is grouped by what the reader must do (ruling A) ─
18552056
battery('15. The Release body is grouped by what the reader must do (ruling A)');
18562057
{

0 commit comments

Comments
 (0)