@mmnto/cli 1.88.0 → 1.90.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.
- package/README.md +34 -0
- package/dist/author-sandbox.d.ts +32 -0
- package/dist/author-sandbox.d.ts.map +1 -0
- package/dist/author-sandbox.js +95 -0
- package/dist/author-sandbox.js.map +1 -0
- package/dist/authored-rule-intake-prewindow.test.d.ts +2 -0
- package/dist/authored-rule-intake-prewindow.test.d.ts.map +1 -0
- package/dist/authored-rule-intake-prewindow.test.js +84 -0
- package/dist/authored-rule-intake-prewindow.test.js.map +1 -0
- package/dist/authored-rule-intake.d.ts +21 -1
- package/dist/authored-rule-intake.d.ts.map +1 -1
- package/dist/authored-rule-intake.js +56 -1
- package/dist/authored-rule-intake.js.map +1 -1
- package/dist/commands/authored-whitelist.d.ts.map +1 -1
- package/dist/commands/authored-whitelist.js +19 -8
- package/dist/commands/authored-whitelist.js.map +1 -1
- package/dist/commands/ecl-gc.d.ts +259 -0
- package/dist/commands/ecl-gc.d.ts.map +1 -0
- package/dist/commands/ecl-gc.js +579 -0
- package/dist/commands/ecl-gc.js.map +1 -0
- package/dist/commands/ecl-gc.test.d.ts +12 -0
- package/dist/commands/ecl-gc.test.d.ts.map +1 -0
- package/dist/commands/ecl-gc.test.js +609 -0
- package/dist/commands/ecl-gc.test.js.map +1 -0
- package/dist/commands/eject.d.ts.map +1 -1
- package/dist/commands/eject.js +10 -6
- package/dist/commands/eject.js.map +1 -1
- package/dist/commands/eject.test.js +5 -1
- package/dist/commands/eject.test.js.map +1 -1
- package/dist/commands/init-templates.d.ts +4 -0
- package/dist/commands/init-templates.d.ts.map +1 -1
- package/dist/commands/init-templates.js +30 -2
- package/dist/commands/init-templates.js.map +1 -1
- package/dist/commands/init.test.js +26 -3
- package/dist/commands/init.test.js.map +1 -1
- package/dist/commands/mail.d.ts +11 -0
- package/dist/commands/mail.d.ts.map +1 -1
- package/dist/commands/mail.js +5 -1
- package/dist/commands/mail.js.map +1 -1
- package/dist/commands/mail.test.js +19 -0
- package/dist/commands/mail.test.js.map +1 -1
- package/dist/commands/rule-author-command.test.js +29 -2
- package/dist/commands/rule-author-command.test.js.map +1 -1
- package/dist/commands/rule-author-teardown.test.d.ts +2 -0
- package/dist/commands/rule-author-teardown.test.d.ts.map +1 -0
- package/dist/commands/rule-author-teardown.test.js +123 -0
- package/dist/commands/rule-author-teardown.test.js.map +1 -0
- package/dist/commands/rule-author.d.ts +1 -0
- package/dist/commands/rule-author.d.ts.map +1 -1
- package/dist/commands/rule-author.js +143 -2
- package/dist/commands/rule-author.js.map +1 -1
- package/dist/commands/spine-authored-cert-corpus.d.ts +15 -0
- package/dist/commands/spine-authored-cert-corpus.d.ts.map +1 -1
- package/dist/commands/spine-authored-cert-corpus.js +17 -1
- package/dist/commands/spine-authored-cert-corpus.js.map +1 -1
- package/dist/commands/spine-authored-cert-corpus.test.js +440 -4
- package/dist/commands/spine-authored-cert-corpus.test.js.map +1 -1
- package/dist/commands/spine-authored-materialize.d.ts +55 -0
- package/dist/commands/spine-authored-materialize.d.ts.map +1 -0
- package/dist/commands/spine-authored-materialize.js +315 -0
- package/dist/commands/spine-authored-materialize.js.map +1 -0
- package/dist/commands/spine-cert-materialize.d.ts.map +1 -1
- package/dist/commands/spine-cert-materialize.js +17 -0
- package/dist/commands/spine-cert-materialize.js.map +1 -1
- package/dist/commands/spine-cert-persist.d.ts +17 -1
- package/dist/commands/spine-cert-persist.d.ts.map +1 -1
- package/dist/commands/spine-cert-persist.js +17 -5
- package/dist/commands/spine-cert-persist.js.map +1 -1
- package/dist/commands/spine-cert-persist.test.js +60 -0
- package/dist/commands/spine-cert-persist.test.js.map +1 -1
- package/dist/commands/spine-cert-run-corpus.d.ts +18 -28
- package/dist/commands/spine-cert-run-corpus.d.ts.map +1 -1
- package/dist/commands/spine-cert-run-corpus.js +162 -21
- package/dist/commands/spine-cert-run-corpus.js.map +1 -1
- package/dist/commands/spine-derive-labels.d.ts.map +1 -1
- package/dist/commands/spine-derive-labels.js +9 -1
- package/dist/commands/spine-derive-labels.js.map +1 -1
- package/dist/commands/spine-freeze-orchestration.test.d.ts +2 -0
- package/dist/commands/spine-freeze-orchestration.test.d.ts.map +1 -0
- package/dist/commands/spine-freeze-orchestration.test.js +468 -0
- package/dist/commands/spine-freeze-orchestration.test.js.map +1 -0
- package/dist/commands/spine-freeze-split.d.ts +10 -0
- package/dist/commands/spine-freeze-split.d.ts.map +1 -0
- package/dist/commands/spine-freeze-split.js +131 -0
- package/dist/commands/spine-freeze-split.js.map +1 -0
- package/dist/commands/spine-windtunnel.d.ts +35 -0
- package/dist/commands/spine-windtunnel.d.ts.map +1 -1
- package/dist/commands/spine-windtunnel.js +47 -7
- package/dist/commands/spine-windtunnel.js.map +1 -1
- package/dist/index.js +93 -0
- package/dist/index.js.map +1 -1
- package/dist/spine-fixture-ancestry.d.ts +20 -0
- package/dist/spine-fixture-ancestry.d.ts.map +1 -0
- package/dist/spine-fixture-ancestry.js +46 -0
- package/dist/spine-fixture-ancestry.js.map +1 -0
- package/dist/spine-fixture-ancestry.test.d.ts +2 -0
- package/dist/spine-fixture-ancestry.test.d.ts.map +1 -0
- package/dist/spine-fixture-ancestry.test.js +62 -0
- package/dist/spine-fixture-ancestry.test.js.map +1 -0
- package/dist/spine-freeze-proof.d.ts +89 -0
- package/dist/spine-freeze-proof.d.ts.map +1 -0
- package/dist/spine-freeze-proof.js +237 -0
- package/dist/spine-freeze-proof.js.map +1 -0
- package/package.json +2 -2
|
@@ -0,0 +1,609 @@
|
|
|
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 { cleanTmpDir } from '../test-utils.js';
|
|
16
|
+
import { classifyEntry, cutoffKey, eclCompact, eclGc, planPrune, resolveEclGcExitCode, toStampKey, } from './ecl-gc.js';
|
|
17
|
+
// `vi.spyOn` cannot rebind a frozen ESM module-namespace export (node:fs), so
|
|
18
|
+
// the partial-delete-failure row drives a module mock instead: a hoisted
|
|
19
|
+
// fail-set makes the mocked `unlinkSync` throw for named files and pass through
|
|
20
|
+
// to the real implementation for everything else. All other fs calls stay real.
|
|
21
|
+
const fsMockState = vi.hoisted(() => ({ failFor: new Set() }));
|
|
22
|
+
vi.mock('node:fs', async (importOriginal) => {
|
|
23
|
+
const actual = await importOriginal();
|
|
24
|
+
return {
|
|
25
|
+
...actual,
|
|
26
|
+
default: actual,
|
|
27
|
+
unlinkSync: (p, ...rest) => {
|
|
28
|
+
const name = String(p);
|
|
29
|
+
for (const suffix of fsMockState.failFor) {
|
|
30
|
+
if (name.endsWith(suffix)) {
|
|
31
|
+
throw new Error('EPERM: simulated locked file');
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return actual.unlinkSync(p, ...rest);
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
});
|
|
38
|
+
// ─── Fixtures ───────────────────────────────────────────
|
|
39
|
+
// Fixed clock so cutoffs are deterministic (Tenet 15). 14-day default window
|
|
40
|
+
// puts the cutoff at 2026-06-21T12:00:00Z → key `20260621120000`.
|
|
41
|
+
const NOW = new Date('2026-07-05T12:00:00.000Z');
|
|
42
|
+
const nowFn = () => NOW;
|
|
43
|
+
const CUTOFF_KEY = '20260621120000';
|
|
44
|
+
let tmpRoot;
|
|
45
|
+
function mkDir(p) {
|
|
46
|
+
fs.mkdirSync(p, { recursive: true });
|
|
47
|
+
return p;
|
|
48
|
+
}
|
|
49
|
+
function orchDir(agent, sub) {
|
|
50
|
+
return path.join(tmpRoot, '.totem', 'orchestration', agent, sub);
|
|
51
|
+
}
|
|
52
|
+
/** Write named `.md`-ish files into `<agent>/<sub>/` with dispatch-shaped content. */
|
|
53
|
+
function writeFiles(agent, sub, names) {
|
|
54
|
+
const dir = mkDir(orchDir(agent, sub));
|
|
55
|
+
for (const name of names) {
|
|
56
|
+
fs.writeFileSync(path.join(dir, name), '---\nto: someone\n---\n\nbody\n', 'utf-8');
|
|
57
|
+
}
|
|
58
|
+
return dir;
|
|
59
|
+
}
|
|
60
|
+
function exists(agent, sub, name) {
|
|
61
|
+
return fs.existsSync(path.join(orchDir(agent, sub), name));
|
|
62
|
+
}
|
|
63
|
+
function run(opts = {}) {
|
|
64
|
+
return eclGc({ repoRoot: tmpRoot, env: {}, now: nowFn, ...opts });
|
|
65
|
+
}
|
|
66
|
+
// Aged (< cutoff) and fresh (>= cutoff) stamp names for the default 14d window.
|
|
67
|
+
const AGED_4 = '2026-06-01T1200Z-totem-gemini.md'; // 20260601120000 < cutoff
|
|
68
|
+
const AGED_6 = '2026-06-01T120000Z-totem-gemini.md'; // dual-form, same instant, aged
|
|
69
|
+
const AGED_2 = '2026-05-15T0930Z-strategy-claude.md'; // 20260515093000 < cutoff
|
|
70
|
+
const FRESH_4 = '2026-07-01T1200Z-totem-gemini.md'; // 20260701120000 >= cutoff
|
|
71
|
+
const BOUNDARY_KEEP = '2026-06-21T120000Z-totem-gemini.md'; // === cutoff → kept
|
|
72
|
+
const BOUNDARY_PRUNE = '2026-06-21T115959Z-totem-gemini.md'; // 1s older → pruned
|
|
73
|
+
beforeEach(() => {
|
|
74
|
+
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-eclgc-'));
|
|
75
|
+
});
|
|
76
|
+
afterEach(() => {
|
|
77
|
+
fsMockState.failFor.clear();
|
|
78
|
+
vi.restoreAllMocks();
|
|
79
|
+
cleanTmpDir(tmpRoot);
|
|
80
|
+
});
|
|
81
|
+
// ─── Pure helpers ───────────────────────────────────────
|
|
82
|
+
describe('pure helpers', () => {
|
|
83
|
+
it('toStampKey canonicalizes both stamp forms to a 14-digit key', () => {
|
|
84
|
+
expect(toStampKey('2026-06-01T1200Z')).toBe('20260601120000');
|
|
85
|
+
expect(toStampKey('2026-06-01T120000Z')).toBe('20260601120000');
|
|
86
|
+
});
|
|
87
|
+
it('cutoffKey derives now − retainDays as a 14-digit key', () => {
|
|
88
|
+
expect(cutoffKey(NOW, 14)).toBe(CUTOFF_KEY);
|
|
89
|
+
// retainDays 0 → cutoff is `now` itself.
|
|
90
|
+
expect(cutoffKey(NOW, 0)).toBe('20260705120000');
|
|
91
|
+
});
|
|
92
|
+
it('classifyEntry: file-type guard runs before the extension/stamp checks', () => {
|
|
93
|
+
// A directory that LOOKS aged + `.md` must still be skipped, never pruned.
|
|
94
|
+
expect(classifyEntry({ name: AGED_4, isFile: false }, CUTOFF_KEY)).toEqual({
|
|
95
|
+
action: 'skip',
|
|
96
|
+
reason: 'not a regular file',
|
|
97
|
+
});
|
|
98
|
+
expect(classifyEntry({ name: 'x.tmp', isFile: true }, CUTOFF_KEY)).toEqual({
|
|
99
|
+
action: 'skip',
|
|
100
|
+
reason: 'not a .md dispatch',
|
|
101
|
+
});
|
|
102
|
+
expect(classifyEntry({ name: 'not-a-stamp.md', isFile: true }, CUTOFF_KEY)).toEqual({
|
|
103
|
+
action: 'skip',
|
|
104
|
+
reason: 'unparseable stamp',
|
|
105
|
+
});
|
|
106
|
+
expect(classifyEntry({ name: AGED_4, isFile: true }, CUTOFF_KEY)).toEqual({ action: 'prune' });
|
|
107
|
+
expect(classifyEntry({ name: BOUNDARY_KEEP, isFile: true }, CUTOFF_KEY)).toEqual({
|
|
108
|
+
action: 'keep',
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
it('planPrune partitions a listing deterministically', () => {
|
|
112
|
+
const plan = planPrune([
|
|
113
|
+
{ name: FRESH_4, isFile: true },
|
|
114
|
+
{ name: AGED_4, isFile: true },
|
|
115
|
+
{ name: 'x.tmp', isFile: true },
|
|
116
|
+
{ name: 'sub', isFile: false },
|
|
117
|
+
], CUTOFF_KEY);
|
|
118
|
+
expect(plan.prune).toEqual([AGED_4]);
|
|
119
|
+
expect(plan.kept).toBe(1);
|
|
120
|
+
expect(plan.skipped).toEqual([
|
|
121
|
+
{ file: 'sub', reason: 'not a regular file' },
|
|
122
|
+
{ file: 'x.tmp', reason: 'not a .md dispatch' },
|
|
123
|
+
]);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
// ─── LOAD-BEARING safety rows ───────────────────────────
|
|
127
|
+
describe('safety invariants (load-bearing)', () => {
|
|
128
|
+
it('row 1 — peer immunity: pruning seat A never touches seat B', () => {
|
|
129
|
+
writeFiles('seat-a', 'outbox', [AGED_4]);
|
|
130
|
+
writeFiles('seat-b', 'outbox', [AGED_2]);
|
|
131
|
+
const result = run({ apply: true, env: { TOTEM_SELF_AGENT: 'seat-a' } });
|
|
132
|
+
expect(result.agent).toBe('seat-a');
|
|
133
|
+
expect(result.pruned).toEqual([AGED_4]);
|
|
134
|
+
expect(exists('seat-a', 'outbox', AGED_4)).toBe(false);
|
|
135
|
+
// NON-VACUITY: seat-b's aged file must survive — if the target were the
|
|
136
|
+
// whole orchestration tree instead of the resolved seat, this would fail.
|
|
137
|
+
expect(exists('seat-b', 'outbox', AGED_2)).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
it('row 2 — never touches journal/ processed/ inbox/', () => {
|
|
140
|
+
writeFiles('seat-a', 'outbox', [AGED_4]);
|
|
141
|
+
writeFiles('seat-a', 'journal', [AGED_2]);
|
|
142
|
+
writeFiles('seat-a', 'processed', [AGED_2]);
|
|
143
|
+
writeFiles('seat-a', 'inbox', [AGED_2]);
|
|
144
|
+
const result = run({ apply: true, env: { TOTEM_SELF_AGENT: 'seat-a' } });
|
|
145
|
+
// The outbox aged file IS pruned (prune actually ran)…
|
|
146
|
+
expect(result.pruned).toEqual([AGED_4]);
|
|
147
|
+
expect(exists('seat-a', 'outbox', AGED_4)).toBe(false);
|
|
148
|
+
// …but the sibling trees are untouched.
|
|
149
|
+
// NON-VACUITY: these fail if the scan ever walked a non-outbox dir.
|
|
150
|
+
expect(exists('seat-a', 'journal', AGED_2)).toBe(true);
|
|
151
|
+
expect(exists('seat-a', 'processed', AGED_2)).toBe(true);
|
|
152
|
+
expect(exists('seat-a', 'inbox', AGED_2)).toBe(true);
|
|
153
|
+
});
|
|
154
|
+
it('row 3 — self-ambiguity throws BEFORE any deletion', () => {
|
|
155
|
+
// Two registered seat dirs, no --agent-id, no TOTEM_SELF_AGENT → ambiguous.
|
|
156
|
+
writeFiles('seat-a', 'outbox', [AGED_4]);
|
|
157
|
+
writeFiles('seat-b', 'outbox', [AGED_2]);
|
|
158
|
+
expect(() => run({ apply: true })).toThrow(/cannot resolve a single agent/i);
|
|
159
|
+
// NON-VACUITY: nothing may be deleted on the throwing path.
|
|
160
|
+
expect(exists('seat-a', 'outbox', AGED_4)).toBe(true);
|
|
161
|
+
expect(exists('seat-b', 'outbox', AGED_2)).toBe(true);
|
|
162
|
+
});
|
|
163
|
+
it('row 3b — an unsafe resolved agent-id is rejected BEFORE any deletion', () => {
|
|
164
|
+
// resolveSelfSender returns an explicit --agent-id verbatim, so a caller
|
|
165
|
+
// could hand in an id that escapes the `<orchestration>/<agent>/outbox`
|
|
166
|
+
// segment. The isPathSafeAgentId guard must reject it before any scan/delete.
|
|
167
|
+
writeFiles('seat-a', 'outbox', [AGED_4]);
|
|
168
|
+
expect(() => run({ apply: true, agentId: '../seat-a' })).toThrow(/invalid agent-id/i);
|
|
169
|
+
// NON-VACUITY: nothing may be deleted on the guard-throw path.
|
|
170
|
+
expect(exists('seat-a', 'outbox', AGED_4)).toBe(true);
|
|
171
|
+
});
|
|
172
|
+
it('row 4 — exact boundary retained; 1s older pruned', () => {
|
|
173
|
+
writeFiles('seat-a', 'outbox', [BOUNDARY_KEEP, BOUNDARY_PRUNE]);
|
|
174
|
+
const result = run({ apply: true, env: { TOTEM_SELF_AGENT: 'seat-a' } });
|
|
175
|
+
expect(result.cutoffKey).toBe(CUTOFF_KEY);
|
|
176
|
+
expect(result.pruned).toEqual([BOUNDARY_PRUNE]);
|
|
177
|
+
expect(result.kept).toBe(1);
|
|
178
|
+
// NON-VACUITY: a `<=` boundary would delete BOUNDARY_KEEP → this fails.
|
|
179
|
+
expect(exists('seat-a', 'outbox', BOUNDARY_KEEP)).toBe(true);
|
|
180
|
+
expect(exists('seat-a', 'outbox', BOUNDARY_PRUNE)).toBe(false);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
// ─── Behavioral coverage ────────────────────────────────
|
|
184
|
+
describe('behavior', () => {
|
|
185
|
+
it('row 5 — dry-run lists would-prune but deletes nothing', () => {
|
|
186
|
+
writeFiles('seat-a', 'outbox', [AGED_4, FRESH_4]);
|
|
187
|
+
const result = run({ env: { TOTEM_SELF_AGENT: 'seat-a' } });
|
|
188
|
+
expect(result.dryRun).toBe(true);
|
|
189
|
+
expect(result.pruned).toEqual([AGED_4]);
|
|
190
|
+
expect(result.failed).toEqual([]);
|
|
191
|
+
// Nothing deleted in dry-run.
|
|
192
|
+
expect(exists('seat-a', 'outbox', AGED_4)).toBe(true);
|
|
193
|
+
expect(exists('seat-a', 'outbox', FRESH_4)).toBe(true);
|
|
194
|
+
});
|
|
195
|
+
it('row 6 — --apply deletes only the aged files; fresh kept', () => {
|
|
196
|
+
writeFiles('seat-a', 'outbox', [AGED_4, AGED_2, FRESH_4]);
|
|
197
|
+
const result = run({ apply: true, env: { TOTEM_SELF_AGENT: 'seat-a' } });
|
|
198
|
+
expect(result.dryRun).toBe(false);
|
|
199
|
+
expect(result.pruned.sort()).toEqual([AGED_2, AGED_4].sort());
|
|
200
|
+
expect(result.kept).toBe(1);
|
|
201
|
+
expect(exists('seat-a', 'outbox', AGED_4)).toBe(false);
|
|
202
|
+
expect(exists('seat-a', 'outbox', AGED_2)).toBe(false);
|
|
203
|
+
expect(exists('seat-a', 'outbox', FRESH_4)).toBe(true);
|
|
204
|
+
});
|
|
205
|
+
it('row 7 — dual-form stamps (4-digit + 6-digit) both classify correctly', () => {
|
|
206
|
+
writeFiles('seat-a', 'outbox', [AGED_4, AGED_6, BOUNDARY_KEEP]);
|
|
207
|
+
const result = run({ apply: true, env: { TOTEM_SELF_AGENT: 'seat-a' } });
|
|
208
|
+
// Both aged forms pruned; the 6-digit boundary kept.
|
|
209
|
+
expect(result.pruned.sort()).toEqual([AGED_4, AGED_6].sort());
|
|
210
|
+
expect(result.kept).toBe(1);
|
|
211
|
+
expect(exists('seat-a', 'outbox', BOUNDARY_KEEP)).toBe(true);
|
|
212
|
+
});
|
|
213
|
+
it('row 8 — malformed / non-.md / non-file entries are skipped, never deleted', () => {
|
|
214
|
+
const outbox = writeFiles('seat-a', 'outbox', [AGED_4, '.gitkeep', 'x.tmp', 'not-a-stamp.md']);
|
|
215
|
+
// A directory named like an aged dispatch — must be skipped, not pruned.
|
|
216
|
+
mkDir(path.join(outbox, '2026-01-01T0000Z-olddir.md'));
|
|
217
|
+
const result = run({ apply: true, env: { TOTEM_SELF_AGENT: 'seat-a' } });
|
|
218
|
+
expect(result.pruned).toEqual([AGED_4]);
|
|
219
|
+
const skippedFiles = result.skipped.map((s) => s.file).sort();
|
|
220
|
+
expect(skippedFiles).toEqual([
|
|
221
|
+
'.gitkeep',
|
|
222
|
+
'2026-01-01T0000Z-olddir.md',
|
|
223
|
+
'not-a-stamp.md',
|
|
224
|
+
'x.tmp',
|
|
225
|
+
]);
|
|
226
|
+
// NON-VACUITY: the aged-looking subdir survives (file-type guard held).
|
|
227
|
+
expect(fs.existsSync(path.join(outbox, '2026-01-01T0000Z-olddir.md'))).toBe(true);
|
|
228
|
+
expect(exists('seat-a', 'outbox', '.gitkeep')).toBe(true);
|
|
229
|
+
expect(exists('seat-a', 'outbox', 'x.tmp')).toBe(true);
|
|
230
|
+
expect(exists('seat-a', 'outbox', 'not-a-stamp.md')).toBe(true);
|
|
231
|
+
});
|
|
232
|
+
it('row 9a — explicit --agent-id override resolves an otherwise-ambiguous repo', () => {
|
|
233
|
+
writeFiles('seat-a', 'outbox', [AGED_4]);
|
|
234
|
+
writeFiles('seat-b', 'outbox', [AGED_2]);
|
|
235
|
+
const result = run({ apply: true, agentId: 'seat-a' });
|
|
236
|
+
expect(result.agent).toBe('seat-a');
|
|
237
|
+
expect(result.pruned).toEqual([AGED_4]);
|
|
238
|
+
expect(exists('seat-b', 'outbox', AGED_2)).toBe(true);
|
|
239
|
+
});
|
|
240
|
+
it('row 9b — env TOTEM_SELF_AGENT single-agent resolves', () => {
|
|
241
|
+
writeFiles('seat-a', 'outbox', [AGED_4]);
|
|
242
|
+
const result = run({ apply: true, env: { TOTEM_SELF_AGENT: 'seat-a' } });
|
|
243
|
+
expect(result.agent).toBe('seat-a');
|
|
244
|
+
expect(result.pruned).toEqual([AGED_4]);
|
|
245
|
+
});
|
|
246
|
+
it('row 10 — missing outbox dir yields a clean empty result, no throw', () => {
|
|
247
|
+
// No outbox created for seat-a at all.
|
|
248
|
+
const result = run({ apply: true, env: { TOTEM_SELF_AGENT: 'seat-a' } });
|
|
249
|
+
expect(result.agent).toBe('seat-a');
|
|
250
|
+
expect(result.pruned).toEqual([]);
|
|
251
|
+
expect(result.kept).toBe(0);
|
|
252
|
+
expect(result.skipped).toEqual([]);
|
|
253
|
+
expect(result.failed).toEqual([]);
|
|
254
|
+
expect(result.outbox).toBe(path.join(tmpRoot, '.totem', 'orchestration', 'seat-a', 'outbox'));
|
|
255
|
+
});
|
|
256
|
+
it('row 11 — invalid --retain-days (negative / non-integer) is a usage throw', () => {
|
|
257
|
+
writeFiles('seat-a', 'outbox', [AGED_4]);
|
|
258
|
+
expect(() => run({ retainDays: -1, env: { TOTEM_SELF_AGENT: 'seat-a' } })).toThrow(/non-negative integer/i);
|
|
259
|
+
expect(() => run({ retainDays: 3.5, env: { TOTEM_SELF_AGENT: 'seat-a' } })).toThrow(/non-negative integer/i);
|
|
260
|
+
// NON-VACUITY: the throw is a usage error raised before any scan.
|
|
261
|
+
expect(exists('seat-a', 'outbox', AGED_4)).toBe(true);
|
|
262
|
+
});
|
|
263
|
+
it('row 12 — a partial delete failure is captured; other files still pruned', () => {
|
|
264
|
+
writeFiles('seat-a', 'outbox', [AGED_4, AGED_2]);
|
|
265
|
+
// The mocked unlinkSync throws for AGED_4, passes through for AGED_2.
|
|
266
|
+
fsMockState.failFor.add(AGED_4);
|
|
267
|
+
const result = run({ apply: true, env: { TOTEM_SELF_AGENT: 'seat-a' } });
|
|
268
|
+
expect(result.pruned).toEqual([AGED_2]);
|
|
269
|
+
expect(result.failed).toHaveLength(1);
|
|
270
|
+
expect(result.failed[0].file).toBe(AGED_4);
|
|
271
|
+
expect(result.failed[0].error).toMatch(/EPERM/);
|
|
272
|
+
// The failed file remains on disk; the succeeding one is gone.
|
|
273
|
+
expect(exists('seat-a', 'outbox', AGED_4)).toBe(true);
|
|
274
|
+
expect(exists('seat-a', 'outbox', AGED_2)).toBe(false);
|
|
275
|
+
});
|
|
276
|
+
it('retainDays 0 prunes everything strictly before now', () => {
|
|
277
|
+
// With N=0 the cutoff is `now`; a fresh-but-past file becomes prunable.
|
|
278
|
+
writeFiles('seat-a', 'outbox', [FRESH_4]);
|
|
279
|
+
const result = run({ apply: true, retainDays: 0, env: { TOTEM_SELF_AGENT: 'seat-a' } });
|
|
280
|
+
expect(result.cutoffKey).toBe('20260705120000');
|
|
281
|
+
expect(result.pruned).toEqual([FRESH_4]);
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
// ─── Compaction (ADR-106 § A2 / ecl-discipline § 4.5; mmnto-ai/totem#2307) ───
|
|
285
|
+
//
|
|
286
|
+
// Compaction runs across a WORKSPACE (not a single repo): `tmpRoot` IS the
|
|
287
|
+
// workspace, the compacting seat lives in `<tmpRoot>/totem`, and peer outboxes
|
|
288
|
+
// (the raw addressed-inbound) live in `<tmpRoot>/<peerRepo>/…`. The A2.2 gate
|
|
289
|
+
// checks that every `expectedRepos` entry is a present directory in the
|
|
290
|
+
// workspace, so tests inject an explicit roster and create (or omit) repo dirs
|
|
291
|
+
// to drive the abort arms.
|
|
292
|
+
const CS = 'totem-agy'; // the compacting seat
|
|
293
|
+
const CROSTER = ['totem', 'totem-strategy']; // injected expected roster
|
|
294
|
+
// Live/swept dispatch basenames (stamp-shaped so pollMail's self-priority
|
|
295
|
+
// bucketing sees them the same way production names are seen).
|
|
296
|
+
const DIRECT_LIVE = '2026-07-01T1000Z-totem-agy-alive.md';
|
|
297
|
+
const DIRECT_SWEPT = '2026-06-01T0900Z-totem-agy-swept.md';
|
|
298
|
+
const BCAST_LIVE = '2026-07-01T1001Z-broadcast-alive.md';
|
|
299
|
+
const BCAST_SWEPT = '2026-06-01T0800Z-broadcast-swept.md';
|
|
300
|
+
function compactRoot() {
|
|
301
|
+
return path.join(tmpRoot, 'totem');
|
|
302
|
+
}
|
|
303
|
+
function processedPath(agent, name, broadcast = false) {
|
|
304
|
+
const dir = broadcast
|
|
305
|
+
? path.join(compactRoot(), '.totem', 'orchestration', agent, 'processed', '_broadcast')
|
|
306
|
+
: path.join(compactRoot(), '.totem', 'orchestration', agent, 'processed');
|
|
307
|
+
return path.join(dir, name);
|
|
308
|
+
}
|
|
309
|
+
/** Write a processed MARK for `agent` (direct or broadcast store). */
|
|
310
|
+
function writeMark(agent, name, broadcast = false) {
|
|
311
|
+
const p = processedPath(agent, name, broadcast);
|
|
312
|
+
mkDir(path.dirname(p));
|
|
313
|
+
fs.writeFileSync(p, 'x', 'utf-8');
|
|
314
|
+
}
|
|
315
|
+
function markExists(agent, name, broadcast = false) {
|
|
316
|
+
return fs.existsSync(processedPath(agent, name, broadcast));
|
|
317
|
+
}
|
|
318
|
+
/** Write an INBOUND dispatch (raw addressed-inbound) in a peer repo's outbox. */
|
|
319
|
+
function writeInbound(repo, sender, name, to) {
|
|
320
|
+
const dir = path.join(tmpRoot, repo, '.totem', 'orchestration', sender, 'outbox');
|
|
321
|
+
mkDir(dir);
|
|
322
|
+
fs.writeFileSync(path.join(dir, name), `---\nto: ${to}\nfrom: ${sender}\n---\n\nbody\n`, 'utf-8');
|
|
323
|
+
}
|
|
324
|
+
/** Ensure each expected roster repo exists as a directory in the workspace. */
|
|
325
|
+
function ensureRepos(repos) {
|
|
326
|
+
for (const r of repos)
|
|
327
|
+
mkDir(path.join(tmpRoot, r));
|
|
328
|
+
}
|
|
329
|
+
function runCompact(opts = {}) {
|
|
330
|
+
return eclCompact({
|
|
331
|
+
repoRoot: compactRoot(),
|
|
332
|
+
workspace: tmpRoot,
|
|
333
|
+
env: { TOTEM_SELF_AGENT: CS },
|
|
334
|
+
expectedRepos: CROSTER,
|
|
335
|
+
...opts,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
describe('compaction — cursor-coupled GC (A2.1–A2.4)', () => {
|
|
339
|
+
it('C1 — canonical fixture: swept marks collected, live marks retained (direct + broadcast)', () => {
|
|
340
|
+
ensureRepos(CROSTER);
|
|
341
|
+
// Live dispatches still present in a peer outbox (their marks are load-bearing).
|
|
342
|
+
writeInbound('totem-strategy', 'strategy-claude', DIRECT_LIVE, CS);
|
|
343
|
+
writeInbound('totem-strategy', 'strategy-claude', BCAST_LIVE, 'broadcast');
|
|
344
|
+
// Four marks: two live (retain), two swept (collect).
|
|
345
|
+
writeMark(CS, DIRECT_LIVE);
|
|
346
|
+
writeMark(CS, DIRECT_SWEPT);
|
|
347
|
+
writeMark(CS, BCAST_LIVE, true);
|
|
348
|
+
writeMark(CS, BCAST_SWEPT, true);
|
|
349
|
+
const r = runCompact({ apply: true });
|
|
350
|
+
expect(r.gateComplete).toBe(true);
|
|
351
|
+
expect(r.collected.sort()).toEqual([BCAST_SWEPT, DIRECT_SWEPT].sort());
|
|
352
|
+
// Live marks survive; swept marks gone.
|
|
353
|
+
expect(markExists(CS, DIRECT_LIVE)).toBe(true);
|
|
354
|
+
expect(markExists(CS, BCAST_LIVE, true)).toBe(true);
|
|
355
|
+
expect(markExists(CS, DIRECT_SWEPT)).toBe(false);
|
|
356
|
+
expect(markExists(CS, BCAST_SWEPT, true)).toBe(false);
|
|
357
|
+
// A2.4: nothing previously-handled re-surfaces.
|
|
358
|
+
expect(r.resurfaced).toEqual([]);
|
|
359
|
+
});
|
|
360
|
+
it('C2 — A2.1 inversion RED test: a live mark is RETAINED (naive pollMail().mail would delete it)', () => {
|
|
361
|
+
ensureRepos(CROSTER);
|
|
362
|
+
// The dispatch is present AND marked handled — so `pollMail().mail` (which
|
|
363
|
+
// subtracts processed) reports it ABSENT. A naive impl keyed on that list
|
|
364
|
+
// would collect the mark and the dispatch would re-surface on re-poll. The
|
|
365
|
+
// raw-addressed-inbound scan (includeProcessed) keeps it in view.
|
|
366
|
+
writeInbound('totem-strategy', 'strategy-claude', DIRECT_LIVE, CS);
|
|
367
|
+
writeMark(CS, DIRECT_LIVE);
|
|
368
|
+
const r = runCompact({ apply: true });
|
|
369
|
+
expect(r.gateComplete).toBe(true);
|
|
370
|
+
expect(r.collected).toEqual([]); // NON-VACUITY: naive impl deletes this mark
|
|
371
|
+
expect(markExists(CS, DIRECT_LIVE)).toBe(true);
|
|
372
|
+
expect(r.resurfaced).toEqual([]); // naive impl trips this
|
|
373
|
+
});
|
|
374
|
+
it('C3 — abort arm: a missing expected repo blocks all deletes (N < M)', () => {
|
|
375
|
+
// Only `totem` present; `totem-strategy` (in the roster) is absent.
|
|
376
|
+
ensureRepos(['totem']);
|
|
377
|
+
writeMark(CS, DIRECT_SWEPT);
|
|
378
|
+
const r = runCompact({ apply: true });
|
|
379
|
+
expect(r.gateComplete).toBe(false);
|
|
380
|
+
expect(r.gateReasons.some((x) => /missing.*totem-strategy/.test(x))).toBe(true);
|
|
381
|
+
expect(r.collected).toEqual([]);
|
|
382
|
+
// NON-VACUITY: uncertain ⇒ retain — the swept mark survives an incomplete poll.
|
|
383
|
+
expect(markExists(CS, DIRECT_SWEPT)).toBe(true);
|
|
384
|
+
});
|
|
385
|
+
it('C4 — abort arm: any scan/parse warning blocks all deletes', () => {
|
|
386
|
+
ensureRepos(CROSTER);
|
|
387
|
+
// A mail-shaped dispatch with no closing delimiter → pollMail parse warning.
|
|
388
|
+
const dir = path.join(tmpRoot, 'totem-strategy', '.totem', 'orchestration', 'strategy-claude', 'outbox');
|
|
389
|
+
mkDir(dir);
|
|
390
|
+
fs.writeFileSync(path.join(dir, '2026-07-01T1200Z-totem-agy-malformed.md'), '---\nto: totem-agy\nno closing', 'utf-8');
|
|
391
|
+
writeMark(CS, DIRECT_SWEPT);
|
|
392
|
+
const r = runCompact({ apply: true });
|
|
393
|
+
expect(r.gateComplete).toBe(false);
|
|
394
|
+
expect(r.warnings.length).toBeGreaterThan(0);
|
|
395
|
+
expect(r.collected).toEqual([]);
|
|
396
|
+
expect(markExists(CS, DIRECT_SWEPT)).toBe(true);
|
|
397
|
+
});
|
|
398
|
+
it('C5 — abort arm: scan truncation blocks all deletes', () => {
|
|
399
|
+
ensureRepos(CROSTER);
|
|
400
|
+
// 3 addressed dispatches, maxScan 2 → pollMail truncates → gate red.
|
|
401
|
+
writeInbound('totem-strategy', 'strategy-claude', DIRECT_LIVE, CS);
|
|
402
|
+
writeInbound('totem-strategy', 'strategy-claude', '2026-07-01T1002Z-totem-agy-b.md', CS);
|
|
403
|
+
writeInbound('totem-strategy', 'strategy-claude', '2026-07-01T1003Z-totem-agy-c.md', CS);
|
|
404
|
+
writeMark(CS, DIRECT_SWEPT);
|
|
405
|
+
const r = runCompact({ apply: true, maxScan: 2 });
|
|
406
|
+
expect(r.gateComplete).toBe(false);
|
|
407
|
+
expect(r.gateReasons.some((x) => /truncat/i.test(x))).toBe(true);
|
|
408
|
+
expect(r.collected).toEqual([]);
|
|
409
|
+
expect(markExists(CS, DIRECT_SWEPT)).toBe(true);
|
|
410
|
+
});
|
|
411
|
+
it('C6 — self-seat ambiguity THROWS before any scan/delete (usage, exit-2 class)', () => {
|
|
412
|
+
ensureRepos(CROSTER);
|
|
413
|
+
// Two seat dirs registered, no TOTEM_SELF_AGENT, no --agent-id → ambiguous.
|
|
414
|
+
writeMark('totem-agy', DIRECT_SWEPT);
|
|
415
|
+
writeMark('totem-claude', DIRECT_SWEPT);
|
|
416
|
+
expect(() => eclCompact({ repoRoot: compactRoot(), workspace: tmpRoot, env: {}, expectedRepos: CROSTER })).toThrow(/cannot resolve a single agent/i);
|
|
417
|
+
// NON-VACUITY: nothing deleted on the throwing path.
|
|
418
|
+
expect(markExists('totem-agy', DIRECT_SWEPT)).toBe(true);
|
|
419
|
+
expect(markExists('totem-claude', DIRECT_SWEPT)).toBe(true);
|
|
420
|
+
});
|
|
421
|
+
it('C7 — multi-seat isolation: compacting seat S never touches a peer seat’s processed/', () => {
|
|
422
|
+
ensureRepos(CROSTER);
|
|
423
|
+
// Same swept basename marked by BOTH seats; only S=totem-agy is targeted.
|
|
424
|
+
writeMark('totem-agy', DIRECT_SWEPT);
|
|
425
|
+
writeMark('totem-claude', DIRECT_SWEPT);
|
|
426
|
+
const r = runCompact({ apply: true });
|
|
427
|
+
expect(r.gateComplete).toBe(true);
|
|
428
|
+
expect(r.collected).toEqual([DIRECT_SWEPT]);
|
|
429
|
+
expect(markExists('totem-agy', DIRECT_SWEPT)).toBe(false);
|
|
430
|
+
// NON-VACUITY: a coordinator-union target would delete the peer's mark too.
|
|
431
|
+
expect(markExists('totem-claude', DIRECT_SWEPT)).toBe(true);
|
|
432
|
+
});
|
|
433
|
+
it('C8 — dry-run lists would-collect but deletes nothing', () => {
|
|
434
|
+
ensureRepos(CROSTER);
|
|
435
|
+
writeInbound('totem-strategy', 'strategy-claude', DIRECT_LIVE, CS);
|
|
436
|
+
writeMark(CS, DIRECT_LIVE);
|
|
437
|
+
writeMark(CS, DIRECT_SWEPT);
|
|
438
|
+
const r = runCompact(); // dry-run (no --apply)
|
|
439
|
+
expect(r.dryRun).toBe(true);
|
|
440
|
+
expect(r.gateComplete).toBe(true);
|
|
441
|
+
expect(r.collectable).toEqual([DIRECT_SWEPT]);
|
|
442
|
+
expect(r.collected).toEqual([]);
|
|
443
|
+
// `retained` reflects the WOULD-survive count in dry-run (marks − collectable),
|
|
444
|
+
// consistent with the display + apply semantics (greptile).
|
|
445
|
+
expect(r.marks).toBe(2);
|
|
446
|
+
expect(r.retained).toBe(1);
|
|
447
|
+
expect(markExists(CS, DIRECT_SWEPT)).toBe(true); // nothing deleted
|
|
448
|
+
});
|
|
449
|
+
it('C9 — union retention: a direct mark is retained by a live BROADCAST of the same basename', () => {
|
|
450
|
+
ensureRepos(CROSTER);
|
|
451
|
+
// Only a broadcast dispatch exists for this basename; the mark sits in the
|
|
452
|
+
// DIRECT store. pollMail's processed filter is recipient-class-blind, so the
|
|
453
|
+
// mark shadows the live broadcast dispatch — union retention keeps it.
|
|
454
|
+
writeInbound('totem-strategy', 'strategy-claude', BCAST_LIVE, 'broadcast');
|
|
455
|
+
writeMark(CS, BCAST_LIVE); // DIRECT store, matched by broadcast inbound
|
|
456
|
+
const r = runCompact({ apply: true });
|
|
457
|
+
expect(r.gateComplete).toBe(true);
|
|
458
|
+
expect(r.collected).toEqual([]);
|
|
459
|
+
expect(markExists(CS, BCAST_LIVE)).toBe(true);
|
|
460
|
+
});
|
|
461
|
+
it('C10 — other-recipient same basename does NOT retain a self mark (filter is parsed to:, not basename)', () => {
|
|
462
|
+
ensureRepos(CROSTER);
|
|
463
|
+
// A dispatch of this basename exists but is addressed to a DIFFERENT seat, so
|
|
464
|
+
// it is not part of S's addressed-inbound; the mark is inert → collected.
|
|
465
|
+
writeInbound('totem-strategy', 'strategy-claude', DIRECT_SWEPT, 'totem-gemini');
|
|
466
|
+
writeMark(CS, DIRECT_SWEPT);
|
|
467
|
+
const r = runCompact({ apply: true });
|
|
468
|
+
expect(r.gateComplete).toBe(true);
|
|
469
|
+
expect(r.collected).toEqual([DIRECT_SWEPT]);
|
|
470
|
+
expect(markExists(CS, DIRECT_SWEPT)).toBe(false);
|
|
471
|
+
});
|
|
472
|
+
it('C11 — a partial mark-delete failure is captured; other marks still collected', () => {
|
|
473
|
+
ensureRepos(CROSTER);
|
|
474
|
+
writeMark(CS, DIRECT_SWEPT);
|
|
475
|
+
writeMark(CS, BCAST_SWEPT, true);
|
|
476
|
+
// Mock unlinkSync to fail the direct swept mark, pass the broadcast one.
|
|
477
|
+
fsMockState.failFor.add(DIRECT_SWEPT);
|
|
478
|
+
const r = runCompact({ apply: true });
|
|
479
|
+
expect(r.gateComplete).toBe(true);
|
|
480
|
+
expect(r.collected).toEqual([BCAST_SWEPT]);
|
|
481
|
+
expect(r.failed).toHaveLength(1);
|
|
482
|
+
expect(r.failed[0].error).toMatch(/EPERM/);
|
|
483
|
+
expect(markExists(CS, DIRECT_SWEPT)).toBe(true); // failed delete → mark remains
|
|
484
|
+
expect(markExists(CS, BCAST_SWEPT, true)).toBe(false);
|
|
485
|
+
});
|
|
486
|
+
it('C12 — undeclared (empty) roster HARD-ABORTS (exit 3), never "assume complete" (strategy#828)', () => {
|
|
487
|
+
ensureRepos(CROSTER);
|
|
488
|
+
writeMark(CS, DIRECT_SWEPT); // a genuinely inert mark that WOULD be collectable
|
|
489
|
+
const r = runCompact({ apply: true, expectedRepos: [] });
|
|
490
|
+
expect(r.rosterDeclared).toBe(false);
|
|
491
|
+
// Folded into the A2.2 gate: no declared roster => gate red => hard-abort (exit 3),
|
|
492
|
+
// fail-loud (never a silent no-op) per the strategy#828 no-roster corollary.
|
|
493
|
+
expect(r.gateComplete).toBe(false);
|
|
494
|
+
expect(r.gateReasons.some((x) => /no cohort roster declared/.test(x))).toBe(true);
|
|
495
|
+
expect(resolveEclGcExitCode({ failed: [] }, r)).toBe(3);
|
|
496
|
+
expect(r.collected).toEqual([]);
|
|
497
|
+
// NON-VACUITY: with no declared roster, completeness is unprovable, so even a
|
|
498
|
+
// truly-inert mark is retained rather than deleted on an assumed-complete scan.
|
|
499
|
+
expect(markExists(CS, DIRECT_SWEPT)).toBe(true);
|
|
500
|
+
expect(r.marks).toBe(1); // still reports the seat's mark count
|
|
501
|
+
});
|
|
502
|
+
it('C13 — --force-incomplete waives the missing-repo abort; deletes proceed', () => {
|
|
503
|
+
// Only `totem` present; `totem-strategy` (in the roster) absent → normally aborts.
|
|
504
|
+
ensureRepos(['totem']);
|
|
505
|
+
writeMark(CS, DIRECT_SWEPT);
|
|
506
|
+
const r = runCompact({ apply: true, forceIncomplete: true });
|
|
507
|
+
// Roster arm waived → gate green; the missing repo is still surfaced (loud).
|
|
508
|
+
expect(r.gateComplete).toBe(true);
|
|
509
|
+
expect(r.gateReasons.some((x) => /missing.*totem-strategy/.test(x))).toBe(true);
|
|
510
|
+
expect(r.collected).toEqual([DIRECT_SWEPT]);
|
|
511
|
+
expect(markExists(CS, DIRECT_SWEPT)).toBe(false);
|
|
512
|
+
});
|
|
513
|
+
it('C13b — --force-incomplete does NOT waive a scan warning (broken read still aborts)', () => {
|
|
514
|
+
ensureRepos(CROSTER);
|
|
515
|
+
const dir = path.join(tmpRoot, 'totem-strategy', '.totem', 'orchestration', 'strategy-claude', 'outbox');
|
|
516
|
+
mkDir(dir);
|
|
517
|
+
fs.writeFileSync(path.join(dir, '2026-07-01T1200Z-totem-agy-malformed.md'), '---\nto: totem-agy\nno closing', 'utf-8');
|
|
518
|
+
writeMark(CS, DIRECT_SWEPT);
|
|
519
|
+
const r = runCompact({ apply: true, forceIncomplete: true });
|
|
520
|
+
// Force waives roster presence only — a parse warning is still a hard abort.
|
|
521
|
+
expect(r.gateComplete).toBe(false);
|
|
522
|
+
expect(r.collected).toEqual([]);
|
|
523
|
+
expect(markExists(CS, DIRECT_SWEPT)).toBe(true);
|
|
524
|
+
});
|
|
525
|
+
it('C14 — dual-store same basename, one arm fails: not collected (no collected/failed overlap), retained accurate', () => {
|
|
526
|
+
ensureRepos(CROSTER);
|
|
527
|
+
const DUAL = DIRECT_SWEPT; // same basename inert in BOTH stores
|
|
528
|
+
writeMark(CS, DUAL); // direct
|
|
529
|
+
writeMark(CS, DUAL, true); // broadcast
|
|
530
|
+
// Fail ONLY the broadcast arm's unlink (platform-safe path suffix match).
|
|
531
|
+
fsMockState.failFor.add(path.join('_broadcast', DUAL));
|
|
532
|
+
const r = runCompact({ apply: true });
|
|
533
|
+
expect(r.gateComplete).toBe(true);
|
|
534
|
+
// Direct arm deleted but broadcast arm failed → NOT fully collected; the
|
|
535
|
+
// basename lands in `failed`, never in `collected` (no overlap — greptile).
|
|
536
|
+
expect(r.collected).toEqual([]);
|
|
537
|
+
expect(r.failed).toHaveLength(1);
|
|
538
|
+
expect(r.failed[0].file).toBe(`_broadcast/${DUAL}`);
|
|
539
|
+
// `retained` counts the still-on-disk broadcast mark (marks=1 basename, collected=0).
|
|
540
|
+
expect(r.marks).toBe(1);
|
|
541
|
+
expect(r.retained).toBe(1);
|
|
542
|
+
expect(markExists(CS, DUAL)).toBe(false); // direct arm gone
|
|
543
|
+
expect(markExists(CS, DUAL, true)).toBe(true); // broadcast arm remains
|
|
544
|
+
});
|
|
545
|
+
it('C15 — an unscannable roster name (dot/node_modules) hard-aborts, NOT waivable by --force-incomplete', () => {
|
|
546
|
+
ensureRepos(['totem', 'totem-strategy']);
|
|
547
|
+
writeMark(CS, DIRECT_SWEPT);
|
|
548
|
+
// A declared roster entry the workspace scan would filter out (starts with
|
|
549
|
+
// '.') — the gate must abort even under --force-incomplete: it is a config
|
|
550
|
+
// error (unscannable), not a known-absent repo (CodeRabbit).
|
|
551
|
+
const r = runCompact({
|
|
552
|
+
apply: true,
|
|
553
|
+
expectedRepos: ['totem', 'totem-strategy', '.evil'],
|
|
554
|
+
forceIncomplete: true,
|
|
555
|
+
});
|
|
556
|
+
expect(r.gateComplete).toBe(false);
|
|
557
|
+
expect(r.gateReasons.some((x) => /unscannable/.test(x))).toBe(true);
|
|
558
|
+
expect(r.collected).toEqual([]);
|
|
559
|
+
expect(markExists(CS, DIRECT_SWEPT)).toBe(true);
|
|
560
|
+
});
|
|
561
|
+
});
|
|
562
|
+
// ─── Combined exit-code precedence (codex panel) ────────
|
|
563
|
+
describe('resolveEclGcExitCode — combined prune+compact precedence', () => {
|
|
564
|
+
const clean = { failed: [] };
|
|
565
|
+
const partial = { failed: [{ file: 'x', error: 'EPERM' }] };
|
|
566
|
+
const base = {
|
|
567
|
+
rosterDeclared: true,
|
|
568
|
+
gateComplete: true,
|
|
569
|
+
resurfaced: [],
|
|
570
|
+
verifyComplete: true,
|
|
571
|
+
};
|
|
572
|
+
const gateGreen = { ...base, failed: [] };
|
|
573
|
+
const gateRed = { ...base, gateComplete: false, failed: [] };
|
|
574
|
+
const noRoster = { ...base, rosterDeclared: false, gateComplete: false, failed: [] };
|
|
575
|
+
const resurfaced = { ...base, resurfaced: ['x.md'], failed: [] };
|
|
576
|
+
const verifyUntrusted = { ...base, verifyComplete: false, failed: [] };
|
|
577
|
+
const compactPartial = { ...base, failed: [{ file: 'm', error: 'EPERM' }] };
|
|
578
|
+
it('0 — clean prune, no compaction', () => {
|
|
579
|
+
expect(resolveEclGcExitCode(clean)).toBe(0);
|
|
580
|
+
});
|
|
581
|
+
it('0 — clean prune + green compaction', () => {
|
|
582
|
+
expect(resolveEclGcExitCode(clean, gateGreen)).toBe(0);
|
|
583
|
+
});
|
|
584
|
+
it('3 — undeclared roster HARD-ABORTS (fail-loud, not a silent no-op) — strategy#828', () => {
|
|
585
|
+
expect(resolveEclGcExitCode(clean, noRoster)).toBe(3);
|
|
586
|
+
});
|
|
587
|
+
it('1 — prune partial delete failure, no compaction', () => {
|
|
588
|
+
expect(resolveEclGcExitCode(partial)).toBe(1);
|
|
589
|
+
});
|
|
590
|
+
it('1 — prune clean + compaction partial delete failure', () => {
|
|
591
|
+
expect(resolveEclGcExitCode(clean, compactPartial)).toBe(1);
|
|
592
|
+
});
|
|
593
|
+
it('3 — undeclared-roster hard-abort outranks a prune partial failure (3 > 1)', () => {
|
|
594
|
+
expect(resolveEclGcExitCode(partial, noRoster)).toBe(3);
|
|
595
|
+
});
|
|
596
|
+
it('3 — compaction gate red (declared roster incomplete) outranks a clean prune', () => {
|
|
597
|
+
expect(resolveEclGcExitCode(clean, gateRed)).toBe(3);
|
|
598
|
+
});
|
|
599
|
+
it('3 — compaction A2.4 falsifier tripped', () => {
|
|
600
|
+
expect(resolveEclGcExitCode(clean, resurfaced)).toBe(3);
|
|
601
|
+
});
|
|
602
|
+
it('3 — compaction A2.4 re-poll untrustworthy (truncated/warned verify)', () => {
|
|
603
|
+
expect(resolveEclGcExitCode(clean, verifyUntrusted)).toBe(3);
|
|
604
|
+
});
|
|
605
|
+
it('3 — prune partial + compaction abort: 3 outranks 1', () => {
|
|
606
|
+
expect(resolveEclGcExitCode(partial, gateRed)).toBe(3);
|
|
607
|
+
});
|
|
608
|
+
});
|
|
609
|
+
//# sourceMappingURL=ecl-gc.test.js.map
|