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.
@@ -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
+ }
@@ -0,0 +1,5 @@
1
+ cache-verdict example input
2
+
3
+ This file is an input to the cache key. Editing it produces a new key,
4
+ which makes the next build cold again instead of restoring an artifact
5
+ for different content.
@@ -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
+ }