cache-verdict 0.0.0-stage → 0.1.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/LICENSE +201 -0
- package/README.md +311 -2
- package/bin/cache-verdict.mjs +383 -0
- package/examples/npm-build-cache/README.md +95 -0
- package/examples/npm-build-cache/build.mjs +25 -0
- package/examples/npm-build-cache/package.json +10 -0
- package/examples/npm-build-cache/src/input.txt +5 -0
- package/examples/run.mjs +63 -0
- package/lib/config.mjs +141 -0
- package/lib/detect.mjs +647 -0
- package/lib/glob.mjs +192 -0
- package/lib/key.mjs +52 -0
- package/lib/ledger.mjs +357 -0
- package/lib/report.mjs +144 -0
- package/lib/run.mjs +204 -0
- package/package.json +41 -4
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
detectProject,
|
|
7
|
+
isSupportedEcosystem,
|
|
8
|
+
supportedEcosystemIds,
|
|
9
|
+
} from '../lib/detect.mjs';
|
|
10
|
+
import {
|
|
11
|
+
CONFIG_FILENAME,
|
|
12
|
+
buildConfig,
|
|
13
|
+
loadConfig,
|
|
14
|
+
serializeConfig,
|
|
15
|
+
writeConfig,
|
|
16
|
+
} from '../lib/config.mjs';
|
|
17
|
+
import { pruneStore, readLedger, storeStats } from '../lib/ledger.mjs';
|
|
18
|
+
import { analyzeSamples, formatReport } from '../lib/report.mjs';
|
|
19
|
+
import { formatRunSummary, runSample } from '../lib/run.mjs';
|
|
20
|
+
|
|
21
|
+
const VERSION = JSON.parse(
|
|
22
|
+
readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
|
|
23
|
+
).version;
|
|
24
|
+
|
|
25
|
+
const USAGE = `cache-verdict: times build-cache cold vs warm runs and says whether the cache pays
|
|
26
|
+
|
|
27
|
+
Usage:
|
|
28
|
+
cache-verdict init [directory] [options]
|
|
29
|
+
cache-verdict run [directory] [--cold]
|
|
30
|
+
cache-verdict report [directory] [--json]
|
|
31
|
+
cache-verdict check [directory]
|
|
32
|
+
cache-verdict prune [directory] --keep <n>
|
|
33
|
+
cache-verdict --help
|
|
34
|
+
cache-verdict --version
|
|
35
|
+
|
|
36
|
+
Commands:
|
|
37
|
+
init Detect Cargo, Gradle, Next.js, npm, Python, or Go in a project and
|
|
38
|
+
write a reviewable .cache-verdict.json beside its build files.
|
|
39
|
+
run Move declared cache dirs aside, restore a cache for the content-
|
|
40
|
+
addressed key when one exists, time the build, store the produced
|
|
41
|
+
artifacts, and append a sample to .cache-verdict/ledger.jsonl.
|
|
42
|
+
report Print hit rate, median cold and warm totals, restore cost, net ms
|
|
43
|
+
per hit, cache size, and one verdict.
|
|
44
|
+
check Exit 0 for cache-pays, 2 for cache-costs, cache-churns,
|
|
45
|
+
cache-never-hits, or cache-unobserved, and 3 when more runs are
|
|
46
|
+
needed before a verdict is possible.
|
|
47
|
+
prune Evict least-recently-used stored cache keys.
|
|
48
|
+
|
|
49
|
+
Init options:
|
|
50
|
+
-e, --ecosystem <id> Use this detector when several build systems match.
|
|
51
|
+
One of: ${supportedEcosystemIds().join(', ')}
|
|
52
|
+
--command <cmd> Override the detected build command.
|
|
53
|
+
-f, --force Overwrite an existing .cache-verdict.json.
|
|
54
|
+
--dry-run Print the config to stdout instead of writing it.
|
|
55
|
+
-h, --help Show this help.
|
|
56
|
+
|
|
57
|
+
Run options:
|
|
58
|
+
--cold Force a cold baseline sample. A stored cache is kept
|
|
59
|
+
for the next normal run, so it does not prevent a hit.
|
|
60
|
+
-h, --help Show this help.
|
|
61
|
+
|
|
62
|
+
Report options:
|
|
63
|
+
--json Print the analysis as JSON.
|
|
64
|
+
-h, --help Show this help.
|
|
65
|
+
|
|
66
|
+
Prune options:
|
|
67
|
+
-k, --keep <n> Number of most-recently-used stored keys to retain.
|
|
68
|
+
-h, --help Show this help.
|
|
69
|
+
|
|
70
|
+
The config records the detected build command, the files whose contents make
|
|
71
|
+
up the cache key, the cache paths to measure, and why the project was detected.
|
|
72
|
+
Review and edit it before sampling. Detection order is Next.js, Cargo, Gradle,
|
|
73
|
+
Go, Python, then npm; use --ecosystem to override.
|
|
74
|
+
|
|
75
|
+
Measured files live under .cache-verdict/ in the project: ledger.jsonl is the
|
|
76
|
+
append-only sample history and store/ holds one content-addressed cache per
|
|
77
|
+
key. Add .cache-verdict/ to .gitignore.
|
|
78
|
+
|
|
79
|
+
Exit codes:
|
|
80
|
+
0 success; check found that the cache pays
|
|
81
|
+
1 no matching build system, unreadable directory, existing config, or a
|
|
82
|
+
failed build command
|
|
83
|
+
2 invalid command-line usage; check found a losing, churning, unobserved,
|
|
84
|
+
or never-hitting cache
|
|
85
|
+
3 check needs more runs before it can decide
|
|
86
|
+
`;
|
|
87
|
+
|
|
88
|
+
class UsageError extends Error {}
|
|
89
|
+
|
|
90
|
+
class CliError extends Error {}
|
|
91
|
+
|
|
92
|
+
function resolveDirectory(value) {
|
|
93
|
+
const directory = path.resolve(process.cwd(), value);
|
|
94
|
+
let stat;
|
|
95
|
+
try {
|
|
96
|
+
stat = statSync(directory);
|
|
97
|
+
} catch {
|
|
98
|
+
throw new CliError(`cannot read directory ${directory}`);
|
|
99
|
+
}
|
|
100
|
+
if (!stat.isDirectory()) throw new CliError(`${directory} is not a directory`);
|
|
101
|
+
return directory;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function parseInitOptions(args) {
|
|
105
|
+
const options = {
|
|
106
|
+
dir: '.',
|
|
107
|
+
force: false,
|
|
108
|
+
dryRun: false,
|
|
109
|
+
ecosystem: null,
|
|
110
|
+
command: undefined,
|
|
111
|
+
help: false,
|
|
112
|
+
};
|
|
113
|
+
const positional = [];
|
|
114
|
+
|
|
115
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
116
|
+
const argument = args[index];
|
|
117
|
+
|
|
118
|
+
if (argument === '--help' || argument === '-h') {
|
|
119
|
+
options.help = true;
|
|
120
|
+
} else if (argument === '--force' || argument === '-f') {
|
|
121
|
+
options.force = true;
|
|
122
|
+
} else if (argument === '--dry-run') {
|
|
123
|
+
options.dryRun = true;
|
|
124
|
+
} else if (argument === '--ecosystem' || argument === '-e') {
|
|
125
|
+
const value = args[index + 1];
|
|
126
|
+
if (value === undefined || value.startsWith('-')) {
|
|
127
|
+
throw new UsageError(`${argument} needs an ecosystem id`);
|
|
128
|
+
}
|
|
129
|
+
options.ecosystem = value;
|
|
130
|
+
index += 1;
|
|
131
|
+
} else if (argument.startsWith('--ecosystem=')) {
|
|
132
|
+
const value = argument.slice('--ecosystem='.length);
|
|
133
|
+
if (!value) throw new UsageError('--ecosystem needs an ecosystem id');
|
|
134
|
+
options.ecosystem = value;
|
|
135
|
+
} else if (argument === '--command') {
|
|
136
|
+
const value = args[index + 1];
|
|
137
|
+
if (value === undefined) throw new UsageError('--command needs a value');
|
|
138
|
+
options.command = value;
|
|
139
|
+
index += 1;
|
|
140
|
+
} else if (argument.startsWith('--command=')) {
|
|
141
|
+
const value = argument.slice('--command='.length);
|
|
142
|
+
if (!value.trim()) throw new UsageError('--command needs a value');
|
|
143
|
+
options.command = value;
|
|
144
|
+
} else if (argument === '--') {
|
|
145
|
+
positional.push(...args.slice(index + 1));
|
|
146
|
+
break;
|
|
147
|
+
} else if (argument.startsWith('-')) {
|
|
148
|
+
throw new UsageError(`unknown option ${argument}`);
|
|
149
|
+
} else {
|
|
150
|
+
positional.push(argument);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (positional.length > 1) {
|
|
155
|
+
throw new UsageError('init accepts at most one directory');
|
|
156
|
+
}
|
|
157
|
+
if (positional.length === 1) options.dir = positional[0];
|
|
158
|
+
if (options.command !== undefined && !options.command.trim()) {
|
|
159
|
+
throw new UsageError('--command needs a value');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return options;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function parseSimpleOptions(args, knownFlags) {
|
|
166
|
+
const options = { dir: '.', help: false };
|
|
167
|
+
for (const flag of knownFlags) options[flag] = false;
|
|
168
|
+
const positional = [];
|
|
169
|
+
|
|
170
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
171
|
+
const argument = args[index];
|
|
172
|
+
if (argument === '--help' || argument === '-h') {
|
|
173
|
+
options.help = true;
|
|
174
|
+
} else if (argument === '--') {
|
|
175
|
+
positional.push(...args.slice(index + 1));
|
|
176
|
+
break;
|
|
177
|
+
} else if (argument.startsWith('-') && knownFlags.includes(argument.slice(2))) {
|
|
178
|
+
options[argument.slice(2)] = true;
|
|
179
|
+
} else if (argument.startsWith('-')) {
|
|
180
|
+
throw new UsageError(`unknown option ${argument}`);
|
|
181
|
+
} else {
|
|
182
|
+
positional.push(argument);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (positional.length > 1) throw new UsageError('this command accepts at most one directory');
|
|
187
|
+
if (positional.length === 1) options.dir = positional[0];
|
|
188
|
+
return options;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function parsePruneOptions(args) {
|
|
192
|
+
const options = { dir: '.', keep: null, help: false };
|
|
193
|
+
const positional = [];
|
|
194
|
+
|
|
195
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
196
|
+
const argument = args[index];
|
|
197
|
+
if (argument === '--help' || argument === '-h') {
|
|
198
|
+
options.help = true;
|
|
199
|
+
} else if (argument === '--keep' || argument === '-k') {
|
|
200
|
+
const value = args[index + 1];
|
|
201
|
+
if (value === undefined || value.startsWith('-')) {
|
|
202
|
+
throw new UsageError(`${argument} needs a count`);
|
|
203
|
+
}
|
|
204
|
+
options.keep = value;
|
|
205
|
+
index += 1;
|
|
206
|
+
} else if (argument.startsWith('--keep=')) {
|
|
207
|
+
options.keep = argument.slice('--keep='.length);
|
|
208
|
+
} else if (argument === '--') {
|
|
209
|
+
positional.push(...args.slice(index + 1));
|
|
210
|
+
break;
|
|
211
|
+
} else if (argument.startsWith('-')) {
|
|
212
|
+
throw new UsageError(`unknown option ${argument}`);
|
|
213
|
+
} else {
|
|
214
|
+
positional.push(argument);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (positional.length > 1) throw new UsageError('prune accepts at most one directory');
|
|
219
|
+
if (positional.length === 1) options.dir = positional[0];
|
|
220
|
+
if (options.help) return options;
|
|
221
|
+
|
|
222
|
+
if (options.keep === null) throw new UsageError('prune needs --keep <n>');
|
|
223
|
+
if (!/^\d+$/.test(String(options.keep))) {
|
|
224
|
+
throw new UsageError('--keep needs a non-negative integer');
|
|
225
|
+
}
|
|
226
|
+
options.keep = Number(options.keep);
|
|
227
|
+
return options;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function init(args) {
|
|
231
|
+
const options = parseInitOptions(args);
|
|
232
|
+
if (options.help) {
|
|
233
|
+
process.stdout.write(USAGE);
|
|
234
|
+
return 0;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (options.ecosystem !== null && !isSupportedEcosystem(options.ecosystem)) {
|
|
238
|
+
throw new UsageError(
|
|
239
|
+
`unsupported ecosystem "${options.ecosystem}"; choose one of: ${supportedEcosystemIds().join(', ')}`,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const directory = resolveDirectory(options.dir);
|
|
244
|
+
const detection = detectProject(directory, { ecosystem: options.ecosystem });
|
|
245
|
+
const config = buildConfig(detection, options.command === undefined ? {} : { command: options.command });
|
|
246
|
+
|
|
247
|
+
if (options.dryRun) {
|
|
248
|
+
process.stdout.write(serializeConfig(config));
|
|
249
|
+
return 0;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const target = writeConfig(directory, config, { force: options.force });
|
|
253
|
+
const shown = path.relative(process.cwd(), target) || CONFIG_FILENAME;
|
|
254
|
+
|
|
255
|
+
console.log(`Detected ${detection.displayName} from ${detection.detectedFrom.join(', ')}.`);
|
|
256
|
+
console.log(`Wrote ${shown}.`);
|
|
257
|
+
if (detection.alternatives.length > 0) {
|
|
258
|
+
const alternatives = detection.alternatives.map((item) => item.ecosystem).join(', ');
|
|
259
|
+
console.log(`Also detected: ${alternatives}. Use --ecosystem to choose one instead.`);
|
|
260
|
+
}
|
|
261
|
+
for (const note of config.notes ?? []) console.log(`note: ${note}`);
|
|
262
|
+
console.log('Review "command", "inputs", and "cachePath" before sampling.');
|
|
263
|
+
|
|
264
|
+
return 0;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function run(args) {
|
|
268
|
+
const options = parseSimpleOptions(args, ['cold']);
|
|
269
|
+
if (options.help) {
|
|
270
|
+
process.stdout.write(USAGE);
|
|
271
|
+
return 0;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const directory = resolveDirectory(options.dir);
|
|
275
|
+
const config = loadConfig(directory);
|
|
276
|
+
const sample = runSample(directory, { cold: options.cold });
|
|
277
|
+
console.log(formatRunSummary(sample, config));
|
|
278
|
+
return 0;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function analyzeProject(directory) {
|
|
282
|
+
const config = loadConfig(directory);
|
|
283
|
+
const samples = readLedger(directory);
|
|
284
|
+
const stats = storeStats(directory);
|
|
285
|
+
return analyzeSamples(samples, {
|
|
286
|
+
hasCachePaths: config.cachePath.length > 0,
|
|
287
|
+
storedKeys: stats.keys,
|
|
288
|
+
storedSizeBytes: stats.sizeBytes,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function report(args) {
|
|
293
|
+
const options = parseSimpleOptions(args, ['json']);
|
|
294
|
+
if (options.help) {
|
|
295
|
+
process.stdout.write(USAGE);
|
|
296
|
+
return 0;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const directory = resolveDirectory(options.dir);
|
|
300
|
+
const analysis = analyzeProject(directory);
|
|
301
|
+
if (options.json) {
|
|
302
|
+
console.log(JSON.stringify(analysis, null, 2));
|
|
303
|
+
} else {
|
|
304
|
+
process.stdout.write(formatReport(analysis));
|
|
305
|
+
}
|
|
306
|
+
return 0;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function check(args) {
|
|
310
|
+
const options = parseSimpleOptions(args, []);
|
|
311
|
+
if (options.help) {
|
|
312
|
+
process.stdout.write(USAGE);
|
|
313
|
+
return 0;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const directory = resolveDirectory(options.dir);
|
|
317
|
+
const analysis = analyzeProject(directory);
|
|
318
|
+
process.stdout.write(formatReport(analysis));
|
|
319
|
+
|
|
320
|
+
if (analysis.moreRunsNeeded) {
|
|
321
|
+
process.stderr.write(
|
|
322
|
+
'cache-verdict: not enough samples yet; run the build again before gating CI\n',
|
|
323
|
+
);
|
|
324
|
+
return 3;
|
|
325
|
+
}
|
|
326
|
+
if (analysis.verdict === 'cache-pays') return 0;
|
|
327
|
+
|
|
328
|
+
process.stderr.write(`cache-verdict: ${analysis.verdict}; the cache did not pay for itself\n`);
|
|
329
|
+
return 2;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function prune(args) {
|
|
333
|
+
const options = parsePruneOptions(args);
|
|
334
|
+
if (options.help) {
|
|
335
|
+
process.stdout.write(USAGE);
|
|
336
|
+
return 0;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const directory = resolveDirectory(options.dir);
|
|
340
|
+
loadConfig(directory);
|
|
341
|
+
const result = pruneStore(directory, options.keep);
|
|
342
|
+
console.log(
|
|
343
|
+
`Kept ${result.kept.length} stored key${result.kept.length === 1 ? '' : 's'}; evicted ${result.evicted.length}.`,
|
|
344
|
+
);
|
|
345
|
+
return 0;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function main(argv) {
|
|
349
|
+
if (argv.length === 0) {
|
|
350
|
+
process.stdout.write(USAGE);
|
|
351
|
+
return 0;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const [command, ...rest] = argv;
|
|
355
|
+
|
|
356
|
+
if (command === '--help' || command === '-h' || command === 'help') {
|
|
357
|
+
process.stdout.write(USAGE);
|
|
358
|
+
return 0;
|
|
359
|
+
}
|
|
360
|
+
if (command === '--version' || command === '-v') {
|
|
361
|
+
console.log(VERSION);
|
|
362
|
+
return 0;
|
|
363
|
+
}
|
|
364
|
+
if (command === 'init') return init(rest);
|
|
365
|
+
if (command === 'run') return run(rest);
|
|
366
|
+
if (command === 'report') return report(rest);
|
|
367
|
+
if (command === 'check') return check(rest);
|
|
368
|
+
if (command === 'prune') return prune(rest);
|
|
369
|
+
if (command.startsWith('-')) throw new UsageError(`unknown option ${command}`);
|
|
370
|
+
throw new UsageError(`unknown command "${command}"`);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
try {
|
|
374
|
+
process.exitCode = main(process.argv.slice(2));
|
|
375
|
+
} catch (error) {
|
|
376
|
+
if (error instanceof UsageError) {
|
|
377
|
+
process.stderr.write(`cache-verdict: ${error.message}\n\n${USAGE}`);
|
|
378
|
+
process.exitCode = 2;
|
|
379
|
+
} else {
|
|
380
|
+
process.stderr.write(`cache-verdict: ${error.message}\n`);
|
|
381
|
+
process.exitCode = Number.isInteger(error.exitCode) ? error.exitCode : 1;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# npm build-cache example
|
|
2
|
+
|
|
3
|
+
A tiny npm project with a deliberately slow cold build and a fast warm build.
|
|
4
|
+
`cache-verdict` can show the difference in numbers instead of a hit counter.
|
|
5
|
+
|
|
6
|
+
- `src/input.txt` is the input. Editing it changes the cache key.
|
|
7
|
+
- `build.mjs` reads `src/input.txt`, writes an artifact to
|
|
8
|
+
`node_modules/.cache/`, and spends 700 ms "compiling" only when that artifact
|
|
9
|
+
is missing. The cache path `node_modules/.cache` is what `cache-verdict`
|
|
10
|
+
moves aside, restores, sizes, and stores.
|
|
11
|
+
- `package.json` provides the `npm run build` command that `init` detects.
|
|
12
|
+
|
|
13
|
+
## Run it from the repository root
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
npm run example
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
That copies this directory to a temporary directory, runs `init`, two `run`
|
|
20
|
+
samples, and `report` with the checkout's CLI, then checks that the verdict is
|
|
21
|
+
`cache-pays`. The temporary copy is removed afterwards, so nothing is written
|
|
22
|
+
into the repository.
|
|
23
|
+
|
|
24
|
+
## Run it by hand
|
|
25
|
+
|
|
26
|
+
From this directory:
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
node ../../bin/cache-verdict.mjs init
|
|
30
|
+
node ../../bin/cache-verdict.mjs run
|
|
31
|
+
node ../../bin/cache-verdict.mjs run
|
|
32
|
+
node ../../bin/cache-verdict.mjs report
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The first `run` is a cold miss and does the slow work. The second restores the
|
|
36
|
+
stored cache, and the report should say `cache-pays`. `.cache-verdict/` and
|
|
37
|
+
`dist/` are ignored by Git; delete them to start over.
|
|
38
|
+
|
|
39
|
+
Expected shape of the output (timings vary by machine):
|
|
40
|
+
|
|
41
|
+
```console
|
|
42
|
+
$ node ../../bin/cache-verdict.mjs run
|
|
43
|
+
...
|
|
44
|
+
cache-verdict: MISS (cold)
|
|
45
|
+
...
|
|
46
|
+
$ node ../../bin/cache-verdict.mjs run
|
|
47
|
+
...
|
|
48
|
+
cache-verdict: HIT (warm)
|
|
49
|
+
...
|
|
50
|
+
$ node ../../bin/cache-verdict.mjs report
|
|
51
|
+
Runs: 2 (1 hit, 1 miss)
|
|
52
|
+
...
|
|
53
|
+
Verdict: cache-pays
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
If the report says `cache-costs` or `cache-churns`, the cache is not paying for
|
|
57
|
+
itself for this key; that is the decision the tool exists to make. To see a
|
|
58
|
+
churn, edit `src/input.txt` between runs and `run` again: the key changes, so
|
|
59
|
+
the next build is cold.
|
|
60
|
+
|
|
61
|
+
## What `init` writes
|
|
62
|
+
|
|
63
|
+
`init` detects npm from `package.json` and writes this reviewable config:
|
|
64
|
+
|
|
65
|
+
```json
|
|
66
|
+
{
|
|
67
|
+
"schemaVersion": 1,
|
|
68
|
+
"ecosystem": "npm",
|
|
69
|
+
"displayName": "npm",
|
|
70
|
+
"command": "npm run build",
|
|
71
|
+
"inputs": [
|
|
72
|
+
"package.json",
|
|
73
|
+
"*.js",
|
|
74
|
+
"*.mjs",
|
|
75
|
+
"*.cjs",
|
|
76
|
+
"*.ts",
|
|
77
|
+
"*.tsx",
|
|
78
|
+
"*.jsx",
|
|
79
|
+
"src/**",
|
|
80
|
+
"lib/**",
|
|
81
|
+
"bin/**",
|
|
82
|
+
"scripts/**"
|
|
83
|
+
],
|
|
84
|
+
"cachePath": ["node_modules/.cache"],
|
|
85
|
+
"detectedFrom": ["package.json"],
|
|
86
|
+
"notes": [
|
|
87
|
+
"npm's download cache defaults to ~/.npm; set npm_config_cache=.npm in CI to keep it project-local and measurable."
|
|
88
|
+
]
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The note is about npm's package-download cache, which this example does not
|
|
93
|
+
measure. The measured cache is `node_modules/.cache`, written by `build.mjs`.
|
|
94
|
+
Edit `command`, `inputs`, or `cachePath` if the detection does not match the
|
|
95
|
+
build you care about, then sample again with a fresh directory.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// A deliberately simple build: derive an artifact from src/input.txt, keep it
|
|
2
|
+
// in node_modules/.cache, and spend 700 ms of "compile" time only on a cold
|
|
3
|
+
// cache. That gap is what cache-verdict measures.
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
6
|
+
|
|
7
|
+
const source = readFileSync('src/input.txt', 'utf8');
|
|
8
|
+
const digest = createHash('sha256').update(source).digest('hex').slice(0, 12);
|
|
9
|
+
const cached = `node_modules/.cache/${digest}.txt`;
|
|
10
|
+
|
|
11
|
+
if (existsSync(cached)) {
|
|
12
|
+
console.log(`warm build: reusing cached artifact ${digest}`);
|
|
13
|
+
} else {
|
|
14
|
+
console.log(`cold build: computing ${digest} (this is the slow part)`);
|
|
15
|
+
const until = Date.now() + 700;
|
|
16
|
+
while (Date.now() < until) {
|
|
17
|
+
// Stand-in for real compilation work.
|
|
18
|
+
}
|
|
19
|
+
mkdirSync('node_modules/.cache', { recursive: true });
|
|
20
|
+
writeFileSync(cached, `${digest}: ${source.trim().length} characters of input\n`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
mkdirSync('dist', { recursive: true });
|
|
24
|
+
writeFileSync('dist/result.txt', readFileSync(cached));
|
|
25
|
+
console.log('wrote dist/result.txt');
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cache-verdict-example-npm-build-cache",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "Runnable cache-verdict example: a slow cold build that is fast when the cache is restored",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "node build.mjs"
|
|
9
|
+
}
|
|
10
|
+
}
|
package/examples/run.mjs
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Run the npm-build-cache example against a temporary copy of the project, so
|
|
3
|
+
// trying it out never leaves .cache-verdict/, node_modules/.cache, or dist/ in
|
|
4
|
+
// the checkout. Uses only Node.js built-ins.
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import { cpSync, mkdtempSync, rmSync } from 'node:fs';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
|
|
11
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const cli = path.resolve(here, '..', 'bin', 'cache-verdict.mjs');
|
|
13
|
+
const source = path.join(here, 'npm-build-cache');
|
|
14
|
+
|
|
15
|
+
const temporary = mkdtempSync(path.join(tmpdir(), 'cache-verdict-example-'));
|
|
16
|
+
const project = path.join(temporary, 'npm-build-cache');
|
|
17
|
+
|
|
18
|
+
function run(args) {
|
|
19
|
+
console.log(`$ node ${path.relative(process.cwd(), cli) || cli} ${args.join(' ')}`);
|
|
20
|
+
const result = spawnSync(process.execPath, [cli, ...args], {
|
|
21
|
+
cwd: project,
|
|
22
|
+
stdio: 'inherit',
|
|
23
|
+
});
|
|
24
|
+
if (result.error) throw result.error;
|
|
25
|
+
if (result.status !== 0) {
|
|
26
|
+
throw new Error(`cache-verdict ${args.join(' ')} exited with status ${result.status}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
cpSync(source, project, { recursive: true });
|
|
32
|
+
|
|
33
|
+
// A manual run in the checkout may have left state behind; always start clean.
|
|
34
|
+
for (const leftover of ['node_modules', '.cache-verdict', 'dist']) {
|
|
35
|
+
rmSync(path.join(project, leftover), { recursive: true, force: true });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
console.log(`Running the example in a temporary copy: ${project}\n`);
|
|
39
|
+
run(['init']);
|
|
40
|
+
run(['run']);
|
|
41
|
+
run(['run']);
|
|
42
|
+
run(['report']);
|
|
43
|
+
|
|
44
|
+
// check prints the same report again, so only surface it when it disagrees.
|
|
45
|
+
const check = spawnSync(process.execPath, [cli, 'check'], {
|
|
46
|
+
cwd: project,
|
|
47
|
+
encoding: 'utf8',
|
|
48
|
+
});
|
|
49
|
+
if (check.error) throw check.error;
|
|
50
|
+
if (check.status !== 0) {
|
|
51
|
+
process.stderr.write(check.stdout ?? '');
|
|
52
|
+
process.stderr.write(check.stderr ?? '');
|
|
53
|
+
throw new Error(`cache-verdict check exited with status ${check.status}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
console.log('\ncheck exited 0: this example cache pays.');
|
|
57
|
+
} catch (error) {
|
|
58
|
+
process.exitCode = 1;
|
|
59
|
+
console.error(`\nexample failed: ${error.message}`);
|
|
60
|
+
} finally {
|
|
61
|
+
rmSync(temporary, { recursive: true, force: true });
|
|
62
|
+
console.log('Removed the temporary copy.');
|
|
63
|
+
}
|