quilltap 4.10.0-dev.18 → 4.10.0-dev.184

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,276 @@
1
+ /**
2
+ * `quilltap memories maintain` and `validate --repair`: flag parsing, phase
3
+ * validation, request bodies, report rendering, and the integrity scan.
4
+ *
5
+ * @jest-environment node
6
+ */
7
+
8
+ 'use strict';
9
+
10
+ const { parseFlags, renderRepairText } = require('../memories-commands');
11
+ const {
12
+ parsePhases,
13
+ buildMaintainBody,
14
+ renderMaintenanceReport,
15
+ renderReportsTable,
16
+ } = require('../memories-maintain-command');
17
+ const { scanDigestIntegrity } = require('../graph-integrity');
18
+
19
+ const ID = '11111111-2222-3333-4444-555555555555';
20
+
21
+ describe('memories maintain flags', () => {
22
+ it('parses verb, character, phases, dry-run, max, threshold, cap and port', () => {
23
+ const { flags, positional } = parseFlags([
24
+ 'maintain', '--instance', 'Friday', '--character', 'Friday', '--phases', 'consolidate,demote',
25
+ '--dry-run', '--max', '12', '--threshold', '0.8', '--cap', '500', '--port', '3005', '--json',
26
+ ]);
27
+ expect(positional).toEqual(['maintain']);
28
+ expect(flags).toMatchObject({
29
+ instance: 'Friday', character: 'Friday', phases: 'consolidate,demote', dryRun: true,
30
+ max: 12, threshold: 0.8, cap: 500, port: 3005, json: true,
31
+ });
32
+ });
33
+
34
+ it('leaves the port undefined so the instance lock can supply it', () => {
35
+ expect(parseFlags(['maintain', '--character', 'X']).flags.port).toBeUndefined();
36
+ expect(parseFlags(['maintain', '-p', '3007']).flags.port).toBe(3007);
37
+ });
38
+
39
+ it('parses --repair and --write for validate', () => {
40
+ const { flags } = parseFlags(['validate', '--repair', '--write']);
41
+ expect(flags).toMatchObject({ repair: true, write: true });
42
+ });
43
+ });
44
+
45
+ describe('parsePhases', () => {
46
+ it('returns null for every phase', () => {
47
+ expect(parsePhases('')).toBeNull();
48
+ expect(parsePhases(undefined)).toBeNull();
49
+ });
50
+
51
+ it('trims and drops duplicates, leaving names and order to the server', () => {
52
+ expect(parsePhases('mirror, demote,fold-near-duplicates,demote')).toEqual(['mirror', 'demote', 'fold-near-duplicates']);
53
+ // No local phase list: an unknown name is the server's 400 to report.
54
+ expect(parsePhases('consolidate,housekeep')).toEqual(['consolidate', 'housekeep']);
55
+ });
56
+ });
57
+
58
+ describe('buildMaintainBody', () => {
59
+ it('defaults to a real run of every phase with no overrides', () => {
60
+ const { flags } = parseFlags(['maintain', '--character', 'X']);
61
+ expect(buildMaintainBody(ID, flags)).toEqual({ characterId: ID, dryRun: false });
62
+ });
63
+
64
+ it('maps the knobs onto settings', () => {
65
+ const { flags } = parseFlags([
66
+ 'maintain', '--dry-run', '--phases', 'consolidate', '--max', '5', '--threshold', '0.65', '--cap', '300',
67
+ ]);
68
+ expect(buildMaintainBody(ID, flags)).toEqual({
69
+ characterId: ID,
70
+ dryRun: true,
71
+ phases: ['consolidate'],
72
+ settings: { maxClustersPerRun: 5, consolidation: { clusterThreshold: 0.65 }, cap: 300 },
73
+ });
74
+ });
75
+
76
+ it('omits characterId for a user-wide run', () => {
77
+ const { flags } = parseFlags(['maintain', '--character', 'all']);
78
+ expect(buildMaintainBody(null, flags)).toEqual({ dryRun: false });
79
+ });
80
+
81
+ it('allows a user-wide dry run of the model-free phases', () => {
82
+ const { flags } = parseFlags(['maintain', '--dry-run', '--phases', 'demote,retire']);
83
+ expect(buildMaintainBody(null, flags)).toEqual({ dryRun: true, phases: ['demote', 'retire'] });
84
+ });
85
+
86
+ it('passes bounds through for the server to judge (no local copy of its limits)', () => {
87
+ expect(buildMaintainBody(ID, parseFlags(['maintain', '--cap', '50']).flags)).toMatchObject({ settings: { cap: 50 } });
88
+ expect(buildMaintainBody(ID, parseFlags(['maintain', '--max', '5000']).flags)).toMatchObject({ settings: { maxClustersPerRun: 5000 } });
89
+ expect(buildMaintainBody(null, parseFlags(['maintain', '--dry-run']).flags)).toEqual({ dryRun: true });
90
+ });
91
+ });
92
+
93
+ describe('renderMaintenanceReport', () => {
94
+ function capture(fn) {
95
+ let out = '';
96
+ fn((s) => { out += s; });
97
+ return out.replace(/\x1b\[[0-9;]*m/g, '');
98
+ }
99
+
100
+ it('prints phase lines and every phase detail', () => {
101
+ const out = capture((write) => renderMaintenanceReport({
102
+ characterId: ID, characterName: 'Ariadne', dryRun: true, durationMs: 2500,
103
+ phases: [
104
+ { phase: 'fold-near-duplicates', status: 'ran', durationMs: 10 },
105
+ { phase: 'consolidate', status: 'ran', durationMs: 2000 },
106
+ { phase: 'demote', status: 'ran', durationMs: 5 },
107
+ { phase: 'retire', status: 'skipped', skippedBecause: 'no-retention-window', durationMs: 0 },
108
+ { phase: 'mirror', status: 'ran', durationMs: 3 },
109
+ ],
110
+ fold: {
111
+ eligible: 80, deferred: 0, groupsDeferred: 0, folded: 2, groupsFailed: 0,
112
+ groups: [{ survivorId: 'aaaaaaaa-1', absorbedIds: ['bbbbbbbb-2', 'cccccccc-3'], minCosine: 0.97 }],
113
+ },
114
+ consolidation: {
115
+ dryRun: true,
116
+ clusters: [{
117
+ bucket: { kind: 'other', subjectName: 'Calvin', subjectCharacterId: 'c' },
118
+ clusterKind: 'semantic', status: 'planned',
119
+ memberIds: ['aaaaaaaa-1', 'bbbbbbbb-2'], memberContents: ['Calvin likes tea', 'Calvin drinks tea daily'],
120
+ keepStandalone: ['bbbbbbbb-2'], contradictions: [{ olderId: 'aaaaaaaa-1', newerId: 'bbbbbbbb-2', note: 'tea vs coffee' }],
121
+ digests: [{ id: 'd1', action: 'create', content: 'Calvin is a devoted tea drinker', importance: 0.6, memberIds: ['aaaaaaaa-1'] }],
122
+ }],
123
+ stats: { candidates: 40, clustersFound: 3, clustersAttempted: 1, clustersDeferred: 2, durationMs: 1500 },
124
+ },
125
+ demote: {
126
+ cap: 1000, hotTotal: 1300, demotable: 1200, protected: 100, excess: 200, demoted: 200,
127
+ demotedIds: [], highestScoreDemoted: 0.4,
128
+ },
129
+ mirror: { subjects: ['self', 'Calvin'], filesWritten: 0, deferredToCommit: false },
130
+ }, write));
131
+ expect(out).toContain('Maintenance dry run');
132
+ expect(out).toContain('Ariadne');
133
+ expect(out).toContain('no-retention-window');
134
+ expect(out).toContain('aaaaaaaa <=');
135
+ expect(out).toContain('folded 2');
136
+ expect(out).toContain('about Calvin');
137
+ expect(out).toContain('Calvin likes tea');
138
+ expect(out).toContain('[kept standalone]');
139
+ expect(out).toContain('Calvin is a devoted tea drinker');
140
+ expect(out).toContain('tea vs coffee');
141
+ expect(out).toContain('candidates 40');
142
+ expect(out).toContain('cap 1000');
143
+ expect(out).toContain('demotable 1200');
144
+ expect(out).toContain('excess 200');
145
+ expect(out).toContain('self, Calvin');
146
+ });
147
+
148
+ it('reports a skipped run', () => {
149
+ const out = capture((write) => renderMaintenanceReport({
150
+ characterId: ID, characterName: null, dryRun: true, skippedReason: 'archived', phases: [],
151
+ }, write));
152
+ expect(out).toContain('Skipped: archived');
153
+ });
154
+
155
+ it('reports a skipped consolidation', () => {
156
+ const out = capture((write) => renderMaintenanceReport({
157
+ characterId: ID, characterName: 'A', dryRun: true, phases: [],
158
+ consolidation: { dryRun: true, skippedReason: 'no-llm', clusters: [], stats: {} },
159
+ }, write));
160
+ expect(out).toContain('Skipped: no-llm');
161
+ });
162
+ });
163
+
164
+ describe('renderReportsTable', () => {
165
+ it('prints one row per character', () => {
166
+ let out = '';
167
+ renderReportsTable([
168
+ {
169
+ characterId: ID, characterName: 'Ariadne', dryRun: true,
170
+ phases: [{ phase: 'demote', status: 'ran', durationMs: 1 }, { phase: 'retire', status: 'ran', durationMs: 1 }],
171
+ demote: { cap: 1000, hotTotal: 10, demotable: 10, protected: 0, excess: 0, demoted: 0, demotedIds: [], highestScoreDemoted: null },
172
+ retire: { retentionDays: 90, retired: 3, retiredIds: [] },
173
+ },
174
+ { characterId: 'ffffffff-0', characterName: null, dryRun: true, skippedReason: 'archived', phases: [] },
175
+ ], (s) => { out += s; });
176
+ out = out.replace(/\x1b\[[0-9;]*m/g, '');
177
+ expect(out).toContain('across 2 characters');
178
+ expect(out).toMatch(/Ariadne\s+demote,retire\s+-\s+10\/1000 -0\s+3/);
179
+ expect(out).toContain('ffffffff');
180
+ expect(out).toContain('skipped: archived');
181
+ });
182
+ });
183
+
184
+ describe('scanDigestIntegrity', () => {
185
+ // better-sqlite3 is mocked under jest, so drive the scanner with a fake
186
+ // that answers the handful of queries it issues.
187
+ function fakeDb(rows, columns) {
188
+ const has = new Set(columns);
189
+ const scoped = (sql, params) => (sql.includes('characterId = ?') ? rows.filter((r) => r.characterId === params[0]) : rows);
190
+ return {
191
+ prepare(sql) {
192
+ return {
193
+ all: (...params) => {
194
+ if (sql.startsWith('PRAGMA')) return [...has].map((name) => ({ name }));
195
+ if (sql.trim() === 'SELECT id FROM memories') return rows.map((r) => ({ id: r.id }));
196
+ if (sql.includes('consolidatedFrom FROM')) {
197
+ return scoped(sql, params).filter((r) => r.consolidatedFrom && r.consolidatedFrom !== '[]');
198
+ }
199
+ if (sql.includes('supersededById FROM')) return scoped(sql, params).filter((r) => r.supersededById != null);
200
+ throw new Error(`unexpected query: ${sql}`);
201
+ },
202
+ get: (...params) => {
203
+ if (sql.includes('embedding IS NULL')) return { n: scoped(sql, params).filter((r) => r.embedding == null).length };
204
+ throw new Error(`unexpected query: ${sql}`);
205
+ },
206
+ };
207
+ },
208
+ };
209
+ }
210
+
211
+ const ALL_COLUMNS = ['id', 'characterId', 'relatedMemoryIds', 'consolidatedFrom', 'supersededById', 'embedding'];
212
+
213
+ it('counts dangling digest members, dangling supersededById and missing embeddings', () => {
214
+ const db = fakeDb([
215
+ { id: 'digest', characterId: 'c1', consolidatedFrom: JSON.stringify(['m1', 'gone1', 'gone2']), supersededById: null, embedding: 'x' },
216
+ { id: 'm1', characterId: 'c1', consolidatedFrom: '[]', supersededById: 'digest', embedding: 'x' },
217
+ { id: 'm2', characterId: 'c1', consolidatedFrom: '[]', supersededById: 'gone-digest', embedding: null },
218
+ { id: 'other', characterId: 'c2', consolidatedFrom: JSON.stringify(['gone3']), supersededById: null, embedding: null },
219
+ ], ALL_COLUMNS);
220
+
221
+ const one = scanDigestIntegrity(db, { characterId: 'c1', includePairs: true });
222
+ expect(one.consolidatedFrom).toEqual({ links: 2, digests: 1 });
223
+ expect(one.supersededBy).toBe(1);
224
+ expect(one.missingEmbeddings).toBe(1);
225
+ expect(one.consolidatedFromPairs).toEqual([{ digestId: 'digest', characterId: 'c1', memberIds: ['gone1', 'gone2'] }]);
226
+ expect(one.supersededByPairs).toEqual([{ memberId: 'm2', characterId: 'c1', digestId: 'gone-digest' }]);
227
+
228
+ const all = scanDigestIntegrity(db);
229
+ expect(all.consolidatedFrom).toEqual({ links: 3, digests: 2 });
230
+ expect(all.missingEmbeddings).toBe(2);
231
+ });
232
+
233
+ it('reads an instance without the tier-era columns as nothing to scan', () => {
234
+ const db = fakeDb([{ id: 'm1', characterId: 'c1', embedding: null }], ['id', 'characterId', 'relatedMemoryIds', 'embedding']);
235
+ expect(scanDigestIntegrity(db)).toEqual({
236
+ consolidatedFrom: { links: 0, digests: 0 }, supersededBy: 0, missingEmbeddings: 1,
237
+ });
238
+ });
239
+ });
240
+
241
+ describe('renderRepairText', () => {
242
+ function captureLog(fn) {
243
+ const lines = [];
244
+ const spy = jest.spyOn(console, 'log').mockImplementation((...args) => { lines.push(args.join(' ')); });
245
+ try { fn(); } finally { spy.mockRestore(); }
246
+ return lines.join('\n').replace(/\x1b\[[0-9;]*m/g, '');
247
+ }
248
+ const found = {
249
+ related: { links: 3, rows: 2 }, consolidatedFrom: { links: 1, digests: 1 }, supersededBy: 2, missingEmbeddings: 4,
250
+ };
251
+
252
+ it('prints a dry run with the --write hint', () => {
253
+ const out = captureLog(() => renderRepairText({ dryRun: true, characterId: null, found }));
254
+ expect(out).toContain('dry run, every memory');
255
+ expect(out).toContain('3 dangling related link(s) on 2 row(s)');
256
+ expect(out).toContain('1 dangling consolidatedFrom member(s) on 1 digest(s)');
257
+ expect(out).toContain('2 dangling supersededById');
258
+ expect(out).toContain('4 row(s) without an embedding');
259
+ expect(out).toContain('Add --write');
260
+ });
261
+
262
+ it('prints the repaired counts and a skipped embedding pass', () => {
263
+ const out = captureLog(() => renderRepairText({
264
+ dryRun: false, characterId: ID, found,
265
+ repaired: {
266
+ relatedRowsTouched: 2, digestsTouched: 1, membersCut: 2, charactersAffected: 1,
267
+ embeddingJobsQueued: 0, embeddingSkippedReason: 'no-embedding-profile',
268
+ },
269
+ }));
270
+ expect(out).toContain('(applied)');
271
+ expect(out).toContain('2 row(s) relinked, 1 digest(s) touched, 2 member(s) cut loose');
272
+ expect(out).toContain('0 embedding job(s) queued');
273
+ expect(out).toContain('no-embedding-profile');
274
+ expect(out).not.toContain('Add --write');
275
+ });
276
+ });
@@ -0,0 +1,54 @@
1
+ /**
2
+ * `quilltap migrations status` compares the ledger against the ids it reads
3
+ * out of `migrations/scripts/*.ts`. Most migrations declare their id through a
4
+ * `MIGRATION_ID` constant rather than a string literal; when the reader only
5
+ * understood literals, every one of those vanished from "source" and showed up
6
+ * as "retired" on any instance that had run it.
7
+ *
8
+ * @jest-environment node
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+
16
+ const { extractMigrationId } = require('../migrations-commands');
17
+
18
+ describe('extractMigrationId', () => {
19
+ it('reads a string-literal id', () => {
20
+ expect(extractMigrationId(`export const m = {\n id: 'foo-v1',\n};`)).toBe('foo-v1');
21
+ });
22
+
23
+ it('resolves an id declared through MIGRATION_ID', () => {
24
+ const src = `const MIGRATION_ID = 'bar-v1';\nexport const m = {\n id: MIGRATION_ID,\n};`;
25
+ expect(extractMigrationId(src)).toBe('bar-v1');
26
+ });
27
+
28
+ it('accepts a typed MIGRATION_ID constant', () => {
29
+ const src = `const MIGRATION_ID: string = 'baz-v1'\nexport const m = {\n id: MIGRATION_ID,\n};`;
30
+ expect(extractMigrationId(src)).toBe('baz-v1');
31
+ });
32
+
33
+ it('returns null when no id is declared', () => {
34
+ expect(extractMigrationId('export const nothing = 1;')).toBeNull();
35
+ });
36
+ });
37
+
38
+ describe('every migration script yields an id', () => {
39
+ const scriptsDir = path.join(__dirname, '..', '..', '..', '..', 'migrations', 'scripts');
40
+ const files = fs.existsSync(scriptsDir)
41
+ ? fs.readdirSync(scriptsDir).filter((f) => f.endsWith('.ts') && f !== 'index.ts')
42
+ : [];
43
+
44
+ it('finds the migration scripts', () => {
45
+ expect(files.length).toBeGreaterThan(100);
46
+ });
47
+
48
+ it('extracts an id from each one', () => {
49
+ const missing = files.filter(
50
+ (f) => !extractMigrationId(fs.readFileSync(path.join(scriptsDir, f), 'utf8'))
51
+ );
52
+ expect(missing).toEqual([]);
53
+ });
54
+ });
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Universal-flag parsing and server-port resolution for the HTTP diagnostic
3
+ * tools (anchor-probe, recall-replay, memory-diff).
4
+ *
5
+ * @jest-environment node
6
+ */
7
+
8
+ 'use strict';
9
+
10
+ const fs = require('fs');
11
+ const os = require('os');
12
+ const path = require('path');
13
+
14
+ const { resolveServerPort } = require('../lock-helpers');
15
+ const tools = {
16
+ 'anchor-probe': require('../anchor-probe-command').parseFlags,
17
+ 'recall-replay': require('../recall-replay-command').parseFlags,
18
+ 'memory-diff': require('../memory-diff-command').parseFlags,
19
+ };
20
+
21
+ describe.each(Object.keys(tools))('%s flag parsing', (name) => {
22
+ const parseFlags = tools[name];
23
+
24
+ it.each([
25
+ [['--instance', 'V4test', 'ID']],
26
+ [['ID', '--instance', 'V4test']],
27
+ [['-i', 'V4test', 'ID']],
28
+ [['ID', '-i', 'V4test']],
29
+ ])('accepts the instance flag around the positional id (%j)', (args) => {
30
+ const { flags, positional } = parseFlags(args);
31
+ expect(flags.instance).toBe('V4test');
32
+ expect(positional).toEqual(['ID']);
33
+ });
34
+
35
+ it.each([
36
+ [['--data-dir', '/x/y', 'ID']],
37
+ [['ID', '-d', '/x/y']],
38
+ ])('accepts the data-dir flag (%j)', (args) => {
39
+ const { flags, positional } = parseFlags(args);
40
+ expect(flags.dataDir).toBe('/x/y');
41
+ expect(positional).toEqual(['ID']);
42
+ });
43
+
44
+ it('accepts --passphrase without treating its value as the id', () => {
45
+ const { positional } = parseFlags(['--passphrase', 'hunter2', 'ID']);
46
+ expect(positional).toEqual(['ID']);
47
+ expect(parseFlags(['ID', '--passphrase', 'hunter2']).positional).toEqual(['ID']);
48
+ });
49
+
50
+ it('leaves the port undefined unless --port is given', () => {
51
+ expect(parseFlags(['ID']).flags.port).toBeUndefined();
52
+ expect(parseFlags(['ID', '--port', '3005']).flags.port).toBe(3005);
53
+ });
54
+ });
55
+
56
+ describe('resolveServerPort', () => {
57
+ let root;
58
+ let lockPath;
59
+
60
+ function writeLock(overrides = {}) {
61
+ const now = new Date().toISOString();
62
+ fs.writeFileSync(lockPath, JSON.stringify({
63
+ pid: process.pid,
64
+ hostname: os.hostname(),
65
+ startedAt: now,
66
+ lastHeartbeat: now,
67
+ environment: 'local',
68
+ processTitle: 'node',
69
+ processArgv0: process.argv[0],
70
+ history: [],
71
+ ...overrides,
72
+ }));
73
+ }
74
+
75
+ beforeEach(() => {
76
+ root = fs.mkdtempSync(path.join(os.tmpdir(), 'qtap-port-test-'));
77
+ fs.mkdirSync(path.join(root, 'data'));
78
+ lockPath = path.join(root, 'data', 'quilltap.lock');
79
+ });
80
+
81
+ afterEach(() => fs.rmSync(root, { recursive: true, force: true }));
82
+
83
+ it('defaults to 3000 with no flags at all', () => {
84
+ expect(resolveServerPort({})).toMatchObject({ port: 3000, source: 'default', note: null });
85
+ });
86
+
87
+ it('lets an explicit --port win over a live lock', () => {
88
+ writeLock({ port: 3005 });
89
+ expect(resolveServerPort({ port: 4000, dataDir: root })).toMatchObject({ port: 4000, source: 'flag' });
90
+ });
91
+
92
+ it('uses the port from a live lock and says so', () => {
93
+ writeLock({ port: 3005 });
94
+ const r = resolveServerPort({ dataDir: root });
95
+ expect(r).toMatchObject({ port: 3005, source: 'lock' });
96
+ expect(r.note).toContain('3005');
97
+ });
98
+
99
+ it('falls back to 3000 when the live lock records no port', () => {
100
+ writeLock();
101
+ const r = resolveServerPort({ dataDir: root });
102
+ expect(r).toMatchObject({ port: 3000, source: 'default', note: null });
103
+ expect(r.hint).toContain('records no port');
104
+ });
105
+
106
+ it('ignores the port of a stale lock', () => {
107
+ writeLock({ pid: 2 ** 22 + 12345, port: 3005 });
108
+ const r = resolveServerPort({ dataDir: root });
109
+ expect(r.port).toBe(3000);
110
+ expect(r.hint).toContain('stale');
111
+ });
112
+
113
+ it('falls back to 3000 when there is no lock', () => {
114
+ const r = resolveServerPort({ dataDir: root });
115
+ expect(r.port).toBe(3000);
116
+ expect(r.hint).toContain('no instance lock');
117
+ });
118
+
119
+ it('rejects --instance together with --data-dir', () => {
120
+ expect(() => resolveServerPort({ instance: 'x', dataDir: root })).toThrow(/not both/);
121
+ });
122
+ });
@@ -0,0 +1,158 @@
1
+ /**
2
+ * How `quilltap sync` renders a plan.
3
+ *
4
+ * The formatter is the operator's whole view of what the verb did, and its
5
+ * two load-bearing claims are easy to get subtly wrong: the second column is
6
+ * the side that CHANGES (so `modify store` means the store is rewritten from
7
+ * disk, not the other way round), and the exit code distinguishes "an
8
+ * unresolved conflict" from "something failed" so a script can tell them apart.
9
+ *
10
+ * @jest-environment node
11
+ */
12
+
13
+ 'use strict';
14
+
15
+ const {
16
+ formatActionLine,
17
+ formatActionLines,
18
+ formatSummary,
19
+ exitCodeFor,
20
+ displayPath,
21
+ formatBytes,
22
+ } = require('../sync-report');
23
+
24
+ function action(over = {}) {
25
+ return { kind: 'create', side: 'disk', relativePath: 'a.md', entryKind: 'file', ...over };
26
+ }
27
+
28
+ function summary(over = {}) {
29
+ return {
30
+ created: 0, modified: 0, deleted: 0, touched: 0,
31
+ described: 0, conflicts: 0, skipped: 0, failed: 0, ...over,
32
+ };
33
+ }
34
+
35
+ describe('one action, one line', () => {
36
+ it('puts the action first and the side that changes second', () => {
37
+ expect(formatActionLine(action({ kind: 'modify', side: 'store', relativePath: 'ch.md' }), false))
38
+ .toBe('modify store ch.md');
39
+ });
40
+
41
+ it('pads the columns so paths line up under each other', () => {
42
+ const lines = formatActionLines([
43
+ action({ kind: 'mkdir', side: 'disk', relativePath: 'lore', entryKind: 'folder' }),
44
+ action({ kind: 'conflict', side: null, relativePath: 'ch.md' }),
45
+ ], false);
46
+ const column = lines.map((l) => l.indexOf(l.trim().split(/\s+/)[2] || ''));
47
+ expect(new Set(column).size).toBe(1);
48
+ });
49
+
50
+ it('marks a folder with a trailing slash so it cannot be mistaken for a file', () => {
51
+ expect(displayPath(action({ entryKind: 'folder', relativePath: 'drafts' }))).toBe('drafts/');
52
+ expect(displayPath(action({ entryKind: 'file', relativePath: 'drafts' }))).toBe('drafts');
53
+ });
54
+
55
+ it('writes an em dash where no side changes', () => {
56
+ expect(formatActionLine(action({ kind: 'conflict', side: null }), false))
57
+ .toContain('conflict —');
58
+ });
59
+
60
+ it('appends the engine’s reason in parentheses', () => {
61
+ expect(formatActionLine(action({ kind: 'modify', side: 'store', reason: 'disk newer by 2h 14m' }), false))
62
+ .toContain('(disk newer by 2h 14m)');
63
+ });
64
+
65
+ it('shows the sha and size on a line that moved bytes', () => {
66
+ const line = formatActionLine(
67
+ action({ sha256: '3f9a' + '0'.repeat(60), sizeBytes: 421888 }), false
68
+ );
69
+ expect(line).toContain('sha 3f9a…');
70
+ expect(line).toContain('412.0 KB');
71
+ });
72
+
73
+ it('says nothing about bytes on a delete', () => {
74
+ expect(formatActionLine(action({ kind: 'delete', side: 'store', sha256: 'x'.repeat(64) }), false))
75
+ .not.toContain('sha');
76
+ });
77
+
78
+ it('leads with the error when an action failed', () => {
79
+ const line = formatActionLine(
80
+ action({ outcome: 'failed', error: 'EACCES', reason: 'store newer' }), false
81
+ );
82
+ expect(line).toContain('FAILED: EACCES');
83
+ expect(line).not.toContain('store newer');
84
+ });
85
+
86
+ it('emits no escape codes when colour is off', () => {
87
+ const lines = formatActionLines(
88
+ [action(), action({ kind: 'conflict', side: null }), action({ kind: 'delete', side: 'store' })],
89
+ false
90
+ );
91
+ expect(lines.join('\n')).not.toMatch(/\x1b\[/);
92
+ });
93
+
94
+ it('tints the line when colour is on', () => {
95
+ expect(formatActionLine(action({ kind: 'conflict', side: null }), true)).toMatch(/\x1b\[31m/);
96
+ });
97
+
98
+ it('caps the path column so one long path does not push every detail off screen', () => {
99
+ const lines = formatActionLines([
100
+ action({ relativePath: 'a/'.repeat(60) + 'deep.md', reason: 'why' }),
101
+ action({ relativePath: 'b.md', reason: 'why' }),
102
+ ], false);
103
+ expect(lines[1].indexOf('(why)')).toBeLessThan(80);
104
+ });
105
+ });
106
+
107
+ describe('the summary', () => {
108
+ it('names only the categories that actually happened', () => {
109
+ expect(formatSummary(summary({ created: 3, modified: 1 }), 800, false))
110
+ .toBe('3 created, 1 modified — 0.8 s');
111
+ });
112
+
113
+ it('says so plainly when there was nothing to do', () => {
114
+ expect(formatSummary(summary(), 120, false)).toBe('Already in step — 0.1 s');
115
+ });
116
+
117
+ it('speaks in the conditional under --dry-run', () => {
118
+ expect(formatSummary(summary({ created: 2 }), 300, true)).toBe('Would do: 2 created — 0.3 s');
119
+ expect(formatSummary(summary(), 300, true)).toBe('Nothing to do — 0.3 s');
120
+ });
121
+
122
+ it('pluralises conflicts', () => {
123
+ expect(formatSummary(summary({ conflicts: 1 }), 0, false)).toContain('1 conflict —');
124
+ expect(formatSummary(summary({ conflicts: 2 }), 0, false)).toContain('2 conflicts');
125
+ });
126
+
127
+ it('reports failures last, where they are hardest to miss', () => {
128
+ const line = formatSummary(summary({ created: 1, failed: 2 }), 0, false);
129
+ expect(line.indexOf('2 failed')).toBeGreaterThan(line.indexOf('1 created'));
130
+ });
131
+ });
132
+
133
+ describe('the exit code', () => {
134
+ it('is 0 when the run was clean', () => {
135
+ expect(exitCodeFor(summary({ created: 5, touched: 2 }))).toBe(0);
136
+ });
137
+
138
+ it('is 2 for an unresolved conflict', () => {
139
+ expect(exitCodeFor(summary({ conflicts: 1 }))).toBe(2);
140
+ });
141
+
142
+ it('is 1 when something failed outright, even alongside a conflict', () => {
143
+ expect(exitCodeFor(summary({ conflicts: 1, failed: 1 }))).toBe(1);
144
+ });
145
+ });
146
+
147
+ describe('byte formatting', () => {
148
+ it('scales through the units', () => {
149
+ expect(formatBytes(512)).toBe('512 B');
150
+ expect(formatBytes(2048)).toBe('2.0 KB');
151
+ expect(formatBytes(5 * 1024 * 1024)).toBe('5.0 MB');
152
+ expect(formatBytes(3 * 1024 ** 3)).toBe('3.00 GB');
153
+ });
154
+
155
+ it('says nothing for a size it was not given', () => {
156
+ expect(formatBytes(undefined)).toBe('');
157
+ });
158
+ });