@mmnto/totem 1.44.0 → 1.46.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/dist/ast-grep-query.js +1 -1
- package/dist/ast-grep-query.js.map +1 -1
- package/dist/compile-cache.d.ts +223 -0
- package/dist/compile-cache.d.ts.map +1 -0
- package/dist/compile-cache.js +325 -0
- package/dist/compile-cache.js.map +1 -0
- package/dist/compile-cache.test.d.ts +9 -0
- package/dist/compile-cache.test.d.ts.map +1 -0
- package/dist/compile-cache.test.js +654 -0
- package/dist/compile-cache.test.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/ledger.d.ts +8 -3
- package/dist/ledger.d.ts.map +1 -1
- package/dist/ledger.js +6 -0
- package/dist/ledger.js.map +1 -1
- package/package.json +1 -1
|
@@ -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
|