@mmnto/totem 1.44.0 → 1.45.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,654 @@
1
+ /**
2
+ * Tests for the per-lesson compile cache (Proposal 281).
3
+ *
4
+ * Falsifying-metric test is `partial_mutation_invariant` — exercises the
5
+ * load-bearing claim that a +1-lesson PR produces a 1-row compiled-rules
6
+ * delta, not a 130-row rotation.
7
+ */
8
+ import * as fs from 'node:fs';
9
+ import * as path from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
12
+ import { buildCacheEntry, cacheEntryPath, composeLessonSourceForHash, computeLessonSourceHash, listCacheEntries, lookupCacheEntry, migrateFromCompiledRules, writeCacheEntry, } from './compile-cache.js';
13
+ import { cleanTmpDir } from './test-utils.js';
14
+ // ─── Fixtures ───────────────────────────────────────
15
+ const FINGERPRINT_A = 'fingerprint-a-1234567890abcdef';
16
+ const FINGERPRINT_B = 'fingerprint-b-fedcba0987654321';
17
+ /**
18
+ * Workspace-scoped temp root per cohort lesson "os.tmpdir() for agent-readable
19
+ * temp files violates workspace boundary." Tests under this suite create per-it
20
+ * temp dirs under `<package>/.totem/temp/compile-cache-tests/<unique-prefix>`,
21
+ * cleaned in afterEach. `.totem/temp/` is already gitignored.
22
+ *
23
+ * Anchored to the test file's own location (not `process.cwd()`) so the path
24
+ * resolves to the package directory regardless of where vitest is invoked from.
25
+ */
26
+ const TEST_FILE_DIR = path.dirname(fileURLToPath(import.meta.url));
27
+ const PACKAGE_ROOT = path.resolve(TEST_FILE_DIR, '..');
28
+ const TEST_TEMP_ROOT = path.join(PACKAGE_ROOT, '.totem', 'temp', 'compile-cache-tests');
29
+ fs.mkdirSync(TEST_TEMP_ROOT, { recursive: true });
30
+ function makeTestTmpDir(prefix) {
31
+ return fs.mkdtempSync(path.join(TEST_TEMP_ROOT, prefix));
32
+ }
33
+ function makeCompiledResult(lessonHash) {
34
+ return {
35
+ status: 'compiled',
36
+ rule: {
37
+ lessonHash,
38
+ lessonHeading: `Lesson ${lessonHash.slice(0, 6)}`,
39
+ pattern: 'foo',
40
+ message: 'avoid foo',
41
+ engine: 'regex',
42
+ fileGlobs: ['**/*.ts'],
43
+ severity: 'warning',
44
+ },
45
+ };
46
+ }
47
+ function makeSkippedResult(hash) {
48
+ return {
49
+ status: 'skipped',
50
+ hash,
51
+ reasonCode: 'no-pattern-found',
52
+ };
53
+ }
54
+ function makeNoopResult() {
55
+ return { status: 'noop' };
56
+ }
57
+ function makeFailedResult() {
58
+ return { status: 'failed' };
59
+ }
60
+ // ─── computeLessonSourceHash ────────────────────────
61
+ describe('computeLessonSourceHash', () => {
62
+ it('produces the same hash for CRLF and LF content', () => {
63
+ const lf = 'line one\nline two\nline three\n';
64
+ const crlf = 'line one\r\nline two\r\nline three\r\n';
65
+ expect(computeLessonSourceHash(lf)).toBe(computeLessonSourceHash(crlf));
66
+ });
67
+ it('produces different hashes for different content', () => {
68
+ expect(computeLessonSourceHash('content A')).not.toBe(computeLessonSourceHash('content B'));
69
+ });
70
+ it('is stable across calls', () => {
71
+ const input = 'stable content\n';
72
+ expect(computeLessonSourceHash(input)).toBe(computeLessonSourceHash(input));
73
+ });
74
+ it('produces a 64-char hex SHA-256 digest', () => {
75
+ const hash = computeLessonSourceHash('any content');
76
+ expect(hash).toMatch(/^[a-f0-9]{64}$/);
77
+ });
78
+ it('distinguishes heading-only edits from body-only edits (CR Major on #1983)', () => {
79
+ // The cache key in compile.ts composes `${lesson.heading}\n${lesson.body}`
80
+ // and hashes that. A heading-only edit must produce a different sourceHash
81
+ // (otherwise the cache hits + preserves stale lessonHash output that would
82
+ // ordinarily rotate to reflect the new heading). This test exercises the
83
+ // composition shape directly.
84
+ const body = 'lesson body content';
85
+ const hashA = computeLessonSourceHash(`Heading A\n${body}`);
86
+ const hashB = computeLessonSourceHash(`Heading B\n${body}`);
87
+ expect(hashA).not.toBe(hashB);
88
+ // And conversely, a body-only edit also invalidates.
89
+ const heading = 'Stable heading';
90
+ const hashBodyA = computeLessonSourceHash(`${heading}\nbody version A`);
91
+ const hashBodyB = computeLessonSourceHash(`${heading}\nbody version B`);
92
+ expect(hashBodyA).not.toBe(hashBodyB);
93
+ });
94
+ it('preserves trailing-whitespace sensitivity (GCA R2 critical on #1983)', () => {
95
+ // The canonical generateInputHash + lessonHash are trailing-whitespace
96
+ // sensitive (`compile-manifest.ts` does CRLF→LF normalization only; no
97
+ // trim). The cache key MUST match that sensitivity. If it didn't, a
98
+ // whitespace-only edit would hit the cache and return a rule with a stale
99
+ // lessonHash while the manifest pipeline computed a fresh lessonHash for
100
+ // the same edit — breaking the deterministic link between lessons and
101
+ // rules.
102
+ //
103
+ // GCA R1 originally flagged a missing .trimEnd() (citing my contract
104
+ // prose, which over-claimed normalization scope); GCA R2 reversed that
105
+ // finding by anchoring to the canonical hash shape. This test locks in
106
+ // the R2-correct behavior so future regressions to "more aggressive
107
+ // normalization" surface immediately.
108
+ const base = 'lesson content';
109
+ expect(computeLessonSourceHash(base)).not.toBe(computeLessonSourceHash(base + '\n'));
110
+ expect(computeLessonSourceHash(base)).not.toBe(computeLessonSourceHash(base + '\n\n\n'));
111
+ expect(computeLessonSourceHash(base)).not.toBe(computeLessonSourceHash(base + ' '));
112
+ });
113
+ it('normalizes CRLF to LF (cross-OS stability)', () => {
114
+ // The one normalization the canonical hash DOES apply: CRLF → LF. Same
115
+ // shape as `generateInputHash`. Without this, a Windows-saved lesson and
116
+ // a Unix-saved lesson with identical visible content would hash
117
+ // differently.
118
+ const lf = 'line one\nline two\nline three\n';
119
+ const crlf = 'line one\r\nline two\r\nline three\r\n';
120
+ expect(computeLessonSourceHash(lf)).toBe(computeLessonSourceHash(crlf));
121
+ });
122
+ });
123
+ // ─── composeLessonSourceForHash ─────────────────────
124
+ describe('composeLessonSourceForHash', () => {
125
+ it('produces the same string for the same (heading, body) inputs', () => {
126
+ const a = composeLessonSourceForHash('My Heading', 'body text\n');
127
+ const b = composeLessonSourceForHash('My Heading', 'body text\n');
128
+ expect(a).toBe(b);
129
+ });
130
+ it('distinguishes heading-only edits from body-only edits', () => {
131
+ // Defends the runtime/migration hash-consistency contract (GCA R2 critical
132
+ // on #1983). Both call sites — compile.ts wiring and migrateFromCompiledRules
133
+ // — route through this helper. If a future refactor accidentally
134
+ // re-introduced separate composition logic on either side, the two paths
135
+ // would diverge again.
136
+ const headingChange = composeLessonSourceForHash('Heading A', 'shared body');
137
+ const headingChangeAlt = composeLessonSourceForHash('Heading B', 'shared body');
138
+ expect(headingChange).not.toBe(headingChangeAlt);
139
+ const bodyChange = composeLessonSourceForHash('shared heading', 'body A');
140
+ const bodyChangeAlt = composeLessonSourceForHash('shared heading', 'body B');
141
+ expect(bodyChange).not.toBe(bodyChangeAlt);
142
+ });
143
+ });
144
+ // ─── cacheEntryPath ─────────────────────────────────
145
+ describe('cacheEntryPath', () => {
146
+ it('uses the first 16 chars of the source hash as the filename', () => {
147
+ const totemDir = '/some/dir/.totem';
148
+ const sourceHash = 'abcdef0123456789' + '0'.repeat(48);
149
+ const expected = path.join(totemDir, 'cache', 'compile-lesson', 'abcdef0123456789.json');
150
+ expect(cacheEntryPath(totemDir, sourceHash)).toBe(expected);
151
+ });
152
+ it('does not duplicate the .totem prefix when totemDir already includes it (CR R3 Major on #1983)', () => {
153
+ // Regression: an earlier draft hardcoded `.totem` into CACHE_DIR. Joined
154
+ // with totemDir (which already resolves to `<repo>/.totem`), the cache
155
+ // landed at `<repo>/.totem/.totem/cache/compile-lesson/...` — outside the
156
+ // documented path and invisible to tooling that scans `<totemDir>/cache`.
157
+ const totemDir = '/repo/.totem';
158
+ const result = cacheEntryPath(totemDir, 'a'.repeat(64));
159
+ expect(result).not.toContain(path.join('.totem', '.totem'));
160
+ expect(result).toBe(path.join('/repo/.totem', 'cache', 'compile-lesson', 'aaaaaaaaaaaaaaaa.json'));
161
+ });
162
+ it('rejects non-SHA cache keys to prevent path traversal (CR R4 Major on #1983)', () => {
163
+ // Without the regex guard, a sourceHash carrying `/` or `..` would flow
164
+ // through path.join() and escape `<totemDir>/cache/compile-lesson`, letting
165
+ // lookup or write touch arbitrary files. computeLessonSourceHash always
166
+ // emits the matching shape; this catches caller misuse.
167
+ expect(() => cacheEntryPath('/some/.totem', '../../../etc/passwd')).toThrow(/Invalid sourceHash/);
168
+ expect(() => cacheEntryPath('/some/.totem', 'a/b/c')).toThrow(/Invalid sourceHash/);
169
+ expect(() => cacheEntryPath('/some/.totem', '..')).toThrow(/Invalid sourceHash/);
170
+ expect(() => cacheEntryPath('/some/.totem', '')).toThrow(/Invalid sourceHash/);
171
+ expect(() => cacheEntryPath('/some/.totem', 'NOT_HEX_!@#$')).toThrow(/Invalid sourceHash/);
172
+ // Hex but wrong length
173
+ expect(() => cacheEntryPath('/some/.totem', 'abcdef')).toThrow(/Invalid sourceHash/);
174
+ // Uppercase hex — the regex requires lowercase for normalization
175
+ expect(() => cacheEntryPath('/some/.totem', 'A'.repeat(64))).toThrow(/Invalid sourceHash/);
176
+ // Sanity: a real digest is accepted
177
+ expect(() => cacheEntryPath('/some/.totem', 'a'.repeat(64))).not.toThrow();
178
+ });
179
+ });
180
+ // ─── lookupCacheEntry + writeCacheEntry round-trips ─
181
+ describe('cache lookup + write round-trip', () => {
182
+ let tmpDir;
183
+ beforeEach(() => {
184
+ tmpDir = makeTestTmpDir('totem-compile-cache-');
185
+ delete process.env.TOTEM_DISABLE_COMPILE_CACHE;
186
+ });
187
+ afterEach(() => {
188
+ cleanTmpDir(tmpDir);
189
+ delete process.env.TOTEM_DISABLE_COMPILE_CACHE;
190
+ });
191
+ it('cache_hit returns the stored entry byte-for-byte', () => {
192
+ const sourceHash = computeLessonSourceHash('lesson source one');
193
+ const output = makeCompiledResult('lesson-1-hash');
194
+ const entry = buildCacheEntry(sourceHash, FINGERPRINT_A, output);
195
+ writeCacheEntry(tmpDir, entry);
196
+ const result = lookupCacheEntry(tmpDir, sourceHash, FINGERPRINT_A);
197
+ expect(result.decision).toBe('cache_hit');
198
+ expect(result.entry).not.toBeNull();
199
+ // Strip the compiledAt timestamp for byte-for-byte comparison of output payload
200
+ expect(result.entry?.output).toEqual(output);
201
+ });
202
+ it('partial_mutation_invariant — modifying one lesson does not rotate siblings', () => {
203
+ // The falsifying-metric test. Build 5 cache entries; modify 1 lesson's
204
+ // source; verify the other 4 entries are still resolvable by their original
205
+ // sourceHash + the modified one is a cache miss.
206
+ const lessons = [
207
+ { source: 'lesson 1 source', hash: 'rule-hash-1' },
208
+ { source: 'lesson 2 source', hash: 'rule-hash-2' },
209
+ { source: 'lesson 3 source', hash: 'rule-hash-3' },
210
+ { source: 'lesson 4 source', hash: 'rule-hash-4' },
211
+ { source: 'lesson 5 source', hash: 'rule-hash-5' },
212
+ ];
213
+ // Initial seed
214
+ for (const lesson of lessons) {
215
+ const sourceHash = computeLessonSourceHash(lesson.source);
216
+ const entry = buildCacheEntry(sourceHash, FINGERPRINT_A, makeCompiledResult(lesson.hash));
217
+ writeCacheEntry(tmpDir, entry);
218
+ }
219
+ // Mutate lesson 3 only
220
+ lessons[2].source = 'lesson 3 MUTATED source';
221
+ // Verify the other 4 lessons still cache-hit on their original sourceHash
222
+ const indices = [0, 1, 3, 4];
223
+ for (const i of indices) {
224
+ const sourceHash = computeLessonSourceHash(lessons[i].source);
225
+ const result = lookupCacheEntry(tmpDir, sourceHash, FINGERPRINT_A);
226
+ expect(result.decision, `lesson ${i + 1} should hit`).toBe('cache_hit');
227
+ }
228
+ // And the mutated lesson is a no-prior-record miss (new sourceHash)
229
+ const mutatedHash = computeLessonSourceHash(lessons[2].source);
230
+ expect(lookupCacheEntry(tmpDir, mutatedHash, FINGERPRINT_A).decision).toBe('cache_miss_no_prior_record');
231
+ });
232
+ it('fingerprint_change_invalidates_all entries', () => {
233
+ const lessons = [
234
+ { source: 'lesson 1 source', hash: 'rule-hash-1' },
235
+ { source: 'lesson 2 source', hash: 'rule-hash-2' },
236
+ ];
237
+ for (const lesson of lessons) {
238
+ const sourceHash = computeLessonSourceHash(lesson.source);
239
+ const entry = buildCacheEntry(sourceHash, FINGERPRINT_A, makeCompiledResult(lesson.hash));
240
+ writeCacheEntry(tmpDir, entry);
241
+ }
242
+ for (const lesson of lessons) {
243
+ const sourceHash = computeLessonSourceHash(lesson.source);
244
+ const result = lookupCacheEntry(tmpDir, sourceHash, FINGERPRINT_B);
245
+ expect(result.decision).toBe('cache_miss_fingerprint_changed');
246
+ expect(result.entry).toBeNull();
247
+ }
248
+ });
249
+ it('source_change_invalidates_one — only the changed lesson misses', () => {
250
+ const original = 'original source';
251
+ const modified = 'modified source';
252
+ const originalHash = computeLessonSourceHash(original);
253
+ writeCacheEntry(tmpDir, buildCacheEntry(originalHash, FINGERPRINT_A, makeCompiledResult('original-rule')));
254
+ // Lookup of the original still hits
255
+ expect(lookupCacheEntry(tmpDir, originalHash, FINGERPRINT_A).decision).toBe('cache_hit');
256
+ // Lookup of the modified misses (different sourceHash)
257
+ const modifiedHash = computeLessonSourceHash(modified);
258
+ expect(lookupCacheEntry(tmpDir, modifiedHash, FINGERPRINT_A).decision).toBe('cache_miss_no_prior_record');
259
+ });
260
+ it('force option bypasses the cache without reading disk', () => {
261
+ const sourceHash = computeLessonSourceHash('any source');
262
+ writeCacheEntry(tmpDir, buildCacheEntry(sourceHash, FINGERPRINT_A, makeCompiledResult('rule-x')));
263
+ const result = lookupCacheEntry(tmpDir, sourceHash, FINGERPRINT_A, { force: true });
264
+ expect(result.decision).toBe('cache_miss_force');
265
+ expect(result.entry).toBeNull();
266
+ });
267
+ it('reserves the stableId slot — schema accepts and round-trips it', () => {
268
+ // P281 never writes stableId. This test asserts that an entry WRITTEN with
269
+ // a stableId (e.g., by future P280 wiring) parses correctly and the slot
270
+ // round-trips. Per #387 § Dependencies — load-bearing reservation.
271
+ const sourceHash = computeLessonSourceHash('lesson with future id');
272
+ const entry = {
273
+ sourceHash,
274
+ stableId: 'future-p280-stable-id',
275
+ fingerprint: FINGERPRINT_A,
276
+ output: makeCompiledResult('rule-with-stable-id'),
277
+ compiledAt: new Date().toISOString(),
278
+ };
279
+ writeCacheEntry(tmpDir, entry);
280
+ const result = lookupCacheEntry(tmpDir, sourceHash, FINGERPRINT_A);
281
+ expect(result.decision).toBe('cache_hit');
282
+ expect(result.entry?.stableId).toBe('future-p280-stable-id');
283
+ });
284
+ it('a malformed cache file produces a graceful cache miss (no throw)', () => {
285
+ const sourceHash = computeLessonSourceHash('lesson with bad cache file');
286
+ const filePath = cacheEntryPath(tmpDir, sourceHash);
287
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
288
+ fs.writeFileSync(filePath, '{ this is not valid JSON', 'utf-8');
289
+ const result = lookupCacheEntry(tmpDir, sourceHash, FINGERPRINT_A);
290
+ expect(result.decision).toBe('cache_miss_no_prior_record');
291
+ expect(result.entry).toBeNull();
292
+ });
293
+ it('a schema-invalid cache entry produces a graceful cache miss', () => {
294
+ const sourceHash = computeLessonSourceHash('lesson with bad schema');
295
+ const filePath = cacheEntryPath(tmpDir, sourceHash);
296
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
297
+ // Output.status is required to be one of the known enum values
298
+ fs.writeFileSync(filePath, JSON.stringify({
299
+ sourceHash,
300
+ fingerprint: FINGERPRINT_A,
301
+ output: { status: 'gibberish' },
302
+ compiledAt: '2026-05-21T00:00:00.000Z',
303
+ }), 'utf-8');
304
+ const result = lookupCacheEntry(tmpDir, sourceHash, FINGERPRINT_A);
305
+ expect(result.decision).toBe('cache_miss_no_prior_record');
306
+ });
307
+ it('incomplete cache payload (compiled without rule) is rejected (CR R3 Major on #1983)', () => {
308
+ // Status enum alone is not sufficient validation — a truncated payload like
309
+ // `{ status: 'compiled' }` would survive a minimal schema and crash the
310
+ // cache-hit path on the missing `rule` dereference. The discriminated union
311
+ // requires the per-variant fields downstream consumers depend on.
312
+ const sourceHash = computeLessonSourceHash('lesson with compiled-no-rule cache entry');
313
+ const filePath = cacheEntryPath(tmpDir, sourceHash);
314
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
315
+ fs.writeFileSync(filePath, JSON.stringify({
316
+ sourceHash,
317
+ fingerprint: FINGERPRINT_A,
318
+ output: { status: 'compiled' }, // missing required `rule`
319
+ compiledAt: '2026-05-21T00:00:00.000Z',
320
+ }), 'utf-8');
321
+ const result = lookupCacheEntry(tmpDir, sourceHash, FINGERPRINT_A);
322
+ expect(result.decision).toBe('cache_miss_no_prior_record');
323
+ expect(result.entry).toBeNull();
324
+ });
325
+ it('incomplete cache payload (skipped without hash/reasonCode) is rejected (CR R3 Major on #1983)', () => {
326
+ const sourceHash = computeLessonSourceHash('lesson with skipped-no-fields cache entry');
327
+ const filePath = cacheEntryPath(tmpDir, sourceHash);
328
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
329
+ fs.writeFileSync(filePath, JSON.stringify({
330
+ sourceHash,
331
+ fingerprint: FINGERPRINT_A,
332
+ output: { status: 'skipped' }, // missing required `hash` and `reasonCode`
333
+ compiledAt: '2026-05-21T00:00:00.000Z',
334
+ }), 'utf-8');
335
+ const result = lookupCacheEntry(tmpDir, sourceHash, FINGERPRINT_A);
336
+ expect(result.decision).toBe('cache_miss_no_prior_record');
337
+ });
338
+ it('hash-disagreeing cache file is treated as cache miss', () => {
339
+ // Defensive against manual cache edits — if the file at path-for-hash-X
340
+ // contains an entry claiming sourceHash-Y, treat as miss.
341
+ const realHash = computeLessonSourceHash('real content');
342
+ const filePath = cacheEntryPath(tmpDir, realHash);
343
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
344
+ fs.writeFileSync(filePath, JSON.stringify({
345
+ sourceHash: 'some-other-hash',
346
+ fingerprint: FINGERPRINT_A,
347
+ // Valid `compiled` shape so the schema check passes and the lookup
348
+ // reaches the sourceHash-disagreement defense (the assertion under test).
349
+ output: { status: 'compiled', rule: { lessonHash: 'whatever' } },
350
+ compiledAt: '2026-05-21T00:00:00.000Z',
351
+ }), 'utf-8');
352
+ const result = lookupCacheEntry(tmpDir, realHash, FINGERPRINT_A);
353
+ expect(result.decision).toBe('cache_miss_source_changed');
354
+ });
355
+ it('writes the entry as pretty-printed JSON with a trailing newline', () => {
356
+ const sourceHash = computeLessonSourceHash('formatting check');
357
+ const entry = buildCacheEntry(sourceHash, FINGERPRINT_A, makeCompiledResult('rule-format'));
358
+ writeCacheEntry(tmpDir, entry);
359
+ const filePath = cacheEntryPath(tmpDir, sourceHash);
360
+ const raw = fs.readFileSync(filePath, 'utf-8');
361
+ expect(raw.endsWith('\n')).toBe(true);
362
+ // Pretty-printed: multiple lines
363
+ expect(raw.split('\n').length).toBeGreaterThan(3);
364
+ });
365
+ it('round-trips a skipped CompileLessonResult', () => {
366
+ const sourceHash = computeLessonSourceHash('lesson that gets skipped');
367
+ const entry = buildCacheEntry(sourceHash, FINGERPRINT_A, makeSkippedResult('skipped-lesson-hash'));
368
+ writeCacheEntry(tmpDir, entry);
369
+ const result = lookupCacheEntry(tmpDir, sourceHash, FINGERPRINT_A);
370
+ expect(result.decision).toBe('cache_hit');
371
+ expect(result.entry?.output.status).toBe('skipped');
372
+ });
373
+ it('round-trips a noop CompileLessonResult', () => {
374
+ // Closes a CompileLessonResult variant-coverage gap. `noop` is the
375
+ // discriminator emitted when a compile invocation has nothing to do (e.g.,
376
+ // an upgrade-batch run where the lesson body is unchanged); the cache must
377
+ // round-trip it without dropping the status field through schema
378
+ // validation.
379
+ const sourceHash = computeLessonSourceHash('lesson that is a noop');
380
+ const entry = buildCacheEntry(sourceHash, FINGERPRINT_A, makeNoopResult());
381
+ writeCacheEntry(tmpDir, entry);
382
+ const result = lookupCacheEntry(tmpDir, sourceHash, FINGERPRINT_A);
383
+ expect(result.decision).toBe('cache_hit');
384
+ expect(result.entry?.output.status).toBe('noop');
385
+ });
386
+ it('round-trips a failed CompileLessonResult (defensive — failures normally bypass cache)', () => {
387
+ // compile.ts wraps the parallel-compile path and explicitly skips writing
388
+ // `failed` outputs (failures are transient; caching them would poison
389
+ // subsequent runs). But the cache primitives themselves must handle the
390
+ // shape correctly when a caller writes one directly — guards against
391
+ // schema regressions that would break the discriminated union for the
392
+ // failed variant.
393
+ const sourceHash = computeLessonSourceHash('lesson that failed');
394
+ const entry = buildCacheEntry(sourceHash, FINGERPRINT_A, makeFailedResult());
395
+ writeCacheEntry(tmpDir, entry);
396
+ const result = lookupCacheEntry(tmpDir, sourceHash, FINGERPRINT_A);
397
+ expect(result.decision).toBe('cache_hit');
398
+ expect(result.entry?.output.status).toBe('failed');
399
+ });
400
+ });
401
+ // ─── TOTEM_DISABLE_COMPILE_CACHE escape hatch ──────
402
+ describe('TOTEM_DISABLE_COMPILE_CACHE env var', () => {
403
+ let tmpDir;
404
+ beforeEach(() => {
405
+ tmpDir = makeTestTmpDir('totem-compile-cache-env-');
406
+ });
407
+ afterEach(() => {
408
+ cleanTmpDir(tmpDir);
409
+ delete process.env.TOTEM_DISABLE_COMPILE_CACHE;
410
+ });
411
+ it('lookup returns no-prior-record when disabled, even if a valid entry exists on disk', () => {
412
+ const sourceHash = computeLessonSourceHash('disable env test');
413
+ // Seed first without the env var so the file lands on disk
414
+ writeCacheEntry(tmpDir, buildCacheEntry(sourceHash, FINGERPRINT_A, makeCompiledResult('rule-disable')));
415
+ process.env.TOTEM_DISABLE_COMPILE_CACHE = '1';
416
+ const result = lookupCacheEntry(tmpDir, sourceHash, FINGERPRINT_A);
417
+ expect(result.decision).toBe('cache_miss_no_prior_record');
418
+ expect(result.entry).toBeNull();
419
+ });
420
+ it('write is a no-op when the env var is set', () => {
421
+ process.env.TOTEM_DISABLE_COMPILE_CACHE = '1';
422
+ const sourceHash = computeLessonSourceHash('disable env write');
423
+ writeCacheEntry(tmpDir, buildCacheEntry(sourceHash, FINGERPRINT_A, makeCompiledResult('rule-disable-write')));
424
+ const filePath = cacheEntryPath(tmpDir, sourceHash);
425
+ expect(fs.existsSync(filePath)).toBe(false);
426
+ });
427
+ });
428
+ // ─── Migration seed ─────────────────────────────────
429
+ describe('migrateFromCompiledRules', () => {
430
+ let tmpDir;
431
+ beforeEach(() => {
432
+ tmpDir = makeTestTmpDir('totem-compile-cache-migrate-');
433
+ delete process.env.TOTEM_DISABLE_COMPILE_CACHE;
434
+ });
435
+ afterEach(() => {
436
+ cleanTmpDir(tmpDir);
437
+ delete process.env.TOTEM_DISABLE_COMPILE_CACHE;
438
+ });
439
+ it('seeds 100% cache hit for subsequent lookups', () => {
440
+ const inputs = [
441
+ {
442
+ lessonHash: 'seed-1',
443
+ heading: 'Lesson 1 heading',
444
+ body: 'seed lesson 1 body content\n',
445
+ output: makeCompiledResult('seed-1'),
446
+ },
447
+ {
448
+ lessonHash: 'seed-2',
449
+ heading: 'Lesson 2 heading',
450
+ body: 'seed lesson 2 body content\n',
451
+ output: makeCompiledResult('seed-2'),
452
+ },
453
+ {
454
+ lessonHash: 'seed-3',
455
+ heading: 'Lesson 3 heading',
456
+ body: 'seed lesson 3 body content\n',
457
+ output: makeCompiledResult('seed-3'),
458
+ },
459
+ ];
460
+ const result = migrateFromCompiledRules(tmpDir, FINGERPRINT_A, inputs);
461
+ expect(result.seeded).toBe(3);
462
+ expect(result.skipped).toBe(0);
463
+ for (const input of inputs) {
464
+ // Migration must produce hashes that the runtime lookup can hit. Both
465
+ // paths route through `composeLessonSourceForHash` to guarantee this.
466
+ const sourceHash = computeLessonSourceHash(composeLessonSourceForHash(input.heading, input.body));
467
+ const lookup = lookupCacheEntry(tmpDir, sourceHash, FINGERPRINT_A);
468
+ expect(lookup.decision).toBe('cache_hit');
469
+ }
470
+ });
471
+ it('runtime/migration hash consistency (GCA R2 critical on #1983)', () => {
472
+ // Load-bearing regression test: a lesson seeded via the migration path
473
+ // MUST be hittable via the runtime lookup path using the same heading +
474
+ // body. Before this fix, migration hashed raw `lessonSource` (the parsed
475
+ // file content including markdown framing) while runtime hashed
476
+ // `${heading}\n${body}` — the same lesson produced two different hashes,
477
+ // making every migrated entry permanently unreachable.
478
+ const heading = 'Some lesson heading';
479
+ const body = 'body line 1\nbody line 2\n';
480
+ migrateFromCompiledRules(tmpDir, FINGERPRINT_A, [
481
+ { lessonHash: 'consistency-1', heading, body, output: makeCompiledResult('consistency-1') },
482
+ ]);
483
+ // Simulating the runtime call shape from compile.ts
484
+ const runtimeSourceHash = computeLessonSourceHash(composeLessonSourceForHash(heading, body));
485
+ const lookup = lookupCacheEntry(tmpDir, runtimeSourceHash, FINGERPRINT_A);
486
+ expect(lookup.decision).toBe('cache_hit');
487
+ expect(lookup.entry).not.toBeNull();
488
+ });
489
+ it('is idempotent — running twice produces the same on-disk state', () => {
490
+ const inputs = [
491
+ {
492
+ lessonHash: 'idempotent-1',
493
+ heading: 'Idempotent heading',
494
+ body: 'idempotent source one body\n',
495
+ output: makeCompiledResult('idempotent-1'),
496
+ },
497
+ ];
498
+ migrateFromCompiledRules(tmpDir, FINGERPRINT_A, inputs);
499
+ const sourceHash = computeLessonSourceHash(composeLessonSourceForHash(inputs[0].heading, inputs[0].body));
500
+ const after1 = fs.readFileSync(cacheEntryPath(tmpDir, sourceHash), 'utf-8');
501
+ migrateFromCompiledRules(tmpDir, FINGERPRINT_A, inputs);
502
+ const after2 = fs.readFileSync(cacheEntryPath(tmpDir, sourceHash), 'utf-8');
503
+ // Bodies match except possibly compiledAt; structural equivalence post-parse
504
+ const parsed1 = JSON.parse(after1);
505
+ const parsed2 = JSON.parse(after2);
506
+ parsed1.compiledAt = parsed2.compiledAt = 'normalized';
507
+ expect(parsed1).toEqual(parsed2);
508
+ });
509
+ it('skips when env var disables cache, reporting all inputs as skipped', () => {
510
+ process.env.TOTEM_DISABLE_COMPILE_CACHE = '1';
511
+ const inputs = [
512
+ {
513
+ lessonHash: 'disabled-1',
514
+ heading: 'Disabled heading',
515
+ body: 'disabled body\n',
516
+ output: makeCompiledResult('disabled-1'),
517
+ },
518
+ ];
519
+ const result = migrateFromCompiledRules(tmpDir, FINGERPRINT_A, inputs);
520
+ expect(result.seeded).toBe(0);
521
+ expect(result.skipped).toBe(1);
522
+ });
523
+ it('seeded count reflects entries actually persisted (write failures land in skipped)', () => {
524
+ // Defensive guarantee: even if `writeCacheEntry` returns false (write
525
+ // failure or env-disable), the seeded counter must not increment. This
526
+ // tests the writeCacheEntry boolean-return contract from the migration
527
+ // bookkeeping path.
528
+ process.env.TOTEM_DISABLE_COMPILE_CACHE = '1';
529
+ const inputs = [
530
+ { lessonHash: 'a', heading: 'Lesson A', body: 'a body\n', output: makeCompiledResult('a') },
531
+ { lessonHash: 'b', heading: 'Lesson B', body: 'b body\n', output: makeCompiledResult('b') },
532
+ { lessonHash: 'c', heading: 'Lesson C', body: 'c body\n', output: makeCompiledResult('c') },
533
+ ];
534
+ const result = migrateFromCompiledRules(tmpDir, FINGERPRINT_A, inputs);
535
+ expect(result.seeded).toBe(0);
536
+ expect(result.skipped).toBe(3);
537
+ });
538
+ });
539
+ // ─── listCacheEntries ───────────────────────────────
540
+ describe('listCacheEntries', () => {
541
+ let tmpDir;
542
+ beforeEach(() => {
543
+ tmpDir = makeTestTmpDir('totem-compile-cache-list-');
544
+ });
545
+ afterEach(() => {
546
+ cleanTmpDir(tmpDir);
547
+ });
548
+ it('returns empty array when the cache directory does not exist', () => {
549
+ expect(listCacheEntries(tmpDir)).toEqual([]);
550
+ });
551
+ it('lists all written cache entry filenames', () => {
552
+ const sources = ['lesson alpha', 'lesson beta', 'lesson gamma'];
553
+ for (const src of sources) {
554
+ const sourceHash = computeLessonSourceHash(src);
555
+ writeCacheEntry(tmpDir, buildCacheEntry(sourceHash, FINGERPRINT_A, makeCompiledResult(src)));
556
+ }
557
+ const entries = listCacheEntries(tmpDir);
558
+ expect(entries.length).toBe(3);
559
+ for (const entry of entries) {
560
+ expect(entry).toMatch(/^[a-f0-9]{16}\.json$/);
561
+ }
562
+ });
563
+ it('ignores non-json files in the cache directory', () => {
564
+ const sourceHash = computeLessonSourceHash('only json counts');
565
+ writeCacheEntry(tmpDir, buildCacheEntry(sourceHash, FINGERPRINT_A, makeCompiledResult('only')));
566
+ // Drop a stray file beside the cache entries
567
+ const cacheDir = path.dirname(cacheEntryPath(tmpDir, sourceHash));
568
+ fs.writeFileSync(path.join(cacheDir, 'README.txt'), 'stray');
569
+ const entries = listCacheEntries(tmpDir);
570
+ expect(entries.length).toBe(1);
571
+ expect(entries[0]).toMatch(/\.json$/);
572
+ });
573
+ });
574
+ // ─── writeCacheEntry return value ───────────────────
575
+ describe('writeCacheEntry return value', () => {
576
+ let tmpDir;
577
+ beforeEach(() => {
578
+ tmpDir = makeTestTmpDir('totem-compile-cache-write-');
579
+ delete process.env.TOTEM_DISABLE_COMPILE_CACHE;
580
+ });
581
+ afterEach(() => {
582
+ cleanTmpDir(tmpDir);
583
+ delete process.env.TOTEM_DISABLE_COMPILE_CACHE;
584
+ });
585
+ it('returns true on successful write', () => {
586
+ const sourceHash = computeLessonSourceHash('write-ok source');
587
+ const result = writeCacheEntry(tmpDir, buildCacheEntry(sourceHash, FINGERPRINT_A, makeCompiledResult('write-ok')));
588
+ expect(result).toBe(true);
589
+ });
590
+ it('returns false when the env-var disables the cache', () => {
591
+ process.env.TOTEM_DISABLE_COMPILE_CACHE = '1';
592
+ const sourceHash = computeLessonSourceHash('write-disabled source');
593
+ const result = writeCacheEntry(tmpDir, buildCacheEntry(sourceHash, FINGERPRINT_A, makeCompiledResult('write-disabled')));
594
+ expect(result).toBe(false);
595
+ });
596
+ it('swallows a throwing onWarn — never propagates out of cache write (CR R4 Major on #1983)', () => {
597
+ // The cache's non-throwing contract requires that a caller-supplied onWarn
598
+ // cannot abort compile even when it itself throws. Without this, a
599
+ // misbehaving telemetry hook could escalate a benign cache-write failure
600
+ // into a hard compile abort.
601
+ const throwingWarn = () => {
602
+ throw new Error('caller-provided onWarn exploded');
603
+ };
604
+ // Force a write failure by pointing at a path that can't be created. On
605
+ // Windows this is a path with an invalid char; on POSIX a path under a
606
+ // file. Easiest cross-platform shape: pass a non-existent volume root.
607
+ const badDir = path.join(tmpDir, '\x00invalid');
608
+ const sourceHash = computeLessonSourceHash('write-with-throwing-onwarn');
609
+ const entry = buildCacheEntry(sourceHash, FINGERPRINT_A, makeCompiledResult('throwing-warn'));
610
+ expect(() => writeCacheEntry(badDir, entry, throwingWarn)).not.toThrow();
611
+ });
612
+ });
613
+ // ─── safeOnWarn guard via migrateFromCompiledRules ──
614
+ describe('safeOnWarn (CR R4 Major on #1983)', () => {
615
+ let tmpDir;
616
+ beforeEach(() => {
617
+ tmpDir = makeTestTmpDir('totem-compile-cache-safewarn-');
618
+ delete process.env.TOTEM_DISABLE_COMPILE_CACHE;
619
+ });
620
+ afterEach(() => {
621
+ cleanTmpDir(tmpDir);
622
+ delete process.env.TOTEM_DISABLE_COMPILE_CACHE;
623
+ });
624
+ it('migrateFromCompiledRules: throwing onWarn does not abort the seed loop', () => {
625
+ // If onWarn throws on one input, subsequent inputs must still be processed.
626
+ // Without safeOnWarn the throw would escape the per-input catch and
627
+ // short-circuit the migration.
628
+ const throwingWarn = () => {
629
+ throw new Error('telemetry hook exploded');
630
+ };
631
+ // First input is malformed (heading has bad hash shape from buildCacheEntry
632
+ // path); subsequent valid inputs should still seed.
633
+ const inputs = [
634
+ {
635
+ lessonHash: 'will-warn-due-to-write-failure',
636
+ heading: 'Bad Lesson',
637
+ body: 'will fail to write\n',
638
+ output: makeCompiledResult('bad'),
639
+ },
640
+ {
641
+ lessonHash: 'will-seed',
642
+ heading: 'Good Lesson',
643
+ body: 'should-be-seeded\n',
644
+ output: makeCompiledResult('good'),
645
+ },
646
+ ];
647
+ // Force the first write to fail by passing a directory that can't be
648
+ // created (null byte). The throwing onWarn would normally explode the
649
+ // loop; with safeOnWarn the second input still seeds.
650
+ const badDir = path.join(tmpDir, '\x00invalid');
651
+ expect(() => migrateFromCompiledRules(badDir, FINGERPRINT_A, inputs, throwingWarn)).not.toThrow();
652
+ });
653
+ });
654
+ //# sourceMappingURL=compile-cache.test.js.map