@mmnto/cli 1.89.0 → 1.91.0

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,765 @@
1
+ /**
2
+ * Tests for `totem ecl-gc` (mmnto-ai/totem#2279; parent mmnto-ai/totem-strategy#700).
3
+ *
4
+ * Filesystem-driven with an injected clock: every test builds a fresh
5
+ * `<tmp>/.totem/orchestration/<agent>/outbox/` tree, exercises `eclGc`, and
6
+ * asserts on the structured `EclGcResult` AND on the on-disk aftermath (which
7
+ * files survived). The safety rows (peer immunity, non-outbox trees untouched,
8
+ * ambiguity-throws-before-deletion, exact-boundary retention) are written to
9
+ * FAIL if their guard were removed — see the per-test non-vacuity notes.
10
+ */
11
+ import * as fs from 'node:fs';
12
+ import * as os from 'node:os';
13
+ import * as path from 'node:path';
14
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
15
+ import { TotemConfigError, TotemConfigSchema } from '@mmnto/totem';
16
+ import { cleanTmpDir } from '../test-utils.js';
17
+ import { classifyEntry, cutoffKey, eclCompact, eclGc, loadEclConfig, planPrune, resolveEclGcExitCode, resolveExpectedRoster, toStampKey, } from './ecl-gc.js';
18
+ /** Build a real (schema-validated) `TotemConfig` for roster-resolution tests —
19
+ * optionally with an `ecl.cohortRepos` roster. No casts: exercises the actual
20
+ * schema so the fixture can never drift from the shipped shape.
21
+ * `emptyEclBlock` yields the DISTINCT block-present-key-omitted state
22
+ * (`ecl: {}`) — schema-valid, still undeclared (greptile on PR #2315: the
23
+ * no-ecl-key and empty-ecl-block states must each be exercised as named). */
24
+ function cfg(cohortRepos, opts) {
25
+ return TotemConfigSchema.parse({
26
+ targets: [{ glob: '**/*.md', type: 'spec', strategy: 'markdown-heading' }],
27
+ ...(cohortRepos ? { ecl: { cohortRepos } } : opts?.emptyEclBlock === true ? { ecl: {} } : {}),
28
+ });
29
+ }
30
+ // `vi.spyOn` cannot rebind a frozen ESM module-namespace export (node:fs), so
31
+ // the partial-delete-failure row drives a module mock instead: a hoisted
32
+ // fail-set makes the mocked `unlinkSync` throw for named files and pass through
33
+ // to the real implementation for everything else. All other fs calls stay real.
34
+ const fsMockState = vi.hoisted(() => ({ failFor: new Set() }));
35
+ vi.mock('node:fs', async (importOriginal) => {
36
+ const actual = await importOriginal();
37
+ return {
38
+ ...actual,
39
+ default: actual,
40
+ unlinkSync: (p, ...rest) => {
41
+ const name = String(p);
42
+ for (const suffix of fsMockState.failFor) {
43
+ if (name.endsWith(suffix)) {
44
+ throw new Error('EPERM: simulated locked file');
45
+ }
46
+ }
47
+ return actual.unlinkSync(p, ...rest);
48
+ },
49
+ };
50
+ });
51
+ // `loadEclConfig` (the config-read seam) is the only path touching `../utils.js`;
52
+ // override just its two functions (spread-actual passthrough keeps every other
53
+ // utils export real, so the fs-driven prune/compact tests are unaffected). Lets
54
+ // the missing-vs-invalid distinction be tested without a real config on disk.
55
+ const utilsMock = vi.hoisted(() => ({
56
+ resolveConfigPath: vi.fn(),
57
+ loadConfig: vi.fn(),
58
+ }));
59
+ vi.mock('../utils.js', async (importOriginal) => {
60
+ const actual = await importOriginal();
61
+ return {
62
+ ...actual,
63
+ resolveConfigPath: utilsMock.resolveConfigPath,
64
+ loadConfig: utilsMock.loadConfig,
65
+ };
66
+ });
67
+ // ─── Fixtures ───────────────────────────────────────────
68
+ // Fixed clock so cutoffs are deterministic (Tenet 15). 14-day default window
69
+ // puts the cutoff at 2026-06-21T12:00:00Z → key `20260621120000`.
70
+ const NOW = new Date('2026-07-05T12:00:00.000Z');
71
+ const nowFn = () => NOW;
72
+ const CUTOFF_KEY = '20260621120000';
73
+ let tmpRoot;
74
+ function mkDir(p) {
75
+ fs.mkdirSync(p, { recursive: true });
76
+ return p;
77
+ }
78
+ function orchDir(agent, sub) {
79
+ return path.join(tmpRoot, '.totem', 'orchestration', agent, sub);
80
+ }
81
+ /** Write named `.md`-ish files into `<agent>/<sub>/` with dispatch-shaped content. */
82
+ function writeFiles(agent, sub, names) {
83
+ const dir = mkDir(orchDir(agent, sub));
84
+ for (const name of names) {
85
+ fs.writeFileSync(path.join(dir, name), '---\nto: someone\n---\n\nbody\n', 'utf-8');
86
+ }
87
+ return dir;
88
+ }
89
+ function exists(agent, sub, name) {
90
+ return fs.existsSync(path.join(orchDir(agent, sub), name));
91
+ }
92
+ function run(opts = {}) {
93
+ return eclGc({ repoRoot: tmpRoot, env: {}, now: nowFn, ...opts });
94
+ }
95
+ // Aged (< cutoff) and fresh (>= cutoff) stamp names for the default 14d window.
96
+ const AGED_4 = '2026-06-01T1200Z-totem-gemini.md'; // 20260601120000 < cutoff
97
+ const AGED_6 = '2026-06-01T120000Z-totem-gemini.md'; // dual-form, same instant, aged
98
+ const AGED_2 = '2026-05-15T0930Z-strategy-claude.md'; // 20260515093000 < cutoff
99
+ const FRESH_4 = '2026-07-01T1200Z-totem-gemini.md'; // 20260701120000 >= cutoff
100
+ const BOUNDARY_KEEP = '2026-06-21T120000Z-totem-gemini.md'; // === cutoff → kept
101
+ const BOUNDARY_PRUNE = '2026-06-21T115959Z-totem-gemini.md'; // 1s older → pruned
102
+ beforeEach(() => {
103
+ tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-eclgc-'));
104
+ // Since mmnto-ai/totem#2312 `eclGc`/`eclCompact` derive the repo root by
105
+ // walking UP to the nearest `.totem`/`.git` marker. Plant markers at the two
106
+ // fixture roots (`tmpRoot` for the prune, `<tmpRoot>/totem` for the compact)
107
+ // so the walk anchors there instead of climbing to a host-level ancestor
108
+ // marker (e.g. `~/.totem`) — the same fixture requirement `selfRepoRoot` gained.
109
+ mkDir(path.join(tmpRoot, '.totem'));
110
+ mkDir(path.join(tmpRoot, 'totem', '.totem'));
111
+ });
112
+ afterEach(() => {
113
+ fsMockState.failFor.clear();
114
+ utilsMock.resolveConfigPath.mockReset();
115
+ utilsMock.loadConfig.mockReset();
116
+ vi.restoreAllMocks();
117
+ cleanTmpDir(tmpRoot);
118
+ });
119
+ // ─── Pure helpers ───────────────────────────────────────
120
+ describe('pure helpers', () => {
121
+ it('toStampKey canonicalizes both stamp forms to a 14-digit key', () => {
122
+ expect(toStampKey('2026-06-01T1200Z')).toBe('20260601120000');
123
+ expect(toStampKey('2026-06-01T120000Z')).toBe('20260601120000');
124
+ });
125
+ it('cutoffKey derives now − retainDays as a 14-digit key', () => {
126
+ expect(cutoffKey(NOW, 14)).toBe(CUTOFF_KEY);
127
+ // retainDays 0 → cutoff is `now` itself.
128
+ expect(cutoffKey(NOW, 0)).toBe('20260705120000');
129
+ });
130
+ it('classifyEntry: file-type guard runs before the extension/stamp checks', () => {
131
+ // A directory that LOOKS aged + `.md` must still be skipped, never pruned.
132
+ expect(classifyEntry({ name: AGED_4, isFile: false }, CUTOFF_KEY)).toEqual({
133
+ action: 'skip',
134
+ reason: 'not a regular file',
135
+ });
136
+ expect(classifyEntry({ name: 'x.tmp', isFile: true }, CUTOFF_KEY)).toEqual({
137
+ action: 'skip',
138
+ reason: 'not a .md dispatch',
139
+ });
140
+ expect(classifyEntry({ name: 'not-a-stamp.md', isFile: true }, CUTOFF_KEY)).toEqual({
141
+ action: 'skip',
142
+ reason: 'unparseable stamp',
143
+ });
144
+ expect(classifyEntry({ name: AGED_4, isFile: true }, CUTOFF_KEY)).toEqual({ action: 'prune' });
145
+ expect(classifyEntry({ name: BOUNDARY_KEEP, isFile: true }, CUTOFF_KEY)).toEqual({
146
+ action: 'keep',
147
+ });
148
+ });
149
+ it('planPrune partitions a listing deterministically', () => {
150
+ const plan = planPrune([
151
+ { name: FRESH_4, isFile: true },
152
+ { name: AGED_4, isFile: true },
153
+ { name: 'x.tmp', isFile: true },
154
+ { name: 'sub', isFile: false },
155
+ ], CUTOFF_KEY);
156
+ expect(plan.prune).toEqual([AGED_4]);
157
+ expect(plan.kept).toBe(1);
158
+ expect(plan.skipped).toEqual([
159
+ { file: 'sub', reason: 'not a regular file' },
160
+ { file: 'x.tmp', reason: 'not a .md dispatch' },
161
+ ]);
162
+ });
163
+ });
164
+ // ─── LOAD-BEARING safety rows ───────────────────────────
165
+ describe('safety invariants (load-bearing)', () => {
166
+ it('row 1 — peer immunity: pruning seat A never touches seat B', () => {
167
+ writeFiles('seat-a', 'outbox', [AGED_4]);
168
+ writeFiles('seat-b', 'outbox', [AGED_2]);
169
+ const result = run({ apply: true, env: { TOTEM_SELF_AGENT: 'seat-a' } });
170
+ expect(result.agent).toBe('seat-a');
171
+ expect(result.pruned).toEqual([AGED_4]);
172
+ expect(exists('seat-a', 'outbox', AGED_4)).toBe(false);
173
+ // NON-VACUITY: seat-b's aged file must survive — if the target were the
174
+ // whole orchestration tree instead of the resolved seat, this would fail.
175
+ expect(exists('seat-b', 'outbox', AGED_2)).toBe(true);
176
+ });
177
+ it('row 2 — never touches journal/ processed/ inbox/', () => {
178
+ writeFiles('seat-a', 'outbox', [AGED_4]);
179
+ writeFiles('seat-a', 'journal', [AGED_2]);
180
+ writeFiles('seat-a', 'processed', [AGED_2]);
181
+ writeFiles('seat-a', 'inbox', [AGED_2]);
182
+ const result = run({ apply: true, env: { TOTEM_SELF_AGENT: 'seat-a' } });
183
+ // The outbox aged file IS pruned (prune actually ran)…
184
+ expect(result.pruned).toEqual([AGED_4]);
185
+ expect(exists('seat-a', 'outbox', AGED_4)).toBe(false);
186
+ // …but the sibling trees are untouched.
187
+ // NON-VACUITY: these fail if the scan ever walked a non-outbox dir.
188
+ expect(exists('seat-a', 'journal', AGED_2)).toBe(true);
189
+ expect(exists('seat-a', 'processed', AGED_2)).toBe(true);
190
+ expect(exists('seat-a', 'inbox', AGED_2)).toBe(true);
191
+ });
192
+ it('row 3 — self-ambiguity throws BEFORE any deletion', () => {
193
+ // Two registered seat dirs, no --agent-id, no TOTEM_SELF_AGENT → ambiguous.
194
+ writeFiles('seat-a', 'outbox', [AGED_4]);
195
+ writeFiles('seat-b', 'outbox', [AGED_2]);
196
+ expect(() => run({ apply: true })).toThrow(/cannot resolve a single agent/i);
197
+ // NON-VACUITY: nothing may be deleted on the throwing path.
198
+ expect(exists('seat-a', 'outbox', AGED_4)).toBe(true);
199
+ expect(exists('seat-b', 'outbox', AGED_2)).toBe(true);
200
+ });
201
+ it('row 3b — an unsafe resolved agent-id is rejected BEFORE any deletion', () => {
202
+ // resolveSelfSender returns an explicit --agent-id verbatim, so a caller
203
+ // could hand in an id that escapes the `<orchestration>/<agent>/outbox`
204
+ // segment. The isPathSafeAgentId guard must reject it before any scan/delete.
205
+ writeFiles('seat-a', 'outbox', [AGED_4]);
206
+ expect(() => run({ apply: true, agentId: '../seat-a' })).toThrow(/invalid agent-id/i);
207
+ // NON-VACUITY: nothing may be deleted on the guard-throw path.
208
+ expect(exists('seat-a', 'outbox', AGED_4)).toBe(true);
209
+ });
210
+ it('row 4 — exact boundary retained; 1s older pruned', () => {
211
+ writeFiles('seat-a', 'outbox', [BOUNDARY_KEEP, BOUNDARY_PRUNE]);
212
+ const result = run({ apply: true, env: { TOTEM_SELF_AGENT: 'seat-a' } });
213
+ expect(result.cutoffKey).toBe(CUTOFF_KEY);
214
+ expect(result.pruned).toEqual([BOUNDARY_PRUNE]);
215
+ expect(result.kept).toBe(1);
216
+ // NON-VACUITY: a `<=` boundary would delete BOUNDARY_KEEP → this fails.
217
+ expect(exists('seat-a', 'outbox', BOUNDARY_KEEP)).toBe(true);
218
+ expect(exists('seat-a', 'outbox', BOUNDARY_PRUNE)).toBe(false);
219
+ });
220
+ });
221
+ // ─── Behavioral coverage ────────────────────────────────
222
+ describe('behavior', () => {
223
+ it('row 5 — dry-run lists would-prune but deletes nothing', () => {
224
+ writeFiles('seat-a', 'outbox', [AGED_4, FRESH_4]);
225
+ const result = run({ env: { TOTEM_SELF_AGENT: 'seat-a' } });
226
+ expect(result.dryRun).toBe(true);
227
+ expect(result.pruned).toEqual([AGED_4]);
228
+ expect(result.failed).toEqual([]);
229
+ // Nothing deleted in dry-run.
230
+ expect(exists('seat-a', 'outbox', AGED_4)).toBe(true);
231
+ expect(exists('seat-a', 'outbox', FRESH_4)).toBe(true);
232
+ });
233
+ it('row 6 — --apply deletes only the aged files; fresh kept', () => {
234
+ writeFiles('seat-a', 'outbox', [AGED_4, AGED_2, FRESH_4]);
235
+ const result = run({ apply: true, env: { TOTEM_SELF_AGENT: 'seat-a' } });
236
+ expect(result.dryRun).toBe(false);
237
+ expect(result.pruned.sort()).toEqual([AGED_2, AGED_4].sort());
238
+ expect(result.kept).toBe(1);
239
+ expect(exists('seat-a', 'outbox', AGED_4)).toBe(false);
240
+ expect(exists('seat-a', 'outbox', AGED_2)).toBe(false);
241
+ expect(exists('seat-a', 'outbox', FRESH_4)).toBe(true);
242
+ });
243
+ it('row 7 — dual-form stamps (4-digit + 6-digit) both classify correctly', () => {
244
+ writeFiles('seat-a', 'outbox', [AGED_4, AGED_6, BOUNDARY_KEEP]);
245
+ const result = run({ apply: true, env: { TOTEM_SELF_AGENT: 'seat-a' } });
246
+ // Both aged forms pruned; the 6-digit boundary kept.
247
+ expect(result.pruned.sort()).toEqual([AGED_4, AGED_6].sort());
248
+ expect(result.kept).toBe(1);
249
+ expect(exists('seat-a', 'outbox', BOUNDARY_KEEP)).toBe(true);
250
+ });
251
+ it('row 8 — malformed / non-.md / non-file entries are skipped, never deleted', () => {
252
+ const outbox = writeFiles('seat-a', 'outbox', [AGED_4, '.gitkeep', 'x.tmp', 'not-a-stamp.md']);
253
+ // A directory named like an aged dispatch — must be skipped, not pruned.
254
+ mkDir(path.join(outbox, '2026-01-01T0000Z-olddir.md'));
255
+ const result = run({ apply: true, env: { TOTEM_SELF_AGENT: 'seat-a' } });
256
+ expect(result.pruned).toEqual([AGED_4]);
257
+ const skippedFiles = result.skipped.map((s) => s.file).sort();
258
+ expect(skippedFiles).toEqual([
259
+ '.gitkeep',
260
+ '2026-01-01T0000Z-olddir.md',
261
+ 'not-a-stamp.md',
262
+ 'x.tmp',
263
+ ]);
264
+ // NON-VACUITY: the aged-looking subdir survives (file-type guard held).
265
+ expect(fs.existsSync(path.join(outbox, '2026-01-01T0000Z-olddir.md'))).toBe(true);
266
+ expect(exists('seat-a', 'outbox', '.gitkeep')).toBe(true);
267
+ expect(exists('seat-a', 'outbox', 'x.tmp')).toBe(true);
268
+ expect(exists('seat-a', 'outbox', 'not-a-stamp.md')).toBe(true);
269
+ });
270
+ it('row 9a — explicit --agent-id override resolves an otherwise-ambiguous repo', () => {
271
+ writeFiles('seat-a', 'outbox', [AGED_4]);
272
+ writeFiles('seat-b', 'outbox', [AGED_2]);
273
+ const result = run({ apply: true, agentId: 'seat-a' });
274
+ expect(result.agent).toBe('seat-a');
275
+ expect(result.pruned).toEqual([AGED_4]);
276
+ expect(exists('seat-b', 'outbox', AGED_2)).toBe(true);
277
+ });
278
+ it('row 9b — env TOTEM_SELF_AGENT single-agent resolves', () => {
279
+ writeFiles('seat-a', 'outbox', [AGED_4]);
280
+ const result = run({ apply: true, env: { TOTEM_SELF_AGENT: 'seat-a' } });
281
+ expect(result.agent).toBe('seat-a');
282
+ expect(result.pruned).toEqual([AGED_4]);
283
+ });
284
+ it('row 10 — missing outbox dir yields a clean empty result, no throw', () => {
285
+ // No outbox created for seat-a at all.
286
+ const result = run({ apply: true, env: { TOTEM_SELF_AGENT: 'seat-a' } });
287
+ expect(result.agent).toBe('seat-a');
288
+ expect(result.pruned).toEqual([]);
289
+ expect(result.kept).toBe(0);
290
+ expect(result.skipped).toEqual([]);
291
+ expect(result.failed).toEqual([]);
292
+ expect(result.outbox).toBe(path.join(tmpRoot, '.totem', 'orchestration', 'seat-a', 'outbox'));
293
+ });
294
+ it('row 11 — invalid --retain-days (negative / non-integer) is a usage throw', () => {
295
+ writeFiles('seat-a', 'outbox', [AGED_4]);
296
+ expect(() => run({ retainDays: -1, env: { TOTEM_SELF_AGENT: 'seat-a' } })).toThrow(/non-negative integer/i);
297
+ expect(() => run({ retainDays: 3.5, env: { TOTEM_SELF_AGENT: 'seat-a' } })).toThrow(/non-negative integer/i);
298
+ // NON-VACUITY: the throw is a usage error raised before any scan.
299
+ expect(exists('seat-a', 'outbox', AGED_4)).toBe(true);
300
+ });
301
+ it('row 12 — a partial delete failure is captured; other files still pruned', () => {
302
+ writeFiles('seat-a', 'outbox', [AGED_4, AGED_2]);
303
+ // The mocked unlinkSync throws for AGED_4, passes through for AGED_2.
304
+ fsMockState.failFor.add(AGED_4);
305
+ const result = run({ apply: true, env: { TOTEM_SELF_AGENT: 'seat-a' } });
306
+ expect(result.pruned).toEqual([AGED_2]);
307
+ expect(result.failed).toHaveLength(1);
308
+ expect(result.failed[0].file).toBe(AGED_4);
309
+ expect(result.failed[0].error).toMatch(/EPERM/);
310
+ // The failed file remains on disk; the succeeding one is gone.
311
+ expect(exists('seat-a', 'outbox', AGED_4)).toBe(true);
312
+ expect(exists('seat-a', 'outbox', AGED_2)).toBe(false);
313
+ });
314
+ it('retainDays 0 prunes everything strictly before now', () => {
315
+ // With N=0 the cutoff is `now`; a fresh-but-past file becomes prunable.
316
+ writeFiles('seat-a', 'outbox', [FRESH_4]);
317
+ const result = run({ apply: true, retainDays: 0, env: { TOTEM_SELF_AGENT: 'seat-a' } });
318
+ expect(result.cutoffKey).toBe('20260705120000');
319
+ expect(result.pruned).toEqual([FRESH_4]);
320
+ });
321
+ });
322
+ // ─── Compaction (ADR-106 § A2 / ecl-discipline § 4.5; mmnto-ai/totem#2307) ───
323
+ //
324
+ // Compaction runs across a WORKSPACE (not a single repo): `tmpRoot` IS the
325
+ // workspace, the compacting seat lives in `<tmpRoot>/totem`, and peer outboxes
326
+ // (the raw addressed-inbound) live in `<tmpRoot>/<peerRepo>/…`. The A2.2 gate
327
+ // checks that every `expectedRepos` entry is a present directory in the
328
+ // workspace, so tests inject an explicit roster and create (or omit) repo dirs
329
+ // to drive the abort arms.
330
+ const CS = 'totem-agy'; // the compacting seat
331
+ const CROSTER = ['totem', 'totem-strategy']; // injected expected roster
332
+ // Live/swept dispatch basenames (stamp-shaped so pollMail's self-priority
333
+ // bucketing sees them the same way production names are seen).
334
+ const DIRECT_LIVE = '2026-07-01T1000Z-totem-agy-alive.md';
335
+ const DIRECT_SWEPT = '2026-06-01T0900Z-totem-agy-swept.md';
336
+ const BCAST_LIVE = '2026-07-01T1001Z-broadcast-alive.md';
337
+ const BCAST_SWEPT = '2026-06-01T0800Z-broadcast-swept.md';
338
+ function compactRoot() {
339
+ return path.join(tmpRoot, 'totem');
340
+ }
341
+ function processedPath(agent, name, broadcast = false) {
342
+ const dir = broadcast
343
+ ? path.join(compactRoot(), '.totem', 'orchestration', agent, 'processed', '_broadcast')
344
+ : path.join(compactRoot(), '.totem', 'orchestration', agent, 'processed');
345
+ return path.join(dir, name);
346
+ }
347
+ /** Write a processed MARK for `agent` (direct or broadcast store). */
348
+ function writeMark(agent, name, broadcast = false) {
349
+ const p = processedPath(agent, name, broadcast);
350
+ mkDir(path.dirname(p));
351
+ fs.writeFileSync(p, 'x', 'utf-8');
352
+ }
353
+ function markExists(agent, name, broadcast = false) {
354
+ return fs.existsSync(processedPath(agent, name, broadcast));
355
+ }
356
+ /** Write an INBOUND dispatch (raw addressed-inbound) in a peer repo's outbox. */
357
+ function writeInbound(repo, sender, name, to) {
358
+ const dir = path.join(tmpRoot, repo, '.totem', 'orchestration', sender, 'outbox');
359
+ mkDir(dir);
360
+ fs.writeFileSync(path.join(dir, name), `---\nto: ${to}\nfrom: ${sender}\n---\n\nbody\n`, 'utf-8');
361
+ }
362
+ /** Ensure each expected roster repo exists as a directory in the workspace. */
363
+ function ensureRepos(repos) {
364
+ for (const r of repos)
365
+ mkDir(path.join(tmpRoot, r));
366
+ }
367
+ function runCompact(opts = {}) {
368
+ return eclCompact({
369
+ repoRoot: compactRoot(),
370
+ workspace: tmpRoot,
371
+ env: { TOTEM_SELF_AGENT: CS },
372
+ expectedRepos: CROSTER,
373
+ ...opts,
374
+ });
375
+ }
376
+ describe('compaction — cursor-coupled GC (A2.1–A2.4)', () => {
377
+ it('C1 — canonical fixture: swept marks collected, live marks retained (direct + broadcast)', () => {
378
+ ensureRepos(CROSTER);
379
+ // Live dispatches still present in a peer outbox (their marks are load-bearing).
380
+ writeInbound('totem-strategy', 'strategy-claude', DIRECT_LIVE, CS);
381
+ writeInbound('totem-strategy', 'strategy-claude', BCAST_LIVE, 'broadcast');
382
+ // Four marks: two live (retain), two swept (collect).
383
+ writeMark(CS, DIRECT_LIVE);
384
+ writeMark(CS, DIRECT_SWEPT);
385
+ writeMark(CS, BCAST_LIVE, true);
386
+ writeMark(CS, BCAST_SWEPT, true);
387
+ const r = runCompact({ apply: true });
388
+ expect(r.gateComplete).toBe(true);
389
+ expect(r.collected.sort()).toEqual([BCAST_SWEPT, DIRECT_SWEPT].sort());
390
+ // Live marks survive; swept marks gone.
391
+ expect(markExists(CS, DIRECT_LIVE)).toBe(true);
392
+ expect(markExists(CS, BCAST_LIVE, true)).toBe(true);
393
+ expect(markExists(CS, DIRECT_SWEPT)).toBe(false);
394
+ expect(markExists(CS, BCAST_SWEPT, true)).toBe(false);
395
+ // A2.4: nothing previously-handled re-surfaces.
396
+ expect(r.resurfaced).toEqual([]);
397
+ });
398
+ it('C2 — A2.1 inversion RED test: a live mark is RETAINED (naive pollMail().mail would delete it)', () => {
399
+ ensureRepos(CROSTER);
400
+ // The dispatch is present AND marked handled — so `pollMail().mail` (which
401
+ // subtracts processed) reports it ABSENT. A naive impl keyed on that list
402
+ // would collect the mark and the dispatch would re-surface on re-poll. The
403
+ // raw-addressed-inbound scan (includeProcessed) keeps it in view.
404
+ writeInbound('totem-strategy', 'strategy-claude', DIRECT_LIVE, CS);
405
+ writeMark(CS, DIRECT_LIVE);
406
+ const r = runCompact({ apply: true });
407
+ expect(r.gateComplete).toBe(true);
408
+ expect(r.collected).toEqual([]); // NON-VACUITY: naive impl deletes this mark
409
+ expect(markExists(CS, DIRECT_LIVE)).toBe(true);
410
+ expect(r.resurfaced).toEqual([]); // naive impl trips this
411
+ });
412
+ it('C3 — abort arm: a missing expected repo blocks all deletes (N < M)', () => {
413
+ // Only `totem` present; `totem-strategy` (in the roster) is absent.
414
+ ensureRepos(['totem']);
415
+ writeMark(CS, DIRECT_SWEPT);
416
+ const r = runCompact({ apply: true });
417
+ expect(r.gateComplete).toBe(false);
418
+ expect(r.gateReasons.some((x) => /missing.*totem-strategy/.test(x))).toBe(true);
419
+ expect(r.collected).toEqual([]);
420
+ // NON-VACUITY: uncertain ⇒ retain — the swept mark survives an incomplete poll.
421
+ expect(markExists(CS, DIRECT_SWEPT)).toBe(true);
422
+ });
423
+ it('C4 — abort arm: any scan/parse warning blocks all deletes', () => {
424
+ ensureRepos(CROSTER);
425
+ // A mail-shaped dispatch with no closing delimiter → pollMail parse warning.
426
+ const dir = path.join(tmpRoot, 'totem-strategy', '.totem', 'orchestration', 'strategy-claude', 'outbox');
427
+ mkDir(dir);
428
+ fs.writeFileSync(path.join(dir, '2026-07-01T1200Z-totem-agy-malformed.md'), '---\nto: totem-agy\nno closing', 'utf-8');
429
+ writeMark(CS, DIRECT_SWEPT);
430
+ const r = runCompact({ apply: true });
431
+ expect(r.gateComplete).toBe(false);
432
+ expect(r.warnings.length).toBeGreaterThan(0);
433
+ expect(r.collected).toEqual([]);
434
+ expect(markExists(CS, DIRECT_SWEPT)).toBe(true);
435
+ });
436
+ it('C5 — abort arm: scan truncation blocks all deletes', () => {
437
+ ensureRepos(CROSTER);
438
+ // 3 addressed dispatches, maxScan 2 → pollMail truncates → gate red.
439
+ writeInbound('totem-strategy', 'strategy-claude', DIRECT_LIVE, CS);
440
+ writeInbound('totem-strategy', 'strategy-claude', '2026-07-01T1002Z-totem-agy-b.md', CS);
441
+ writeInbound('totem-strategy', 'strategy-claude', '2026-07-01T1003Z-totem-agy-c.md', CS);
442
+ writeMark(CS, DIRECT_SWEPT);
443
+ const r = runCompact({ apply: true, maxScan: 2 });
444
+ expect(r.gateComplete).toBe(false);
445
+ expect(r.gateReasons.some((x) => /truncat/i.test(x))).toBe(true);
446
+ expect(r.collected).toEqual([]);
447
+ expect(markExists(CS, DIRECT_SWEPT)).toBe(true);
448
+ });
449
+ it('C6 — self-seat ambiguity THROWS before any scan/delete (usage, exit-2 class)', () => {
450
+ ensureRepos(CROSTER);
451
+ // Two seat dirs registered, no TOTEM_SELF_AGENT, no --agent-id → ambiguous.
452
+ writeMark('totem-agy', DIRECT_SWEPT);
453
+ writeMark('totem-claude', DIRECT_SWEPT);
454
+ expect(() => eclCompact({ repoRoot: compactRoot(), workspace: tmpRoot, env: {}, expectedRepos: CROSTER })).toThrow(/cannot resolve a single agent/i);
455
+ // NON-VACUITY: nothing deleted on the throwing path.
456
+ expect(markExists('totem-agy', DIRECT_SWEPT)).toBe(true);
457
+ expect(markExists('totem-claude', DIRECT_SWEPT)).toBe(true);
458
+ });
459
+ it('C7 — multi-seat isolation: compacting seat S never touches a peer seat’s processed/', () => {
460
+ ensureRepos(CROSTER);
461
+ // Same swept basename marked by BOTH seats; only S=totem-agy is targeted.
462
+ writeMark('totem-agy', DIRECT_SWEPT);
463
+ writeMark('totem-claude', DIRECT_SWEPT);
464
+ const r = runCompact({ apply: true });
465
+ expect(r.gateComplete).toBe(true);
466
+ expect(r.collected).toEqual([DIRECT_SWEPT]);
467
+ expect(markExists('totem-agy', DIRECT_SWEPT)).toBe(false);
468
+ // NON-VACUITY: a coordinator-union target would delete the peer's mark too.
469
+ expect(markExists('totem-claude', DIRECT_SWEPT)).toBe(true);
470
+ });
471
+ it('C8 — dry-run lists would-collect but deletes nothing', () => {
472
+ ensureRepos(CROSTER);
473
+ writeInbound('totem-strategy', 'strategy-claude', DIRECT_LIVE, CS);
474
+ writeMark(CS, DIRECT_LIVE);
475
+ writeMark(CS, DIRECT_SWEPT);
476
+ const r = runCompact(); // dry-run (no --apply)
477
+ expect(r.dryRun).toBe(true);
478
+ expect(r.gateComplete).toBe(true);
479
+ expect(r.collectable).toEqual([DIRECT_SWEPT]);
480
+ expect(r.collected).toEqual([]);
481
+ // `retained` reflects the WOULD-survive count in dry-run (marks − collectable),
482
+ // consistent with the display + apply semantics (greptile).
483
+ expect(r.marks).toBe(2);
484
+ expect(r.retained).toBe(1);
485
+ expect(markExists(CS, DIRECT_SWEPT)).toBe(true); // nothing deleted
486
+ });
487
+ it('C9 — union retention: a direct mark is retained by a live BROADCAST of the same basename', () => {
488
+ ensureRepos(CROSTER);
489
+ // Only a broadcast dispatch exists for this basename; the mark sits in the
490
+ // DIRECT store. pollMail's processed filter is recipient-class-blind, so the
491
+ // mark shadows the live broadcast dispatch — union retention keeps it.
492
+ writeInbound('totem-strategy', 'strategy-claude', BCAST_LIVE, 'broadcast');
493
+ writeMark(CS, BCAST_LIVE); // DIRECT store, matched by broadcast inbound
494
+ const r = runCompact({ apply: true });
495
+ expect(r.gateComplete).toBe(true);
496
+ expect(r.collected).toEqual([]);
497
+ expect(markExists(CS, BCAST_LIVE)).toBe(true);
498
+ });
499
+ it('C10 — other-recipient same basename does NOT retain a self mark (filter is parsed to:, not basename)', () => {
500
+ ensureRepos(CROSTER);
501
+ // A dispatch of this basename exists but is addressed to a DIFFERENT seat, so
502
+ // it is not part of S's addressed-inbound; the mark is inert → collected.
503
+ writeInbound('totem-strategy', 'strategy-claude', DIRECT_SWEPT, 'totem-gemini');
504
+ writeMark(CS, DIRECT_SWEPT);
505
+ const r = runCompact({ apply: true });
506
+ expect(r.gateComplete).toBe(true);
507
+ expect(r.collected).toEqual([DIRECT_SWEPT]);
508
+ expect(markExists(CS, DIRECT_SWEPT)).toBe(false);
509
+ });
510
+ it('C11 — a partial mark-delete failure is captured; other marks still collected', () => {
511
+ ensureRepos(CROSTER);
512
+ writeMark(CS, DIRECT_SWEPT);
513
+ writeMark(CS, BCAST_SWEPT, true);
514
+ // Mock unlinkSync to fail the direct swept mark, pass the broadcast one.
515
+ fsMockState.failFor.add(DIRECT_SWEPT);
516
+ const r = runCompact({ apply: true });
517
+ expect(r.gateComplete).toBe(true);
518
+ expect(r.collected).toEqual([BCAST_SWEPT]);
519
+ expect(r.failed).toHaveLength(1);
520
+ expect(r.failed[0].error).toMatch(/EPERM/);
521
+ expect(markExists(CS, DIRECT_SWEPT)).toBe(true); // failed delete → mark remains
522
+ expect(markExists(CS, BCAST_SWEPT, true)).toBe(false);
523
+ });
524
+ it('C12 — undeclared (empty) roster HARD-ABORTS (exit 3), never "assume complete" (strategy#828)', () => {
525
+ ensureRepos(CROSTER);
526
+ writeMark(CS, DIRECT_SWEPT); // a genuinely inert mark that WOULD be collectable
527
+ const r = runCompact({ apply: true, expectedRepos: [] });
528
+ expect(r.rosterDeclared).toBe(false);
529
+ // Folded into the A2.2 gate: no declared roster => gate red => hard-abort (exit 3),
530
+ // fail-loud (never a silent no-op) per the strategy#828 no-roster corollary.
531
+ expect(r.gateComplete).toBe(false);
532
+ expect(r.gateReasons.some((x) => /no cohort roster declared/.test(x))).toBe(true);
533
+ expect(resolveEclGcExitCode({ failed: [] }, r)).toBe(3);
534
+ expect(r.collected).toEqual([]);
535
+ // NON-VACUITY: with no declared roster, completeness is unprovable, so even a
536
+ // truly-inert mark is retained rather than deleted on an assumed-complete scan.
537
+ expect(markExists(CS, DIRECT_SWEPT)).toBe(true);
538
+ expect(r.marks).toBe(1); // still reports the seat's mark count
539
+ });
540
+ it('C13 — --force-incomplete waives the missing-repo abort; deletes proceed', () => {
541
+ // Only `totem` present; `totem-strategy` (in the roster) absent → normally aborts.
542
+ ensureRepos(['totem']);
543
+ writeMark(CS, DIRECT_SWEPT);
544
+ const r = runCompact({ apply: true, forceIncomplete: true });
545
+ // Roster arm waived → gate green; the missing repo is still surfaced (loud).
546
+ expect(r.gateComplete).toBe(true);
547
+ expect(r.gateReasons.some((x) => /missing.*totem-strategy/.test(x))).toBe(true);
548
+ expect(r.collected).toEqual([DIRECT_SWEPT]);
549
+ expect(markExists(CS, DIRECT_SWEPT)).toBe(false);
550
+ });
551
+ it('C13b — --force-incomplete does NOT waive a scan warning (broken read still aborts)', () => {
552
+ ensureRepos(CROSTER);
553
+ const dir = path.join(tmpRoot, 'totem-strategy', '.totem', 'orchestration', 'strategy-claude', 'outbox');
554
+ mkDir(dir);
555
+ fs.writeFileSync(path.join(dir, '2026-07-01T1200Z-totem-agy-malformed.md'), '---\nto: totem-agy\nno closing', 'utf-8');
556
+ writeMark(CS, DIRECT_SWEPT);
557
+ const r = runCompact({ apply: true, forceIncomplete: true });
558
+ // Force waives roster presence only — a parse warning is still a hard abort.
559
+ expect(r.gateComplete).toBe(false);
560
+ expect(r.collected).toEqual([]);
561
+ expect(markExists(CS, DIRECT_SWEPT)).toBe(true);
562
+ });
563
+ it('C14 — dual-store same basename, one arm fails: not collected (no collected/failed overlap), retained accurate', () => {
564
+ ensureRepos(CROSTER);
565
+ const DUAL = DIRECT_SWEPT; // same basename inert in BOTH stores
566
+ writeMark(CS, DUAL); // direct
567
+ writeMark(CS, DUAL, true); // broadcast
568
+ // Fail ONLY the broadcast arm's unlink (platform-safe path suffix match).
569
+ fsMockState.failFor.add(path.join('_broadcast', DUAL));
570
+ const r = runCompact({ apply: true });
571
+ expect(r.gateComplete).toBe(true);
572
+ // Direct arm deleted but broadcast arm failed → NOT fully collected; the
573
+ // basename lands in `failed`, never in `collected` (no overlap — greptile).
574
+ expect(r.collected).toEqual([]);
575
+ expect(r.failed).toHaveLength(1);
576
+ expect(r.failed[0].file).toBe(`_broadcast/${DUAL}`);
577
+ // `retained` counts the still-on-disk broadcast mark (marks=1 basename, collected=0).
578
+ expect(r.marks).toBe(1);
579
+ expect(r.retained).toBe(1);
580
+ expect(markExists(CS, DUAL)).toBe(false); // direct arm gone
581
+ expect(markExists(CS, DUAL, true)).toBe(true); // broadcast arm remains
582
+ });
583
+ it('C15 — an unscannable roster name (dot/node_modules) hard-aborts, NOT waivable by --force-incomplete', () => {
584
+ ensureRepos(['totem', 'totem-strategy']);
585
+ writeMark(CS, DIRECT_SWEPT);
586
+ // A declared roster entry the workspace scan would filter out (starts with
587
+ // '.') — the gate must abort even under --force-incomplete: it is a config
588
+ // error (unscannable), not a known-absent repo (CodeRabbit).
589
+ const r = runCompact({
590
+ apply: true,
591
+ expectedRepos: ['totem', 'totem-strategy', '.evil'],
592
+ forceIncomplete: true,
593
+ });
594
+ expect(r.gateComplete).toBe(false);
595
+ expect(r.gateReasons.some((x) => /unscannable/.test(x))).toBe(true);
596
+ expect(r.collected).toEqual([]);
597
+ expect(markExists(CS, DIRECT_SWEPT)).toBe(true);
598
+ });
599
+ it('C16 — config.ecl.cohortRepos drives the roster when expectedRepos is absent (mmnto-ai/totem#2310)', () => {
600
+ ensureRepos(CROSTER);
601
+ writeInbound('totem-strategy', 'strategy-claude', DIRECT_LIVE, CS);
602
+ writeMark(CS, DIRECT_LIVE);
603
+ writeMark(CS, DIRECT_SWEPT);
604
+ // No `expectedRepos` → the injected config roster reaches the A2.2 gate.
605
+ const r = eclCompact({
606
+ repoRoot: compactRoot(),
607
+ workspace: tmpRoot,
608
+ env: { TOTEM_SELF_AGENT: CS },
609
+ config: cfg(CROSTER),
610
+ apply: true,
611
+ });
612
+ expect(r.expectedRepos).toEqual([...CROSTER].sort());
613
+ expect(r.gateComplete).toBe(true);
614
+ expect(r.collected).toEqual([DIRECT_SWEPT]);
615
+ expect(markExists(CS, DIRECT_SWEPT)).toBe(false);
616
+ expect(markExists(CS, DIRECT_LIVE)).toBe(true);
617
+ });
618
+ it('C17 — explicit expectedRepos WINS over config.ecl.cohortRepos (precedence)', () => {
619
+ ensureRepos(CROSTER); // only the explicit roster's repos are present
620
+ writeMark(CS, DIRECT_SWEPT);
621
+ // Config declares an EXTRA repo that is absent from the workspace — if config
622
+ // won, the gate would go RED (missing repo). Explicit CROSTER (all present)
623
+ // must win → gate green.
624
+ const r = eclCompact({
625
+ repoRoot: compactRoot(),
626
+ workspace: tmpRoot,
627
+ env: { TOTEM_SELF_AGENT: CS },
628
+ expectedRepos: CROSTER,
629
+ config: cfg([...CROSTER, 'totem-absent']),
630
+ apply: true,
631
+ });
632
+ expect(r.expectedRepos).toEqual([...CROSTER].sort());
633
+ // NON-VACUITY: had the config roster been used, `totem-absent` would gate-red.
634
+ expect(r.gateComplete).toBe(true);
635
+ expect(r.collected).toEqual([DIRECT_SWEPT]);
636
+ });
637
+ it('C18 — no expectedRepos AND no config ⇒ undeclared hard-abort (exit-3 arm)', () => {
638
+ ensureRepos(CROSTER);
639
+ writeMark(CS, DIRECT_SWEPT);
640
+ // Neither source declared → the `?? []` fallback lands in the undeclared
641
+ // gate-red arm (parity with C12's explicit `expectedRepos: []`).
642
+ const r = eclCompact({
643
+ repoRoot: compactRoot(),
644
+ workspace: tmpRoot,
645
+ env: { TOTEM_SELF_AGENT: CS },
646
+ apply: true,
647
+ });
648
+ expect(r.expectedRepos).toEqual([]);
649
+ expect(r.rosterDeclared).toBe(false);
650
+ expect(r.gateComplete).toBe(false);
651
+ expect(r.gateReasons.some((x) => /no cohort roster declared/.test(x))).toBe(true);
652
+ expect(resolveEclGcExitCode({ failed: [] }, r)).toBe(3);
653
+ expect(markExists(CS, DIRECT_SWEPT)).toBe(true);
654
+ });
655
+ it('C19 — an ecl block without cohortRepos is undeclared (config present, key omitted)', () => {
656
+ ensureRepos(CROSTER);
657
+ writeMark(CS, DIRECT_SWEPT);
658
+ // The NAMED state: a schema-valid `ecl: {}` block with `cohortRepos`
659
+ // omitted → still undeclared → hard-abort. (The no-`ecl`-key-at-all state
660
+ // is a resolveExpectedRoster unit row; C18 covers no config object.)
661
+ const r = eclCompact({
662
+ repoRoot: compactRoot(),
663
+ workspace: tmpRoot,
664
+ env: { TOTEM_SELF_AGENT: CS },
665
+ config: cfg(undefined, { emptyEclBlock: true }),
666
+ apply: true,
667
+ });
668
+ expect(r.gateComplete).toBe(false);
669
+ expect(r.gateReasons.some((x) => /no cohort roster declared/.test(x))).toBe(true);
670
+ expect(markExists(CS, DIRECT_SWEPT)).toBe(true);
671
+ });
672
+ });
673
+ // ─── Roster resolution precedence (mmnto-ai/totem#2310) ──
674
+ describe('resolveExpectedRoster — explicit > config > undefined', () => {
675
+ it('explicit expectedRepos wins over config', () => {
676
+ expect(resolveExpectedRoster(['a', 'b'], cfg(['c', 'd']))).toEqual(['a', 'b']);
677
+ });
678
+ it('config.ecl.cohortRepos is used when explicit is absent', () => {
679
+ expect(resolveExpectedRoster(undefined, cfg(['c', 'd']))).toEqual(['c', 'd']);
680
+ });
681
+ it('undefined when a config has no ecl block', () => {
682
+ expect(resolveExpectedRoster(undefined, cfg())).toBeUndefined();
683
+ });
684
+ it('undefined when the ecl block is present but cohortRepos is omitted', () => {
685
+ expect(resolveExpectedRoster(undefined, cfg(undefined, { emptyEclBlock: true }))).toBeUndefined();
686
+ });
687
+ it('undefined when neither source is present', () => {
688
+ expect(resolveExpectedRoster(undefined, undefined)).toBeUndefined();
689
+ });
690
+ it('explicit wins even over an undefined-roster config', () => {
691
+ expect(resolveExpectedRoster(['a'], cfg())).toEqual(['a']);
692
+ });
693
+ });
694
+ // ─── Config-read seam: loadEclConfig (mmnto-ai/totem#2310) ──
695
+ describe('loadEclConfig — missing ⇒ undeclared, invalid ⇒ loud', () => {
696
+ it('returns undefined when NO config file exists (honest undeclared → gate-red)', async () => {
697
+ utilsMock.resolveConfigPath.mockImplementation(() => {
698
+ throw new TotemConfigError('No Totem configuration found.', 'run totem init', 'CONFIG_MISSING');
699
+ });
700
+ await expect(loadEclConfig('/nowhere')).resolves.toBeUndefined();
701
+ expect(utilsMock.loadConfig).not.toHaveBeenCalled();
702
+ });
703
+ it('RETHROWS a present-but-invalid config LOUD (never degraded to undeclared)', async () => {
704
+ // The empty-roster / any Zod failure path: loadConfig throws CONFIG_INVALID.
705
+ utilsMock.resolveConfigPath.mockReturnValue('/repo/totem.config.ts');
706
+ utilsMock.loadConfig.mockRejectedValue(new TotemConfigError('Invalid configuration:\n ecl.cohortRepos: Array must contain at least 1 element(s)', 'fix the fields listed above', 'CONFIG_INVALID'));
707
+ // NON-VACUITY: a catch-and-degrade (orient's pattern) would resolve undefined
708
+ // here, aliasing a config bug into the undeclared arm — this asserts it does NOT.
709
+ await expect(loadEclConfig('/repo')).rejects.toThrow(/Invalid configuration/);
710
+ });
711
+ it('returns the loaded config when present and valid', async () => {
712
+ const loaded = cfg(CROSTER);
713
+ utilsMock.resolveConfigPath.mockReturnValue('/repo/totem.config.ts');
714
+ utilsMock.loadConfig.mockResolvedValue(loaded);
715
+ await expect(loadEclConfig('/repo')).resolves.toBe(loaded);
716
+ });
717
+ });
718
+ // ─── Combined exit-code precedence (codex panel) ────────
719
+ describe('resolveEclGcExitCode — combined prune+compact precedence', () => {
720
+ const clean = { failed: [] };
721
+ const partial = { failed: [{ file: 'x', error: 'EPERM' }] };
722
+ const base = {
723
+ rosterDeclared: true,
724
+ gateComplete: true,
725
+ resurfaced: [],
726
+ verifyComplete: true,
727
+ };
728
+ const gateGreen = { ...base, failed: [] };
729
+ const gateRed = { ...base, gateComplete: false, failed: [] };
730
+ const noRoster = { ...base, rosterDeclared: false, gateComplete: false, failed: [] };
731
+ const resurfaced = { ...base, resurfaced: ['x.md'], failed: [] };
732
+ const verifyUntrusted = { ...base, verifyComplete: false, failed: [] };
733
+ const compactPartial = { ...base, failed: [{ file: 'm', error: 'EPERM' }] };
734
+ it('0 — clean prune, no compaction', () => {
735
+ expect(resolveEclGcExitCode(clean)).toBe(0);
736
+ });
737
+ it('0 — clean prune + green compaction', () => {
738
+ expect(resolveEclGcExitCode(clean, gateGreen)).toBe(0);
739
+ });
740
+ it('3 — undeclared roster HARD-ABORTS (fail-loud, not a silent no-op) — strategy#828', () => {
741
+ expect(resolveEclGcExitCode(clean, noRoster)).toBe(3);
742
+ });
743
+ it('1 — prune partial delete failure, no compaction', () => {
744
+ expect(resolveEclGcExitCode(partial)).toBe(1);
745
+ });
746
+ it('1 — prune clean + compaction partial delete failure', () => {
747
+ expect(resolveEclGcExitCode(clean, compactPartial)).toBe(1);
748
+ });
749
+ it('3 — undeclared-roster hard-abort outranks a prune partial failure (3 > 1)', () => {
750
+ expect(resolveEclGcExitCode(partial, noRoster)).toBe(3);
751
+ });
752
+ it('3 — compaction gate red (declared roster incomplete) outranks a clean prune', () => {
753
+ expect(resolveEclGcExitCode(clean, gateRed)).toBe(3);
754
+ });
755
+ it('3 — compaction A2.4 falsifier tripped', () => {
756
+ expect(resolveEclGcExitCode(clean, resurfaced)).toBe(3);
757
+ });
758
+ it('3 — compaction A2.4 re-poll untrustworthy (truncated/warned verify)', () => {
759
+ expect(resolveEclGcExitCode(clean, verifyUntrusted)).toBe(3);
760
+ });
761
+ it('3 — prune partial + compaction abort: 3 outranks 1', () => {
762
+ expect(resolveEclGcExitCode(partial, gateRed)).toBe(3);
763
+ });
764
+ });
765
+ //# sourceMappingURL=ecl-gc.test.js.map