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/lib/detect.mjs ADDED
@@ -0,0 +1,647 @@
1
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ /**
5
+ * Canonical ids are the ones written to .cache-verdict.json. The aliases exist
6
+ * because people reasonably type "next", "rust", or "node" at the prompt.
7
+ */
8
+ const ECOSYSTEM_ALIASES = new Map([
9
+ ['nextjs', 'nextjs'],
10
+ ['next', 'nextjs'],
11
+ ['next.js', 'nextjs'],
12
+ ['cargo', 'cargo'],
13
+ ['gradle', 'gradle'],
14
+ ['go', 'go'],
15
+ ['golang', 'go'],
16
+ ['python', 'python'],
17
+ ['py', 'python'],
18
+ ['pip', 'python'],
19
+ ['npm', 'npm'],
20
+ ['node', 'npm'],
21
+ ['nodejs', 'npm'],
22
+ ['node.js', 'npm'],
23
+ ]);
24
+
25
+ const LOCKFILES = [
26
+ 'package-lock.json',
27
+ 'npm-shrinkwrap.json',
28
+ 'pnpm-lock.yaml',
29
+ 'yarn.lock',
30
+ 'bun.lock',
31
+ 'bun.lockb',
32
+ ];
33
+
34
+ function statIsFile(target) {
35
+ try {
36
+ return statSync(target).isFile();
37
+ } catch {
38
+ return false;
39
+ }
40
+ }
41
+
42
+ function unique(values) {
43
+ return values.filter(
44
+ (value, index) =>
45
+ typeof value === 'string' && value.length > 0 && values.indexOf(value) === index,
46
+ );
47
+ }
48
+
49
+ function present(ctx, names) {
50
+ return names.filter((name) => ctx.exists(name));
51
+ }
52
+
53
+ function createContext(directory) {
54
+ let entries;
55
+ try {
56
+ entries = readdirSync(directory, { withFileTypes: true });
57
+ } catch (error) {
58
+ throw new Error(`cannot read directory ${directory}: ${error.message}`);
59
+ }
60
+
61
+ const byName = new Map(entries.map((entry) => [entry.name, entry]));
62
+ let packageState;
63
+
64
+ const ctx = {
65
+ directory,
66
+
67
+ has(name) {
68
+ return byName.has(name);
69
+ },
70
+
71
+ isFile(name) {
72
+ const entry = byName.get(name);
73
+ if (!entry) return false;
74
+ if (entry.isFile()) return true;
75
+ // Follow symlinks only when we can prove they point at a regular file.
76
+ if (entry.isSymbolicLink()) return statIsFile(path.join(directory, name));
77
+ return false;
78
+ },
79
+
80
+ isDirectory(name) {
81
+ const entry = byName.get(name);
82
+ return entry !== undefined && entry.isDirectory();
83
+ },
84
+
85
+ exists(relativePath) {
86
+ return statIsFile(path.join(directory, ...relativePath.split('/')));
87
+ },
88
+
89
+ list() {
90
+ return [...byName.keys()].sort();
91
+ },
92
+
93
+ packageJson() {
94
+ if (packageState !== undefined) return packageState.value;
95
+ packageState = { value: null, error: null };
96
+
97
+ if (!ctx.isFile('package.json')) return null;
98
+
99
+ try {
100
+ const text = readFileSync(path.join(directory, 'package.json'), 'utf8').replace(
101
+ /^\uFEFF/,
102
+ '',
103
+ );
104
+ const parsed = JSON.parse(text);
105
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
106
+ throw new Error('package.json must contain a JSON object');
107
+ }
108
+ packageState = { value: parsed, error: null };
109
+ } catch (error) {
110
+ packageState = { value: null, error };
111
+ }
112
+
113
+ return packageState.value;
114
+ },
115
+
116
+ packageJsonError() {
117
+ ctx.packageJson();
118
+ return packageState.error;
119
+ },
120
+ };
121
+
122
+ return ctx;
123
+ }
124
+
125
+ function packageManager(ctx) {
126
+ if (ctx.exists('bun.lock') || ctx.exists('bun.lockb')) return 'bun';
127
+ if (ctx.exists('pnpm-lock.yaml')) return 'pnpm';
128
+ if (ctx.exists('yarn.lock')) return 'yarn';
129
+ return 'npm';
130
+ }
131
+
132
+ function installCommand(ctx, manager) {
133
+ if (manager === 'npm') {
134
+ return ctx.exists('package-lock.json') || ctx.exists('npm-shrinkwrap.json')
135
+ ? 'npm ci'
136
+ : 'npm install';
137
+ }
138
+ if (manager === 'pnpm') {
139
+ return ctx.exists('pnpm-lock.yaml') ? 'pnpm install --frozen-lockfile' : 'pnpm install';
140
+ }
141
+ if (manager === 'yarn') {
142
+ return ctx.exists('yarn.lock') ? 'yarn install --frozen-lockfile' : 'yarn install';
143
+ }
144
+ return ctx.exists('bun.lock') || ctx.exists('bun.lockb')
145
+ ? 'bun install --frozen-lockfile'
146
+ : 'bun install';
147
+ }
148
+
149
+ function hasBuildScript(pkg) {
150
+ return typeof pkg?.scripts?.build === 'string' && pkg.scripts.build.trim().length > 0;
151
+ }
152
+
153
+ function nextBuildCommand(ctx, pkg) {
154
+ const manager = packageManager(ctx);
155
+ if (hasBuildScript(pkg)) {
156
+ return `${manager} run build`;
157
+ }
158
+
159
+ if (manager === 'pnpm') return 'pnpm exec next build';
160
+ if (manager === 'yarn') return 'yarn next build';
161
+ if (manager === 'bun') return 'bunx next build';
162
+ return 'npx next build';
163
+ }
164
+
165
+ /**
166
+ * Find Gradle subprojects below the root. Multi-module builds keep each
167
+ * module's output in its own build/ directory, so a single top-level
168
+ * "build" path would miss most of the artifacts being measured.
169
+ */
170
+ function gradleSubprojectDirs(root, maxDepth = 3) {
171
+ const skip = new Set([
172
+ 'node_modules',
173
+ '.git',
174
+ '.gradle',
175
+ '.idea',
176
+ '.vscode',
177
+ 'build',
178
+ 'out',
179
+ 'target',
180
+ ]);
181
+ const found = new Set();
182
+
183
+ function walk(relative, depth) {
184
+ let entries;
185
+ try {
186
+ entries = readdirSync(path.join(root, ...relative.split('/').filter(Boolean)), {
187
+ withFileTypes: true,
188
+ });
189
+ } catch {
190
+ return;
191
+ }
192
+
193
+ if (relative !== '') {
194
+ const names = new Set(entries.map((entry) => entry.name));
195
+ if (names.has('build.gradle') || names.has('build.gradle.kts')) {
196
+ found.add(relative);
197
+ }
198
+ }
199
+
200
+ if (depth >= maxDepth) return;
201
+
202
+ for (const entry of entries) {
203
+ if (!entry.isDirectory()) continue;
204
+ if (entry.name.startsWith('.') || skip.has(entry.name)) continue;
205
+ const child = relative === '' ? entry.name : `${relative}/${entry.name}`;
206
+ walk(child, depth + 1);
207
+ }
208
+ }
209
+
210
+ walk('', 0);
211
+ return [...found].sort();
212
+ }
213
+
214
+ const PACKAGE_MANAGER_NOTES = {
215
+ npm: "npm's download cache defaults to ~/.npm; set npm_config_cache=.npm in CI to keep it project-local and measurable.",
216
+ pnpm: 'pnpm keeps its content-addressable store outside node_modules; set the store directory inside .pnpm-store if restores should be measured.',
217
+ yarn: 'Yarn keeps its cache under .yarn/cache when global caching is disabled; configure that to make restores measurable.',
218
+ bun: 'Bun caches packages globally by default; point its cache inside the project if restores should be measured.',
219
+ };
220
+
221
+ const ECOSYSTEMS = [
222
+ {
223
+ id: 'nextjs',
224
+ displayName: 'Next.js',
225
+ markers: ['package.json with a next dependency or build script', 'next.config.js'],
226
+ detect(ctx) {
227
+ const pkg = ctx.packageJson();
228
+ const configFiles = ['next.config.js', 'next.config.mjs', 'next.config.cjs', 'next.config.ts'].filter(
229
+ (name) => ctx.isFile(name),
230
+ );
231
+
232
+ const dependencyFields = [
233
+ 'dependencies',
234
+ 'devDependencies',
235
+ 'peerDependencies',
236
+ 'optionalDependencies',
237
+ ];
238
+ const hasNextDependency = dependencyFields.some(
239
+ (field) =>
240
+ pkg !== null &&
241
+ pkg[field] !== null &&
242
+ typeof pkg[field] === 'object' &&
243
+ Object.prototype.hasOwnProperty.call(pkg[field], 'next'),
244
+ );
245
+
246
+ const scripts = pkg?.scripts ?? {};
247
+ const usesNextScript =
248
+ (typeof scripts.build === 'string' && /\bnext\s+build\b/.test(scripts.build)) ||
249
+ (typeof scripts.dev === 'string' && /\bnext\s+dev\b/.test(scripts.dev));
250
+
251
+ if (!hasNextDependency && configFiles.length === 0 && !usesNextScript) return null;
252
+
253
+ const manager = packageManager(ctx);
254
+ const notes = [
255
+ 'Next.js stores build output in .next/cache; this path is measured.',
256
+ ];
257
+ if (manager !== 'npm') {
258
+ notes.push(`Detected a ${manager} lockfile; the command uses ${manager}.`);
259
+ }
260
+ if (!hasBuildScript(pkg)) {
261
+ notes.push(
262
+ 'No usable "build" script was found; the command invokes Next.js directly. Edit it if the project builds differently.',
263
+ );
264
+ }
265
+ const packageError = ctx.packageJsonError();
266
+ if (packageError) {
267
+ notes.push(`Could not parse package.json (${packageError.message}); review command and inputs.`);
268
+ }
269
+
270
+ const detectedFrom = unique([
271
+ ...(ctx.isFile('package.json') ? ['package.json'] : []),
272
+ ...configFiles,
273
+ ]);
274
+
275
+ return {
276
+ command: nextBuildCommand(ctx, pkg),
277
+ inputs: unique([
278
+ ...present(ctx, [
279
+ 'package.json',
280
+ ...LOCKFILES,
281
+ ...configFiles,
282
+ 'tsconfig.json',
283
+ 'jsconfig.json',
284
+ 'middleware.ts',
285
+ 'middleware.js',
286
+ 'postcss.config.js',
287
+ 'postcss.config.mjs',
288
+ 'tailwind.config.js',
289
+ 'tailwind.config.ts',
290
+ 'babel.config.js',
291
+ '.babelrc',
292
+ ]),
293
+ 'app/**',
294
+ 'pages/**',
295
+ 'src/**',
296
+ 'public/**',
297
+ ]),
298
+ cachePath: ['.next/cache'],
299
+ detectedFrom,
300
+ notes,
301
+ };
302
+ },
303
+ },
304
+ {
305
+ id: 'cargo',
306
+ displayName: 'Cargo',
307
+ markers: ['Cargo.toml'],
308
+ detect(ctx) {
309
+ if (!ctx.isFile('Cargo.toml')) return null;
310
+
311
+ return {
312
+ command: 'cargo build',
313
+ inputs: unique([
314
+ ...present(ctx, [
315
+ 'Cargo.toml',
316
+ 'Cargo.lock',
317
+ 'rust-toolchain',
318
+ 'rust-toolchain.toml',
319
+ 'build.rs',
320
+ '.cargo/config.toml',
321
+ '.cargo/config',
322
+ ]),
323
+ 'src/**',
324
+ 'tests/**',
325
+ 'benches/**',
326
+ 'examples/**',
327
+ ]),
328
+ cachePath: ['target'],
329
+ detectedFrom: ['Cargo.toml'],
330
+ notes: [
331
+ 'Cargo writes build artifacts to target/. Keep CARGO_TARGET_DIR unset so this path is measured.',
332
+ ],
333
+ };
334
+ },
335
+ },
336
+ {
337
+ id: 'gradle',
338
+ displayName: 'Gradle',
339
+ markers: ['build.gradle', 'build.gradle.kts', 'settings.gradle', 'settings.gradle.kts'],
340
+ detect(ctx) {
341
+ const buildFiles = [
342
+ 'build.gradle',
343
+ 'build.gradle.kts',
344
+ 'settings.gradle',
345
+ 'settings.gradle.kts',
346
+ ].filter((name) => ctx.isFile(name));
347
+ if (buildFiles.length === 0) return null;
348
+
349
+ const hasWrapper = ctx.exists('gradlew') || ctx.exists('gradlew.bat');
350
+ const command = hasWrapper
351
+ ? process.platform === 'win32'
352
+ ? 'gradlew.bat build'
353
+ : './gradlew build'
354
+ : 'gradle build';
355
+
356
+ const notes = [
357
+ 'Gradle keeps dependency caches in GRADLE_USER_HOME (~/.gradle by default); set it inside the project if those restores should be measured.',
358
+ ];
359
+ if (!hasWrapper) {
360
+ notes.push('No Gradle wrapper was found; ensure "gradle" is on PATH before sampling.');
361
+ }
362
+
363
+ const subprojects = gradleSubprojectDirs(ctx.directory);
364
+ if (subprojects.length > 0) {
365
+ notes.push(
366
+ `Measured subproject build directories: ${subprojects.map((dir) => `${dir}/build`).join(', ')}.`,
367
+ );
368
+ }
369
+
370
+ return {
371
+ command,
372
+ inputs: unique([
373
+ ...present(ctx, [
374
+ ...buildFiles,
375
+ 'gradle.properties',
376
+ 'gradle.lockfile',
377
+ 'gradle/libs.versions.toml',
378
+ 'gradle/wrapper/gradle-wrapper.properties',
379
+ 'gradlew',
380
+ 'gradlew.bat',
381
+ ]),
382
+ 'buildSrc/**/*',
383
+ 'gradle/**',
384
+ 'src/**/*.java',
385
+ 'src/**/*.kt',
386
+ 'src/**/*.kts',
387
+ 'src/**/*.groovy',
388
+ ...subprojects.map((dir) => `${dir}/**`),
389
+ ]),
390
+ cachePath: unique([
391
+ '.gradle',
392
+ 'build',
393
+ ...subprojects.map((dir) => `${dir}/build`),
394
+ ]),
395
+ detectedFrom: buildFiles,
396
+ notes,
397
+ };
398
+ },
399
+ },
400
+ {
401
+ id: 'go',
402
+ displayName: 'Go',
403
+ markers: ['go.mod', 'go.work'],
404
+ detect(ctx) {
405
+ const detectedFrom = present(ctx, ['go.mod', 'go.work']);
406
+ if (detectedFrom.length === 0) return null;
407
+
408
+ return {
409
+ command: 'go build ./...',
410
+ inputs: unique([
411
+ ...present(ctx, ['go.mod', 'go.sum', 'go.work', 'go.work.sum', 'vendor/modules.txt']),
412
+ '*.go',
413
+ 'cmd/**',
414
+ 'internal/**',
415
+ 'pkg/**',
416
+ '**/*.go',
417
+ ]),
418
+ cachePath: ['$GOCACHE'],
419
+ detectedFrom,
420
+ notes: [
421
+ 'Go stores build artifacts in $GOCACHE, which defaults outside the project. Set $GOCACHE to an absolute path inside .cache/go-build before sampling.',
422
+ ],
423
+ };
424
+ },
425
+ },
426
+ {
427
+ id: 'python',
428
+ displayName: 'Python',
429
+ markers: ['pyproject.toml', 'requirements.txt', 'setup.py', 'setup.cfg', 'Pipfile', 'manage.py'],
430
+ detect(ctx) {
431
+ const markerFiles = [
432
+ 'pyproject.toml',
433
+ 'requirements.txt',
434
+ 'setup.py',
435
+ 'setup.cfg',
436
+ 'Pipfile',
437
+ 'manage.py',
438
+ ];
439
+ const requirementFiles = ctx
440
+ .list()
441
+ .filter((name) => /^requirements.*\.txt$/.test(name) && ctx.isFile(name));
442
+ const detectedFrom = unique([...present(ctx, markerFiles), ...requirementFiles]);
443
+ if (detectedFrom.length === 0) return null;
444
+
445
+ let command;
446
+ const notes = [];
447
+ if (ctx.exists('pyproject.toml') || ctx.exists('setup.py') || ctx.exists('setup.cfg')) {
448
+ command = 'python -m build';
449
+ notes.push(
450
+ 'The "build" package is required for python -m build (pip install build); edit the command if the project uses Poetry, PDM, uv, or Hatch.',
451
+ );
452
+ } else if (ctx.exists('Pipfile')) {
453
+ command = 'pipenv install --dev';
454
+ notes.push(
455
+ 'Pipenv creates a project-local virtualenv; review .venv below if it uses a different location.',
456
+ );
457
+ } else if (requirementFiles.length > 0) {
458
+ command = 'python -m compileall -q .';
459
+ notes.push(
460
+ 'No packaging metadata was found; compile sources with compileall so samples are comparable without installing dependencies. Use --command to time the real build instead.',
461
+ );
462
+ } else {
463
+ command = 'python -m pip install .';
464
+ notes.push(
465
+ 'Only manage.py was found; review the command and add the dependency manifest for this project.',
466
+ );
467
+ }
468
+
469
+ return {
470
+ command,
471
+ inputs: unique([
472
+ ...present(ctx, [
473
+ ...markerFiles,
474
+ 'Pipfile.lock',
475
+ 'poetry.lock',
476
+ 'pdm.lock',
477
+ 'uv.lock',
478
+ 'requirements-dev.txt',
479
+ 'requirements-test.txt',
480
+ 'tox.ini',
481
+ ]),
482
+ 'requirements*.txt',
483
+ '*.py',
484
+ 'src/**',
485
+ 'app/**',
486
+ 'tests/**',
487
+ ]),
488
+ cachePath: [
489
+ '.venv',
490
+ 'build',
491
+ 'dist',
492
+ '.pytest_cache',
493
+ '.mypy_cache',
494
+ '.ruff_cache',
495
+ '**/__pycache__',
496
+ ],
497
+ detectedFrom,
498
+ notes,
499
+ };
500
+ },
501
+ },
502
+ {
503
+ id: 'npm',
504
+ displayName: 'npm',
505
+ markers: ['package.json'],
506
+ detect(ctx) {
507
+ if (!ctx.isFile('package.json')) return null;
508
+
509
+ const manager = packageManager(ctx);
510
+ const pkg = ctx.packageJson();
511
+ const notes = [PACKAGE_MANAGER_NOTES[manager]];
512
+ const packageError = ctx.packageJsonError();
513
+ if (packageError) {
514
+ notes.push(`Could not parse package.json (${packageError.message}); review command and inputs.`);
515
+ }
516
+ if (!hasBuildScript(pkg)) {
517
+ notes.push(
518
+ 'No usable "build" script was found; the command installs dependencies instead. Use --command to time the real build.',
519
+ );
520
+ }
521
+
522
+ return {
523
+ command: hasBuildScript(pkg) ? `${manager} run build` : installCommand(ctx, manager),
524
+ inputs: unique([
525
+ ...present(ctx, [
526
+ 'package.json',
527
+ ...LOCKFILES,
528
+ '.npmrc',
529
+ '.yarnrc.yml',
530
+ 'pnpm-workspace.yaml',
531
+ 'tsconfig.json',
532
+ 'jsconfig.json',
533
+ 'vite.config.js',
534
+ 'vite.config.mjs',
535
+ 'vite.config.ts',
536
+ 'webpack.config.js',
537
+ 'webpack.config.mjs',
538
+ 'webpack.config.ts',
539
+ 'rollup.config.js',
540
+ 'rollup.config.mjs',
541
+ 'rollup.config.ts',
542
+ ]),
543
+ '*.js',
544
+ '*.mjs',
545
+ '*.cjs',
546
+ '*.ts',
547
+ '*.tsx',
548
+ '*.jsx',
549
+ 'src/**',
550
+ 'lib/**',
551
+ 'bin/**',
552
+ 'scripts/**',
553
+ ]),
554
+ cachePath: ['node_modules/.cache'],
555
+ detectedFrom: ['package.json'],
556
+ notes,
557
+ };
558
+ },
559
+ },
560
+ ];
561
+
562
+ export function supportedEcosystemIds() {
563
+ return ECOSYSTEMS.map((ecosystem) => ecosystem.id);
564
+ }
565
+
566
+ export function normalizeEcosystem(value) {
567
+ if (typeof value !== 'string') return null;
568
+ return ECOSYSTEM_ALIASES.get(value.trim().toLowerCase()) ?? null;
569
+ }
570
+
571
+ export function isSupportedEcosystem(value) {
572
+ return normalizeEcosystem(value) !== null;
573
+ }
574
+
575
+ /**
576
+ * Detect the build systems present in a directory.
577
+ *
578
+ * @param {string} directory absolute or relative directory to inspect
579
+ * @param {{ ecosystem?: string | null }} [options] force one detector by id/alias
580
+ * @returns {{
581
+ * ecosystem: string,
582
+ * displayName: string,
583
+ * command: string,
584
+ * inputs: string[],
585
+ * cachePath: string[],
586
+ * detectedFrom: string[],
587
+ * notes: string[],
588
+ * alternatives: Array<{ ecosystem: string, displayName: string, command: string, detectedFrom: string[] }>,
589
+ * }}
590
+ */
591
+ export function detectProject(directory, options = {}) {
592
+ const ctx = createContext(directory);
593
+ const matches = [];
594
+
595
+ for (const ecosystem of ECOSYSTEMS) {
596
+ const details = ecosystem.detect(ctx);
597
+ if (details !== null) {
598
+ matches.push({
599
+ ecosystem: ecosystem.id,
600
+ displayName: ecosystem.displayName,
601
+ command: details.command,
602
+ inputs: details.inputs,
603
+ cachePath: details.cachePath,
604
+ detectedFrom: details.detectedFrom,
605
+ notes: details.notes,
606
+ });
607
+ }
608
+ }
609
+
610
+ let selected;
611
+ const requested = options.ecosystem;
612
+
613
+ if (requested !== undefined && requested !== null) {
614
+ const ecosystemId = normalizeEcosystem(requested);
615
+ if (ecosystemId === null) {
616
+ throw new Error(
617
+ `unsupported ecosystem "${requested}"; choose one of: ${supportedEcosystemIds().join(', ')}`,
618
+ );
619
+ }
620
+
621
+ selected = matches.find((match) => match.ecosystem === ecosystemId);
622
+ if (selected === undefined) {
623
+ const definition = ECOSYSTEMS.find((ecosystem) => ecosystem.id === ecosystemId);
624
+ throw new Error(
625
+ `no ${definition.displayName} project detected in ${directory} (looked for ${definition.markers.join(', ')})`,
626
+ );
627
+ }
628
+ } else {
629
+ selected = matches[0];
630
+ if (selected === undefined) {
631
+ throw new Error(
632
+ `no supported build system found in ${directory}; looked for Cargo, Gradle, Next.js, npm, Python, and Go manifests`,
633
+ );
634
+ }
635
+ }
636
+
637
+ const alternatives = matches
638
+ .filter((match) => match !== selected)
639
+ .map((match) => ({
640
+ ecosystem: match.ecosystem,
641
+ displayName: match.displayName,
642
+ command: match.command,
643
+ detectedFrom: match.detectedFrom,
644
+ }));
645
+
646
+ return { ...selected, alternatives };
647
+ }