@celilo/e2e 0.18.0 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,326 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
+ import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { RegistryStorage } from './storage';
6
+ import { DEFAULT_KEEP_BUILD_REVISIONS, parseRevision, planModuleSweep, sweep } from './sweep';
7
+
8
+ let dataDir: string;
9
+ let storage: RegistryStorage;
10
+
11
+ beforeEach(() => {
12
+ dataDir = mkdtempSync(join(tmpdir(), 'celilo-registry-sweep-'));
13
+ storage = new RegistryStorage(dataDir);
14
+ });
15
+
16
+ afterEach(() => {
17
+ rmSync(dataDir, { recursive: true, force: true });
18
+ });
19
+
20
+ /** Publish a version through the same two calls the real publish path uses. */
21
+ function publish(name: string, vers: string, sizeBytes = 32): void {
22
+ storage.storePackage(name, vers, Buffer.alloc(sizeBytes, 7));
23
+ storage.appendIndex({ name, vers, deps: [], cksum: `sha256:${vers}`, yanked: false });
24
+ }
25
+
26
+ /**
27
+ * THE invariant. A sweep that trims payloads without trimming index lines
28
+ * leaves the index advertising a version whose file is gone — search lists it
29
+ * and the download 404s. The reverse leaves unreachable bytes on the disk the
30
+ * sweep exists to reclaim. Both directions have to hold.
31
+ */
32
+ function expectIndexAndPayloadsAgree(name: string): void {
33
+ const indexed = storage.readIndex(name).map((e) => e.vers);
34
+ const stored = storage.storedVersions(name).sort();
35
+ for (const vers of indexed) {
36
+ expect(storage.readPackage(name, vers)).not.toBeNull();
37
+ }
38
+ expect(stored).toEqual([...indexed].sort());
39
+ }
40
+
41
+ describe('parseRevision', () => {
42
+ test('splits a release from its build revision', () => {
43
+ expect(parseRevision('1.0.3+7')).toEqual({ release: '1.0.3', revision: 7 });
44
+ });
45
+
46
+ test('keeps a Debian epoch with the release', () => {
47
+ expect(parseRevision('2:1.0.0+1')).toEqual({ release: '2:1.0.0', revision: 1 });
48
+ });
49
+
50
+ test('returns null for a version with no build revision', () => {
51
+ expect(parseRevision('1.0.0')).toBeNull();
52
+ });
53
+ });
54
+
55
+ describe('planModuleSweep', () => {
56
+ const entry = (vers: string, yanked = false) => ({
57
+ name: 'm',
58
+ vers,
59
+ deps: [],
60
+ cksum: 'sha256:x',
61
+ yanked,
62
+ });
63
+
64
+ /**
65
+ * Plan against a store where every indexed version HAS its payload — the
66
+ * ordinary case. Passing an empty stored set instead would trip the
67
+ * unreadable guard, which is a different scenario with its own tests below.
68
+ */
69
+ const planWithPayloads = (entries: ReturnType<typeof entry>[], keepBuildRevisions: number) =>
70
+ planModuleSweep(
71
+ 'm',
72
+ entries,
73
+ entries.map((e) => e.vers),
74
+ { keepBuildRevisions },
75
+ );
76
+
77
+ test('keeps the newest build revision of every release', () => {
78
+ const entries = [
79
+ entry('1.0.0+1'),
80
+ entry('1.0.0+2'),
81
+ entry('1.0.2+8'),
82
+ entry('1.0.2+9'),
83
+ entry('1.0.3+5'),
84
+ entry('1.0.3+6'),
85
+ entry('1.0.3+7'),
86
+ ];
87
+ const plan = planWithPayloads(entries, 1);
88
+ expect(plan.keep.map((e) => e.vers)).toEqual(['1.0.0+2', '1.0.2+9', '1.0.3+7']);
89
+ expect(plan.remove).toEqual(['1.0.0+1', '1.0.2+8', '1.0.3+5', '1.0.3+6']);
90
+ });
91
+
92
+ test('never removes the only revision of a release', () => {
93
+ const entries = [entry('0.9.0+2'), entry('1.0.0+1')];
94
+ const plan = planWithPayloads(entries, 1);
95
+ expect(plan.remove).toEqual([]);
96
+ });
97
+
98
+ test('a module can never be swept out of existence', () => {
99
+ const entries = [entry('1.0.0+1')];
100
+ const plan = planWithPayloads(entries, 1);
101
+ expect(plan.keep).toHaveLength(1);
102
+ });
103
+
104
+ test('keeps more revisions per release when asked', () => {
105
+ const entries = [entry('1.0.3+5'), entry('1.0.3+6'), entry('1.0.3+7')];
106
+ const plan = planWithPayloads(entries, 2);
107
+ expect(plan.keep.map((e) => e.vers)).toEqual(['1.0.3+6', '1.0.3+7']);
108
+ });
109
+
110
+ test('keepBuildRevisions below 1 is clamped — no policy deletes a whole release', () => {
111
+ // Needs MORE THAN ONE release to be a real check. With a single release
112
+ // the latest-line retention below masks a missing clamp; with two, an
113
+ // unclamped 0 wipes out every release except the last.
114
+ const entries = [entry('1.0.0+1'), entry('1.0.0+2'), entry('1.0.3+5'), entry('1.0.3+6')];
115
+ const plan = planWithPayloads(entries, 0);
116
+ expect(plan.keep.map((e) => e.vers)).toEqual(['1.0.0+2', '1.0.3+6']);
117
+ });
118
+
119
+ test('retains what latestVersion resolves to even when the index is out of order', () => {
120
+ // Last line wins for RegistryClient.latestVersion regardless of ordering,
121
+ // so a disordered index must not lose the version clients actually fetch.
122
+ const entries = [entry('1.0.3+7'), entry('1.0.3+6'), entry('1.0.3+5')];
123
+ const plan = planWithPayloads(entries, 1);
124
+ expect(plan.keep.map((e) => e.vers)).toContain('1.0.3+5');
125
+ expect(plan.keep.map((e) => e.vers)).toContain('1.0.3+7');
126
+ });
127
+
128
+ test('retains the last NON-yanked line, which is what clients resolve to', () => {
129
+ const entries = [entry('1.0.3+5'), entry('1.0.3+6'), entry('1.0.3+7', true)];
130
+ const plan = planWithPayloads(entries, 1);
131
+ expect(plan.keep.map((e) => e.vers)).toContain('1.0.3+6');
132
+ });
133
+
134
+ test('keeps a version it cannot parse rather than deleting it', () => {
135
+ const entries = [entry('nonsense'), entry('1.0.0+1'), entry('1.0.0+2')];
136
+ const plan = planWithPayloads(entries, 1);
137
+ expect(plan.remove).toEqual(['1.0.0+1']);
138
+ });
139
+
140
+ test('reports a payload with no index line as an orphan', () => {
141
+ const plan = planModuleSweep('m', [entry('1.0.0+2')], ['1.0.0+1', '1.0.0+2'], {
142
+ keepBuildRevisions: 1,
143
+ });
144
+ expect(plan.orphans).toEqual(['1.0.0+1']);
145
+ });
146
+ });
147
+
148
+ describe('sweep over a fixture store', () => {
149
+ test('index and payloads agree afterwards, and every release survives', () => {
150
+ for (const rev of [1, 2, 3, 4, 5]) publish('celilo-registry', `1.0.3+${rev}`);
151
+ for (const rev of [1, 2]) publish('celilo-registry', `1.0.2+${rev}`);
152
+ publish('forgejo', '2.1.0+1');
153
+
154
+ const report = sweep(storage, { keepBuildRevisions: DEFAULT_KEEP_BUILD_REVISIONS });
155
+
156
+ expect(report.removedCount).toBe(5);
157
+ expectIndexAndPayloadsAgree('celilo-registry');
158
+ expectIndexAndPayloadsAgree('forgejo');
159
+ // Original index order is preserved: the fixture published 1.0.3 before
160
+ // 1.0.2, and the sweep filters lines rather than re-sorting them.
161
+ expect(storage.readIndex('celilo-registry').map((e) => e.vers)).toEqual(['1.0.3+5', '1.0.2+2']);
162
+ expect(storage.readIndex('forgejo').map((e) => e.vers)).toEqual(['2.1.0+1']);
163
+ });
164
+
165
+ test('reclaims the bytes it says it reclaims', () => {
166
+ publish('m', '1.0.0+1', 4096);
167
+ publish('m', '1.0.0+2', 4096);
168
+ const report = sweep(storage, { keepBuildRevisions: 1 });
169
+ expect(report.reclaimedBytes).toBe(4096);
170
+ });
171
+
172
+ test('a dry run changes nothing', () => {
173
+ for (const rev of [1, 2, 3]) publish('m', `1.0.0+${rev}`);
174
+ const report = sweep(storage, { keepBuildRevisions: 1 }, true);
175
+
176
+ expect(report.removedCount).toBe(2);
177
+ expect(storage.readIndex('m')).toHaveLength(3);
178
+ expect(storage.storedVersions('m').sort()).toEqual(['1.0.0+1', '1.0.0+2', '1.0.0+3']);
179
+ });
180
+
181
+ test('is idempotent — a second sweep finds nothing to do', () => {
182
+ for (const rev of [1, 2, 3]) publish('m', `1.0.0+${rev}`);
183
+ sweep(storage, { keepBuildRevisions: 1 });
184
+ const second = sweep(storage, { keepBuildRevisions: 1 });
185
+
186
+ expect(second.removedCount).toBe(0);
187
+ expect(second.orphanCount).toBe(0);
188
+ expect(second.reclaimedBytes).toBe(0);
189
+ expectIndexAndPayloadsAgree('m');
190
+ });
191
+
192
+ test('a sweep interrupted after the index rewrite self-heals on the next run', () => {
193
+ for (const rev of [1, 2, 3]) publish('m', `1.0.0+${rev}`);
194
+ // The crash window: index trimmed, payloads not yet unlinked. The version
195
+ // is unlisted but still on disk — wasted bytes, not a broken registry.
196
+ storage.updateIndex('m', storage.readIndex('m').slice(-1));
197
+ expect(storage.storedVersions('m')).toHaveLength(3);
198
+
199
+ const report = sweep(storage, { keepBuildRevisions: 1 });
200
+
201
+ expect(report.orphanCount).toBe(2);
202
+ expectIndexAndPayloadsAgree('m');
203
+ });
204
+
205
+ test('a version left unlisted stays downloadable until its payload goes', () => {
206
+ // Why index-first is the safe order: the download route reads the payload
207
+ // directly and never consults the index, so the window an interrupted
208
+ // sweep opens serves stale-but-valid bytes rather than 404ing.
209
+ publish('m', '1.0.0+1');
210
+ storage.updateIndex('m', []);
211
+ expect(storage.readPackage('m', '1.0.0+1')).not.toBeNull();
212
+ });
213
+
214
+ test('reclaims a half-written publish, which is what makes the version publishable again', () => {
215
+ // Both states below exist on the production registry, created 2026-08-22 by
216
+ // real failures: an ENOSPC that killed a run after storePackage, and an
217
+ // EACCES on appendIndex. handlePublish has no rollback between the two.
218
+ //
219
+ // The consequence is worse than wasted bytes: `packageExists` reads the
220
+ // FILESYSTEM, so the orphan makes its own version permanently
221
+ // unpublishable — "versions are immutable". Removing the orphan is the
222
+ // repair, and nothing else in the product performs it.
223
+ storage.storePackage('celilo-mgmt', '0.6.2+2', Buffer.alloc(2048, 1));
224
+ publish('celilo-mgmt', '0.6.1+1');
225
+ expect(storage.packageExists('celilo-mgmt', '0.6.2+2')).toBe(true);
226
+
227
+ const report = sweep(storage, { keepBuildRevisions: 1 });
228
+
229
+ expect(report.orphanCount).toBe(1);
230
+ expect(storage.packageExists('celilo-mgmt', '0.6.2+2')).toBe(false);
231
+ expectIndexAndPayloadsAgree('celilo-mgmt');
232
+ });
233
+
234
+ test('REPORTS an index line with no payload and does not remove it', () => {
235
+ // The dangerous direction. Removing the line would actually repair the
236
+ // module — a dangling entry already 404s — but a missing payload is
237
+ // indistinguishable from an unreadable store, so it is reported only.
238
+ publish('m', '1.0.0+1');
239
+ publish('m', '1.0.0+2');
240
+ storage.removePackage('m', '1.0.0+1');
241
+
242
+ const report = sweep(storage, { keepBuildRevisions: 1 });
243
+
244
+ expect(report.danglingCount).toBe(1);
245
+ expect(report.modules[0]?.dangling).toEqual(['1.0.0+1']);
246
+ // Still listed: the sweep reported it rather than acting on it.
247
+ expect(storage.readIndex('m').map((e) => e.vers)).toContain('1.0.0+1');
248
+ });
249
+
250
+ test('touches nothing when a module has index lines and no payloads at all', () => {
251
+ // An unmounted volume or a wrong DATA_DIR looks exactly like this. Acting
252
+ // on it would trim the index of a module whose files are merely
253
+ // unreachable.
254
+ for (const rev of [1, 2, 3]) publish('m', `1.0.0+${rev}`);
255
+ for (const rev of [1, 2, 3]) storage.removePackage('m', `1.0.0+${rev}`);
256
+
257
+ const report = sweep(storage, { keepBuildRevisions: 1 });
258
+
259
+ expect(report.unreadable).toEqual(['m']);
260
+ expect(report.removedCount).toBe(0);
261
+ expect(storage.readIndex('m')).toHaveLength(3);
262
+ });
263
+
264
+ test('a dangling entry does not stop the rest of the sweep', () => {
265
+ publish('m', '1.0.0+1');
266
+ publish('m', '1.0.0+2');
267
+ publish('m', '1.0.0+3');
268
+ storage.removePackage('m', '1.0.0+1');
269
+
270
+ const report = sweep(storage, { keepBuildRevisions: 1 });
271
+
272
+ expect(report.danglingCount).toBe(1);
273
+ // 1.0.0+2 is superseded and still had its payload, so it is swept normally.
274
+ expect(report.removedCount).toBe(1);
275
+ expect(storage.readIndex('m').map((e) => e.vers)).toEqual(['1.0.0+1', '1.0.0+3']);
276
+ });
277
+
278
+ test('never sweeps the last SERVABLE version because a newer line is dangling', () => {
279
+ // The failure this guards: retention computed over every index line lets a
280
+ // dangling NEWEST entry satisfy both the per-release rule and the
281
+ // latest-line rule, so every servable older version reads as superseded.
282
+ // The module ends up listed, with one index line, and undownloadable.
283
+ publish('m', '1.0.0+1');
284
+ publish('m', '1.0.0+2');
285
+ publish('m', '1.0.0+3');
286
+ storage.removePackage('m', '1.0.0+3'); // the newest line is now dangling
287
+
288
+ sweep(storage, { keepBuildRevisions: 1 });
289
+
290
+ // Something is still downloadable, which is the whole point.
291
+ const servable = storage
292
+ .readIndex('m')
293
+ .map((e) => e.vers)
294
+ .filter((v) => storage.packageExists('m', v));
295
+ expect(servable.length).toBeGreaterThan(0);
296
+ expect(servable).toContain('1.0.0+2');
297
+ });
298
+
299
+ test('an empty store sweeps cleanly', () => {
300
+ const report = sweep(storage, { keepBuildRevisions: 1 });
301
+ expect(report.removedCount).toBe(0);
302
+ expect(report.modules).toEqual([]);
303
+ });
304
+ });
305
+
306
+ describe('updateIndex atomicity', () => {
307
+ test('leaves no temp file behind', () => {
308
+ publish('m', '1.0.0+1');
309
+ storage.updateIndex('m', storage.readIndex('m'));
310
+ expect(existsSync(`${storage.indexPath('m')}.tmp`)).toBe(false);
311
+ });
312
+
313
+ test('a write that cannot land leaves the original index intact', () => {
314
+ publish('m', '1.0.0+1');
315
+ publish('m', '1.0.0+2');
316
+ const before = storage.readIndex('m');
317
+ // Block the temp path so the new content cannot be written. A truncating
318
+ // in-place write ignores the obstruction and replaces the index anyway;
319
+ // the rename path fails with the original still on disk. This stands in
320
+ // for the failure that actually matters — ENOSPC on the full disk this
321
+ // sweep exists to relieve — which needs an fs seam to reproduce directly.
322
+ mkdirSync(`${storage.indexPath('m')}.tmp`, { recursive: true });
323
+ expect(() => storage.updateIndex('m', before.slice(0, 1))).toThrow();
324
+ expect(storage.readIndex('m')).toEqual(before);
325
+ });
326
+ });
@@ -0,0 +1,289 @@
1
+ /**
2
+ * Reclaiming disk from superseded build revisions.
3
+ *
4
+ * The registry had no delete path at all: `yank` sets a boolean in the index
5
+ * and frees nothing, so every revision ever published was retained forever.
6
+ * A `+N` build revision is auto-assigned on every publish, so the store grew
7
+ * with every release until the disk filled and the release pipeline stopped.
8
+ *
9
+ * ── What the policy keeps, and why it cannot break a fetch ──
10
+ *
11
+ * A version is `X.Y.Z+N`: a released semver plus a build revision. The bloat
12
+ * is the rebuilds, not the releases — a module embedding compiled binaries
13
+ * republished twenty times carries twenty ~73 MB payloads of one release.
14
+ *
15
+ * So the sweep keeps the newest `keepBuildRevisions` of EVERY release and
16
+ * drops the rest. No release ever disappears, which is what makes the safety
17
+ * argument checkable rather than a judgement call:
18
+ *
19
+ * - The only version a celilo client can ask for is the one
20
+ * `RegistryClient.latestVersion` picks — the last non-yanked line in the
21
+ * index. There is no `--version` flag, no lockfile, and `IndexEntry.deps`
22
+ * is always `[]`, so nothing pins a specific revision.
23
+ * - {@link planModuleSweep} retains that line explicitly, on top of the
24
+ * per-release rule, so the property holds even for an index whose lines
25
+ * are out of publish order.
26
+ * - Every release keeps a downloadable payload, so an operator rolling back
27
+ * to an older release still can.
28
+ *
29
+ * ── Ordering, and why it is index-first ──
30
+ *
31
+ * The download route reads the payload file directly and never consults the
32
+ * index. That asymmetry decides the order:
33
+ *
34
+ * - index line first, then payload — the window is "unlisted but still
35
+ * served". Nothing 404s. A crash here leaves an orphan payload, which
36
+ * wastes the disk we are reclaiming but is otherwise correct.
37
+ * - payload first, then index — the window is "listed but 404s", an
38
+ * actively wrong registry. A crash here leaves it that way.
39
+ *
40
+ * So: index first. The orphan a crash leaves behind is then reclaimed by the
41
+ * NEXT run, which is what makes the sweep idempotent and safe to interrupt.
42
+ * The orphan pass runs FIRST for a second reason: it is pure unlink and needs
43
+ * no free space, so it can still make progress on a disk with nothing left to
44
+ * write with — the state that produced this code.
45
+ */
46
+
47
+ import type { IndexEntry, RegistryStorage } from './storage';
48
+
49
+ export interface SweepPolicy {
50
+ /**
51
+ * Build revisions to keep per released semver. Must be >= 1 — keeping zero
52
+ * would delete a whole release, which this policy never does.
53
+ */
54
+ keepBuildRevisions: number;
55
+ }
56
+
57
+ export const DEFAULT_KEEP_BUILD_REVISIONS = 1;
58
+
59
+ export interface ModuleSweepPlan {
60
+ name: string;
61
+ /** Index entries to retain, in their original order. */
62
+ keep: IndexEntry[];
63
+ /** Versions whose index line and payload both go. */
64
+ remove: string[];
65
+ /**
66
+ * Payloads on disk with no index line. Unreachable by search, and the
67
+ * publisher's immutability check still sees them — `packageExists` reads the
68
+ * FILESYSTEM, so an orphan makes its version unpublishable forever with no
69
+ * repair path anywhere else in the product. Safe to delete, and deleting it
70
+ * IS the repair.
71
+ */
72
+ orphans: string[];
73
+ /**
74
+ * Index lines whose payload is missing. REPORTED, NEVER REMOVED.
75
+ *
76
+ * This is the direction the sweep must never create, and the reason it takes
77
+ * the index line before the payload. Removing the line would in fact repair
78
+ * the module — a dangling entry already 404s on download, and dropping it
79
+ * makes `latestVersion` resolve to a version that can actually be served.
80
+ * It is left alone anyway, because a missing payload and an unreadable store
81
+ * are indistinguishable from here: an unmounted volume or a wrong DATA_DIR
82
+ * makes every payload look absent, and an auto-remove would then delete the
83
+ * whole index. Reporting costs nothing and cannot destroy anything.
84
+ */
85
+ dangling: string[];
86
+ /**
87
+ * The module's index lists versions and NOT ONE has a payload on disk. That
88
+ * is a broken or unmounted store rather than N independent losses, so the
89
+ * sweep does nothing at all to this module — see {@link ModuleSweepPlan.dangling}.
90
+ */
91
+ unreadable: boolean;
92
+ }
93
+
94
+ export interface SweepReport {
95
+ modules: Array<{
96
+ name: string;
97
+ removed: string[];
98
+ orphans: string[];
99
+ dangling: string[];
100
+ bytes: number;
101
+ }>;
102
+ removedCount: number;
103
+ orphanCount: number;
104
+ /** Index lines with no payload. Reported for a human; never acted on. */
105
+ danglingCount: number;
106
+ /** Modules skipped entirely because their whole payload set is missing. */
107
+ unreadable: string[];
108
+ reclaimedBytes: number;
109
+ dryRun: boolean;
110
+ }
111
+
112
+ /**
113
+ * Split `X.Y.Z+N` (or `E:X.Y.Z+N`) into the release it belongs to and its
114
+ * build revision. Returns null for anything that does not parse — the caller
115
+ * treats that as "keep", because a version the sweep cannot reason about is
116
+ * not one it should delete.
117
+ */
118
+ export function parseRevision(vers: string): { release: string; revision: number } | null {
119
+ const plus = vers.lastIndexOf('+');
120
+ if (plus <= 0) return null;
121
+ const release = vers.slice(0, plus);
122
+ const revision = Number(vers.slice(plus + 1));
123
+ if (!Number.isInteger(revision) || revision < 0) return null;
124
+ return { release, revision };
125
+ }
126
+
127
+ /**
128
+ * Decide what one module keeps. Pure — no filesystem, no storage.
129
+ *
130
+ * `storedVersions` is what is on DISK, which is deliberately not the same set
131
+ * as `entries`: a previous interrupted run can leave a payload whose index
132
+ * line is already gone, and reclaiming it is the whole reason this takes both.
133
+ */
134
+ export function planModuleSweep(
135
+ name: string,
136
+ entries: IndexEntry[],
137
+ storedVersions: string[],
138
+ policy: SweepPolicy,
139
+ ): ModuleSweepPlan {
140
+ const keepBuildRevisions = Math.max(1, Math.trunc(policy.keepBuildRevisions));
141
+
142
+ const stored = new Set(storedVersions);
143
+ const dangling = entries.filter((e) => !stored.has(e.vers)).map((e) => e.vers);
144
+
145
+ // Every listed version missing its payload is one broken store, not N
146
+ // independent losses. Touch nothing.
147
+ if (entries.length > 0 && dangling.length === entries.length) {
148
+ return { name, keep: entries, remove: [], orphans: [], dangling, unreadable: true };
149
+ }
150
+
151
+ /**
152
+ * Retention is computed over the SERVABLE entries only — the ones whose
153
+ * payload is actually there.
154
+ *
155
+ * Doing it over every index line is subtly wrong in a way that bites exactly
156
+ * when the store is already damaged. If a module's NEWEST line is dangling,
157
+ * that line satisfies both the per-release rule and the latest-line rule, so
158
+ * every older version — the servable ones — becomes "superseded" and is
159
+ * swept. The module is then listed, has one index line, and cannot be
160
+ * downloaded at all. The sweep would not have created the dangling entry,
161
+ * but it would have removed the last thing that still worked.
162
+ */
163
+ const servable = entries.filter((e) => stored.has(e.vers));
164
+
165
+ const retained = new Set<string>();
166
+
167
+ // The newest `keepBuildRevisions` of each release.
168
+ const byRelease = new Map<string, Array<{ vers: string; revision: number }>>();
169
+ for (const entry of servable) {
170
+ const parsed = parseRevision(entry.vers);
171
+ if (!parsed) {
172
+ // Unparseable — never a publish this server accepted, so it is hand-
173
+ // written state. Keep it and move on.
174
+ retained.add(entry.vers);
175
+ continue;
176
+ }
177
+ const group = byRelease.get(parsed.release) ?? [];
178
+ group.push({ vers: entry.vers, revision: parsed.revision });
179
+ byRelease.set(parsed.release, group);
180
+ }
181
+ for (const group of byRelease.values()) {
182
+ group.sort((a, b) => b.revision - a.revision);
183
+ for (const { vers } of group.slice(0, keepBuildRevisions)) retained.add(vers);
184
+ }
185
+
186
+ // What every client actually resolves to, retained explicitly rather than
187
+ // inferred from the rule above. The rule already covers it for an index in
188
+ // publish order; stating it means a disordered or hand-edited index cannot
189
+ // turn into an unfetchable module.
190
+ const lastServable = servable.at(-1);
191
+ if (lastServable) retained.add(lastServable.vers);
192
+ const lastUnyanked = [...servable].reverse().find((e) => !e.yanked);
193
+ if (lastUnyanked) retained.add(lastUnyanked.vers);
194
+
195
+ // Only ever removes something it can see. A dangling line is left in the
196
+ // index untouched and reported instead.
197
+ const remove = servable.filter((e) => !retained.has(e.vers)).map((e) => e.vers);
198
+ const removing = new Set(remove);
199
+ const indexed = new Set(entries.map((e) => e.vers));
200
+
201
+ return {
202
+ name,
203
+ keep: entries.filter((e) => !removing.has(e.vers)),
204
+ remove,
205
+ orphans: storedVersions.filter((v) => !indexed.has(v)),
206
+ dangling,
207
+ unreadable: false,
208
+ };
209
+ }
210
+
211
+ /**
212
+ * Apply one module's plan. Orphans first (needs no free space), then the
213
+ * index rewrite, then the payloads the rewrite just unlisted.
214
+ */
215
+ function applyModuleSweep(
216
+ storage: RegistryStorage,
217
+ plan: ModuleSweepPlan,
218
+ dryRun: boolean,
219
+ ): number {
220
+ let bytes = 0;
221
+
222
+ for (const version of plan.orphans) {
223
+ bytes += storage.packageSize(plan.name, version);
224
+ if (!dryRun) storage.removePackage(plan.name, version);
225
+ }
226
+
227
+ for (const version of plan.remove) {
228
+ bytes += storage.packageSize(plan.name, version);
229
+ }
230
+
231
+ if (plan.remove.length > 0 && !dryRun) {
232
+ storage.updateIndex(plan.name, plan.keep);
233
+ for (const version of plan.remove) storage.removePackage(plan.name, version);
234
+ }
235
+
236
+ return bytes;
237
+ }
238
+
239
+ /** Sweep every module in the store. */
240
+ export function sweep(storage: RegistryStorage, policy: SweepPolicy, dryRun = false): SweepReport {
241
+ const modules: SweepReport['modules'] = [];
242
+ let removedCount = 0;
243
+ let orphanCount = 0;
244
+ let reclaimedBytes = 0;
245
+
246
+ const unreadable: string[] = [];
247
+ let danglingCount = 0;
248
+
249
+ for (const name of storage.storedNames()) {
250
+ const plan = planModuleSweep(
251
+ name,
252
+ storage.readIndex(name),
253
+ storage.storedVersions(name),
254
+ policy,
255
+ );
256
+
257
+ if (plan.unreadable) {
258
+ unreadable.push(name);
259
+ continue;
260
+ }
261
+
262
+ danglingCount += plan.dangling.length;
263
+ if (plan.remove.length === 0 && plan.orphans.length === 0 && plan.dangling.length === 0) {
264
+ continue;
265
+ }
266
+
267
+ const bytes = applyModuleSweep(storage, plan, dryRun);
268
+ modules.push({
269
+ name,
270
+ removed: plan.remove,
271
+ orphans: plan.orphans,
272
+ dangling: plan.dangling,
273
+ bytes,
274
+ });
275
+ removedCount += plan.remove.length;
276
+ orphanCount += plan.orphans.length;
277
+ reclaimedBytes += bytes;
278
+ }
279
+
280
+ return {
281
+ modules,
282
+ removedCount,
283
+ orphanCount,
284
+ danglingCount,
285
+ unreadable,
286
+ reclaimedBytes,
287
+ dryRun,
288
+ };
289
+ }
@@ -22,6 +22,9 @@
22
22
  export const CONSUMER_PATHS = [
23
23
  'packages/e2e/**',
24
24
  'packages/registry-server/**',
25
+ // The smoke test IS this gate's body — the only place publishModule and
26
+ // friends run in the consumer install shape (celilo#1142).
27
+ 'e2e/tests/smoke.test.ts',
25
28
  // The publish driver (moved from the dead apps/celilo/scripts/publish.ts).
26
29
  'apps/celilo/src/cli/commands/publish/**',
27
30
  'scripts/publish.ts',
@@ -50,6 +50,25 @@ const SCANNED_PATHS = ['packages/e2e', 'e2e/tests', 'modules/*/e2e'];
50
50
  */
51
51
  const EXCLUDED = [/^packages\/e2e\/CHANGELOG\.md$/, /address-plan\.test\.ts$/];
52
52
 
53
+ /**
54
+ * A single LINE may exempt itself with `#539-ok: <reason>`.
55
+ *
56
+ * There is one legitimate reason to write a banned prefix: enumerating an
57
+ * RFC 1918 block as a CLASS, which any correct private-address predicate must
58
+ * do. `192.168.` is both the operator's LAN (banned) and 192.168.0.0/16
59
+ * (unavoidable), and RETIRED_PREFIXES cannot tell them apart. The tell that
60
+ * this is the prefix list's blind spot rather than a real judgement: `10.` sits
61
+ * beside `192.168.` in exactly such a predicate and escapes only because `10.`
62
+ * was never added to the list.
63
+ *
64
+ * Deliberately per-line and not per-file. Excluding a file would blind the gate
65
+ * to every OTHER address in it, which is how a suppression becomes the next
66
+ * leak. The reason is required — a marker with nothing after the colon does not
67
+ * match, because an exemption that names only a line number is the next
68
+ * reader's mystery.
69
+ */
70
+ const INLINE_EXEMPTION = /#539-ok:\s*\S/;
71
+
53
72
  function grepRetired(): string[] {
54
73
  const pattern = RETIRED_PREFIXES.map(toPattern).join('|');
55
74
  let out = '';
@@ -66,7 +85,8 @@ function grepRetired(): string[] {
66
85
  return out
67
86
  .split('\n')
68
87
  .filter(Boolean)
69
- .filter((line) => !EXCLUDED.some((re) => re.test(line.split(':')[0])));
88
+ .filter((line) => !EXCLUDED.some((re) => re.test(line.split(':')[0] as string)))
89
+ .filter((line) => !INLINE_EXEMPTION.test(line));
70
90
  }
71
91
 
72
92
  describe('simulated address plan (#539)', () => {