@gjsify/cli 0.14.0 → 0.16.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,670 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Content-hash per-package build cache (ADR 0006 phase 1).
3
+ //
4
+ // `gjsify workspace <name> <script> --cached` / `gjsify foreach <script>
5
+ // --cached` skip re-running a build-ish script for a workspace package whose
6
+ // inputs are unchanged, restoring the previously stored outputs instead.
7
+ //
8
+ // Cache key per package = sha256 over:
9
+ // - a layout version (bump BUILD_CACHE_LAYOUT_VERSION to invalidate all),
10
+ // - the script name + forwarded args,
11
+ // - a toolchain salt (workspace-resolved versions of @gjsify/cli,
12
+ // @gjsify/tsc, rolldown, typescript — a bundler/compiler bump must
13
+ // invalidate everything),
14
+ // - the package's own input hash (every file under `src/**`, plus
15
+ // `package.json`, plus the package's root `tsconfig*.json` — real file
16
+ // contents, streamed sha256, sorted for determinism),
17
+ // - the input hashes of EVERY workspace dependency in the package's
18
+ // transitive dep closure (prod + dev + optional + peer `workspace:`
19
+ // edges). Composing the closure's OWN hashes (instead of recursing into
20
+ // dep KEYS) gives identical invalidation semantics — any change in any
21
+ // transitive dep changes this package's key — while staying well-defined
22
+ // on the cyclic dev-dependency graphs this monorepo actually has.
23
+ //
24
+ // Storage layout (per workspace root):
25
+ // node_modules/.cache/gjsify/build/<pkg-name-sanitized>/<key>/
26
+ // manifest.json — { package, script, key, dirs, outputsHash, … }
27
+ // outputs/<dir>/… — copies of the output dirs the build MODIFIED
28
+ //
29
+ // Output contract: after a successful (miss) run, the candidate output dirs
30
+ // (`lib/`, `dist/`, `dist-templates/`) are compared against a pre-run
31
+ // snapshot and ONLY the dirs the script actually created/modified are
32
+ // stored. On a later hit, EXACTLY those dirs are replaced (delete-then-copy
33
+ // within the package). This is the safety rule from the v0.x CI incident
34
+ // where a raw YAML cache restored over a package whose committed `lib/` is
35
+ // tracked source: a dir the build never touches is never recorded, so it can
36
+ // never be restored over. Packages that don't define the script at all are
37
+ // filtered out by the calling command and never reach the cache.
38
+ //
39
+ // Growth is bounded: at most MAX_KEYS_PER_PACKAGE entries per package,
40
+ // oldest (by last store/use) evicted.
41
+ //
42
+ // Every cache operation is fail-soft: an unreadable cache entry, a full disk
43
+ // or a permission error degrade to "run the script uncached" — a cache
44
+ // hiccup must never break a build.
45
+ import { createHash } from 'node:crypto';
46
+ import { chmodSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, readlinkSync, readSync, renameSync, rmSync, symlinkSync, copyFileSync, writeFileSync, } from 'node:fs';
47
+ import { dirname, join } from 'node:path';
48
+ import { fileURLToPath } from 'node:url';
49
+ import { buildDependencyGraph } from '@gjsify/workspace';
50
+ /** Bump to invalidate every existing cache entry (schema/semantic changes). */
51
+ export const BUILD_CACHE_LAYOUT_VERSION = 'v1';
52
+ /** Conventional per-package build output directories, checked in this order. */
53
+ export const OUTPUT_DIR_CANDIDATES = ['lib', 'dist', 'dist-templates'];
54
+ /** Keep at most this many keys per package; evict the least recently stored/used. */
55
+ export const MAX_KEYS_PER_PACKAGE = 2;
56
+ /** Toolchain packages whose resolved versions salt every cache key. */
57
+ export const TOOLCHAIN_PACKAGES = ['@gjsify/cli', '@gjsify/tsc', 'rolldown', 'typescript'];
58
+ /** `true` when GJSIFY_BUILD_CACHE is set to a non-empty, non-"0"/"false" value. */
59
+ export function buildCacheEnabledByEnv(env = process.env) {
60
+ const v = env['GJSIFY_BUILD_CACHE'];
61
+ if (v === undefined)
62
+ return false;
63
+ return v !== '' && v !== '0' && v !== 'false';
64
+ }
65
+ /** Cache root for a workspace: `<root>/node_modules/.cache/gjsify/build`. */
66
+ export function buildCacheRoot(workspaceRoot) {
67
+ return join(workspaceRoot, 'node_modules', '.cache', 'gjsify', 'build');
68
+ }
69
+ /**
70
+ * Directory-safe package name. Scoped names collapse (`@gjsify/cli` →
71
+ * `gjsify-cli`); an 8-hex sha256 suffix of the exact name rules out
72
+ * collisions between e.g. `@a/b-c` and `@a-b/c`.
73
+ */
74
+ export function sanitizePackageDirName(name) {
75
+ const cleaned = name.replace(/^@/, '').replace(/[^A-Za-z0-9._-]/g, '-');
76
+ const suffix = createHash('sha256').update(name).digest('hex').slice(0, 8);
77
+ return `${cleaned}-${suffix}`;
78
+ }
79
+ /** Streamed sha256 of one file (bounded memory, real contents). */
80
+ export function hashFileStream(path) {
81
+ const hash = createHash('sha256');
82
+ const fd = openSync(path, 'r');
83
+ try {
84
+ const buf = Buffer.alloc(64 * 1024);
85
+ for (;;) {
86
+ const n = readSync(fd, buf, 0, buf.length, null);
87
+ if (n <= 0)
88
+ break;
89
+ hash.update(buf.subarray(0, n));
90
+ }
91
+ }
92
+ finally {
93
+ closeSync(fd);
94
+ }
95
+ return hash.digest('hex');
96
+ }
97
+ // Recursive, sorted file walk. Yields workspace-relative paths (forward
98
+ // slashes). Directory symlinks are NOT followed (loop safety) — the link
99
+ // target string participates in the hash instead. `node_modules` and the
100
+ // output dirs never appear because callers only walk `src/` + explicit root
101
+ // files, but guard anyway for nested oddities.
102
+ function walkFiles(dir, prefix, out) {
103
+ let entries;
104
+ try {
105
+ entries = readdirSync(dir);
106
+ }
107
+ catch {
108
+ return;
109
+ }
110
+ entries.sort();
111
+ for (const entry of entries) {
112
+ if (entry === 'node_modules')
113
+ continue;
114
+ const abs = join(dir, entry);
115
+ const rel = prefix === '' ? entry : `${prefix}/${entry}`;
116
+ let st;
117
+ try {
118
+ st = lstatSync(abs);
119
+ }
120
+ catch {
121
+ continue;
122
+ }
123
+ if (st.isSymbolicLink()) {
124
+ // File symlinks: hash the resolved content below if readable;
125
+ // dir symlinks: record the target only.
126
+ try {
127
+ const target = readlinkSync(abs);
128
+ out.push({ rel, abs, link: target });
129
+ }
130
+ catch {
131
+ /* dropped mid-walk */
132
+ }
133
+ }
134
+ else if (st.isDirectory()) {
135
+ walkFiles(abs, rel, out);
136
+ }
137
+ else if (st.isFile()) {
138
+ out.push({ rel, abs });
139
+ }
140
+ }
141
+ }
142
+ /**
143
+ * Content hash of a package's build INPUTS: every file under `src/**`, plus
144
+ * `package.json`, plus root `tsconfig*.json`. Sorted, content-streamed —
145
+ * mtimes do not participate, so a `git checkout` with identical contents
146
+ * hashes identically across machines.
147
+ */
148
+ export function hashPackageInputs(location) {
149
+ const files = [];
150
+ const pkgJson = join(location, 'package.json');
151
+ if (existsSync(pkgJson))
152
+ files.push({ rel: 'package.json', abs: pkgJson });
153
+ let rootEntries = [];
154
+ try {
155
+ rootEntries = readdirSync(location);
156
+ }
157
+ catch {
158
+ /* unreadable package dir — hash degenerates to the empty set */
159
+ }
160
+ for (const entry of rootEntries.sort()) {
161
+ if (/^tsconfig.*\.json$/.test(entry)) {
162
+ files.push({ rel: entry, abs: join(location, entry) });
163
+ }
164
+ }
165
+ const srcDir = join(location, 'src');
166
+ if (existsSync(srcDir))
167
+ walkFiles(srcDir, 'src', files);
168
+ const hash = createHash('sha256');
169
+ hash.update(`gjsify-build-cache-inputs/${BUILD_CACHE_LAYOUT_VERSION}\n`);
170
+ for (const f of files) {
171
+ if (f.link !== undefined) {
172
+ // Symlink: try content (file links), fall back to the target path.
173
+ let content;
174
+ try {
175
+ content = hashFileStream(f.abs);
176
+ }
177
+ catch {
178
+ content = `link:${f.link}`;
179
+ }
180
+ hash.update(`${f.rel} ${content}\n`);
181
+ continue;
182
+ }
183
+ try {
184
+ hash.update(`${f.rel} ${hashFileStream(f.abs)}\n`);
185
+ }
186
+ catch {
187
+ // Vanished mid-hash (concurrent edit) — record the absence so the
188
+ // key still changes vs. the file being present.
189
+ hash.update(`${f.rel} unreadable\n`);
190
+ }
191
+ }
192
+ return hash.digest('hex');
193
+ }
194
+ /**
195
+ * Mtime-independent content hash of a set of output dirs — used to skip the
196
+ * restore copy when the on-disk outputs already match the cached entry, and
197
+ * as an integrity stamp of what was stored.
198
+ */
199
+ export function hashOutputDirs(location, dirs) {
200
+ const hash = createHash('sha256');
201
+ hash.update(`gjsify-build-cache-outputs/${BUILD_CACHE_LAYOUT_VERSION}\n`);
202
+ for (const dir of [...dirs].sort()) {
203
+ const abs = join(location, dir);
204
+ if (!existsSync(abs)) {
205
+ hash.update(`${dir} absent\n`);
206
+ continue;
207
+ }
208
+ const files = [];
209
+ walkFiles(abs, dir, files);
210
+ for (const f of files) {
211
+ if (f.link !== undefined) {
212
+ hash.update(`${f.rel} link:${f.link}\n`);
213
+ continue;
214
+ }
215
+ try {
216
+ hash.update(`${f.rel} ${hashFileStream(f.abs)}\n`);
217
+ }
218
+ catch {
219
+ hash.update(`${f.rel} unreadable\n`);
220
+ }
221
+ }
222
+ }
223
+ return hash.digest('hex');
224
+ }
225
+ /**
226
+ * Cheap per-dir change signature (existence + file list + sizes + mtimes).
227
+ * Taken BEFORE running the script and compared AFTER, to detect which output
228
+ * dirs the build actually modified. Content hashing is deliberately avoided
229
+ * here — a build that rewrites identical bytes still bumps mtimes, and that
230
+ * is exactly the "this dir belongs to the build" signal we want.
231
+ */
232
+ export function snapshotOutputDirs(location) {
233
+ const snapshot = {};
234
+ for (const dir of OUTPUT_DIR_CANDIDATES) {
235
+ const abs = join(location, dir);
236
+ if (!existsSync(abs)) {
237
+ snapshot[dir] = null;
238
+ continue;
239
+ }
240
+ const files = [];
241
+ walkFiles(abs, dir, files);
242
+ const hash = createHash('sha256');
243
+ for (const f of files) {
244
+ let stamp = 'link';
245
+ if (f.link === undefined) {
246
+ try {
247
+ const st = lstatSync(f.abs);
248
+ stamp = `${st.size}:${st.mtimeMs}`;
249
+ }
250
+ catch {
251
+ stamp = 'gone';
252
+ }
253
+ }
254
+ hash.update(`${f.rel} ${stamp}\n`);
255
+ }
256
+ snapshot[dir] = hash.digest('hex');
257
+ }
258
+ return snapshot;
259
+ }
260
+ /** Output dirs whose signature changed (or that newly appeared) since `before`. */
261
+ export function modifiedOutputDirs(location, before) {
262
+ const after = snapshotOutputDirs(location);
263
+ const modified = [];
264
+ for (const dir of OUTPUT_DIR_CANDIDATES) {
265
+ // Newly created, deleted, or changed — all count as "the build owns
266
+ // this dir". A dir that is now ABSENT cannot be stored, though.
267
+ if (after[dir] !== before[dir] && after[dir] !== null)
268
+ modified.push(dir);
269
+ }
270
+ return modified;
271
+ }
272
+ /** Deterministic cache key (32 hex chars) from all invalidation ingredients. */
273
+ export function composeCacheKey(input) {
274
+ const hash = createHash('sha256');
275
+ hash.update(`gjsify-build-cache/${BUILD_CACHE_LAYOUT_VERSION}\n`);
276
+ hash.update(`script:${input.script}\n`);
277
+ for (const arg of input.args ?? [])
278
+ hash.update(`arg:${arg}\n`);
279
+ for (const name of Object.keys(input.toolchain).sort()) {
280
+ hash.update(`tool:${name}@${input.toolchain[name]}\n`);
281
+ }
282
+ hash.update(`self:${input.ownHash}\n`);
283
+ const deps = input.depHashes instanceof Map ? input.depHashes : new Map(Object.entries(input.depHashes));
284
+ for (const name of [...deps.keys()].sort()) {
285
+ hash.update(`dep:${name} ${deps.get(name)}\n`);
286
+ }
287
+ return hash.digest('hex').slice(0, 32);
288
+ }
289
+ /**
290
+ * Resolve the toolchain versions that salt every key from the workspace's
291
+ * OWN resolution (`<root>/node_modules/<pkg>/package.json`). In the gjsify
292
+ * monorepo these are symlinks into the workspace itself, so a CLI/tsc bump
293
+ * invalidates immediately. A package that isn't resolvable maps to 'none'
294
+ * (deterministic). `@gjsify/cli` additionally falls back to the RUNNING
295
+ * CLI's own version so globally-installed CLIs still salt correctly.
296
+ */
297
+ export function resolveToolchainVersions(workspaceRoot) {
298
+ const toolchain = {};
299
+ for (const name of TOOLCHAIN_PACKAGES) {
300
+ let version = 'none';
301
+ try {
302
+ const raw = readFileSync(join(workspaceRoot, 'node_modules', ...name.split('/'), 'package.json'), 'utf-8');
303
+ const parsed = JSON.parse(raw);
304
+ if (typeof parsed.version === 'string')
305
+ version = parsed.version;
306
+ }
307
+ catch {
308
+ /* not resolvable in this workspace */
309
+ }
310
+ if (name === '@gjsify/cli' && version === 'none') {
311
+ version = readOwnCliVersion() ?? 'none';
312
+ }
313
+ toolchain[name] = version;
314
+ }
315
+ return toolchain;
316
+ }
317
+ // Walk up from this module (lib/utils/… in the package build, dist/… in the
318
+ // GJS bundle) to the CLI's own package.json. Mirrors cli-app's
319
+ // readBundleVersion but tolerates both directory depths.
320
+ function readOwnCliVersion() {
321
+ try {
322
+ let dir = dirname(fileURLToPath(import.meta.url));
323
+ for (let i = 0; i < 4; i++) {
324
+ const candidate = join(dir, 'package.json');
325
+ if (existsSync(candidate)) {
326
+ const pkg = JSON.parse(readFileSync(candidate, 'utf-8'));
327
+ if (pkg.name === '@gjsify/cli' && typeof pkg.version === 'string')
328
+ return pkg.version;
329
+ }
330
+ dir = dirname(dir);
331
+ }
332
+ }
333
+ catch {
334
+ /* fall through */
335
+ }
336
+ return null;
337
+ }
338
+ // Recursive copy preserving file modes (the CLI's own `lib/index.js` carries
339
+ // an exec bit) and symlinks. Primitive fs ops only, so it behaves the same
340
+ // under Node and the GJS facade.
341
+ function copyDirRecursive(from, to) {
342
+ mkdirSync(to, { recursive: true });
343
+ for (const entry of readdirSync(from).sort()) {
344
+ const src = join(from, entry);
345
+ const dst = join(to, entry);
346
+ const st = lstatSync(src);
347
+ if (st.isSymbolicLink()) {
348
+ symlinkSync(readlinkSync(src), dst);
349
+ }
350
+ else if (st.isDirectory()) {
351
+ copyDirRecursive(src, dst);
352
+ }
353
+ else if (st.isFile()) {
354
+ copyFileSync(src, dst);
355
+ try {
356
+ chmodSync(dst, st.mode & 0o777);
357
+ }
358
+ catch {
359
+ /* mode best-effort */
360
+ }
361
+ }
362
+ }
363
+ }
364
+ /** Directory of one cache entry: `<cacheRoot>/<pkg-sanitized>/<key>`. */
365
+ export function buildCacheEntryDir(cacheRoot, pkgName, key) {
366
+ return join(cacheRoot, sanitizePackageDirName(pkgName), key);
367
+ }
368
+ const entryDir = buildCacheEntryDir;
369
+ /** Read + validate a cache entry's manifest; any mismatch is a MISS (null). */
370
+ export function readCacheEntry(cacheRoot, pkgName, key) {
371
+ const manifestPath = join(entryDir(cacheRoot, pkgName, key), 'manifest.json');
372
+ try {
373
+ const parsed = JSON.parse(readFileSync(manifestPath, 'utf-8'));
374
+ if (parsed.version !== 1)
375
+ return null;
376
+ if (parsed.layout !== BUILD_CACHE_LAYOUT_VERSION)
377
+ return null;
378
+ if (parsed.package !== pkgName || parsed.key !== key)
379
+ return null;
380
+ if (!Array.isArray(parsed.dirs))
381
+ return null;
382
+ // Every stored dir must actually exist in the entry — a half-written
383
+ // entry (crash mid-store before the atomic rename could never produce
384
+ // this, but a manually truncated one could) is a MISS.
385
+ for (const dir of parsed.dirs) {
386
+ if (!existsSync(join(entryDir(cacheRoot, pkgName, key), 'outputs', dir)))
387
+ return null;
388
+ }
389
+ return parsed;
390
+ }
391
+ catch {
392
+ return null;
393
+ }
394
+ }
395
+ /**
396
+ * Store the given output dirs of a package under `key`. Atomic: builds a
397
+ * `.tmp-<pid>` sibling then renames into place, so concurrent readers never
398
+ * observe a partial entry. Best-effort — failures are swallowed (the build
399
+ * itself already succeeded).
400
+ */
401
+ export function storeCacheEntry(cacheRoot, pkg, key, script, dirs) {
402
+ const finalDir = entryDir(cacheRoot, pkg.name, key);
403
+ const tmpDir = `${finalDir}.tmp-${process.pid}`;
404
+ try {
405
+ rmSync(tmpDir, { recursive: true, force: true });
406
+ mkdirSync(join(tmpDir, 'outputs'), { recursive: true });
407
+ for (const dir of dirs) {
408
+ copyDirRecursive(join(pkg.location, dir), join(tmpDir, 'outputs', dir));
409
+ }
410
+ const now = new Date().toISOString();
411
+ const manifest = {
412
+ version: 1,
413
+ layout: BUILD_CACHE_LAYOUT_VERSION,
414
+ package: pkg.name,
415
+ script,
416
+ key,
417
+ dirs: [...dirs],
418
+ outputsHash: hashOutputDirs(pkg.location, dirs),
419
+ createdAt: now,
420
+ usedAt: now,
421
+ };
422
+ writeFileSync(join(tmpDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
423
+ rmSync(finalDir, { recursive: true, force: true });
424
+ renameSync(tmpDir, finalDir);
425
+ pruneCacheEntries(cacheRoot, pkg.name);
426
+ return manifest;
427
+ }
428
+ catch {
429
+ try {
430
+ rmSync(tmpDir, { recursive: true, force: true });
431
+ }
432
+ catch {
433
+ /* best-effort cleanup */
434
+ }
435
+ return null;
436
+ }
437
+ }
438
+ /**
439
+ * Restore a validated entry into the package. Replaces EXACTLY the dirs the
440
+ * manifest records (delete-then-copy within the package); skips the copy
441
+ * when the on-disk outputs already content-match the entry. Returns how the
442
+ * hit was satisfied, or null when restoration failed (treat as MISS).
443
+ */
444
+ export function restoreCacheEntry(cacheRoot, pkg, manifest) {
445
+ const dir = entryDir(cacheRoot, pkg.name, manifest.key);
446
+ try {
447
+ if (manifest.dirs.length > 0 && hashOutputDirs(pkg.location, manifest.dirs) === manifest.outputsHash) {
448
+ touchManifestUsed(dir, manifest);
449
+ return 'up-to-date';
450
+ }
451
+ for (const outDir of manifest.dirs) {
452
+ const target = join(pkg.location, outDir);
453
+ rmSync(target, { recursive: true, force: true });
454
+ copyDirRecursive(join(dir, 'outputs', outDir), target);
455
+ }
456
+ touchManifestUsed(dir, manifest);
457
+ return manifest.dirs.length > 0 ? 'restored' : 'up-to-date';
458
+ }
459
+ catch {
460
+ return null;
461
+ }
462
+ }
463
+ function touchManifestUsed(dir, manifest) {
464
+ try {
465
+ manifest.usedAt = new Date().toISOString();
466
+ writeFileSync(join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2));
467
+ }
468
+ catch {
469
+ /* recency bump is best-effort */
470
+ }
471
+ }
472
+ /** Evict the oldest entries (by last store/use) beyond `keep` per package. */
473
+ export function pruneCacheEntries(cacheRoot, pkgName, keep = MAX_KEYS_PER_PACKAGE) {
474
+ const pkgDir = join(cacheRoot, sanitizePackageDirName(pkgName));
475
+ let entries;
476
+ try {
477
+ entries = readdirSync(pkgDir);
478
+ }
479
+ catch {
480
+ return;
481
+ }
482
+ const dated = [];
483
+ for (const entry of entries) {
484
+ // Leftover tmp dirs from crashed stores are always eligible.
485
+ if (entry.includes('.tmp-')) {
486
+ try {
487
+ rmSync(join(pkgDir, entry), { recursive: true, force: true });
488
+ }
489
+ catch {
490
+ /* ignore */
491
+ }
492
+ continue;
493
+ }
494
+ let stamp = '';
495
+ try {
496
+ const manifest = JSON.parse(readFileSync(join(pkgDir, entry, 'manifest.json'), 'utf-8'));
497
+ stamp = manifest.usedAt ?? manifest.createdAt ?? '';
498
+ }
499
+ catch {
500
+ /* unreadable → oldest */
501
+ }
502
+ dated.push({ name: entry, stamp });
503
+ }
504
+ if (dated.length <= keep)
505
+ return;
506
+ dated.sort((a, b) => (a.stamp < b.stamp ? 1 : a.stamp > b.stamp ? -1 : 0));
507
+ for (const evict of dated.slice(keep)) {
508
+ try {
509
+ rmSync(join(pkgDir, evict.name), { recursive: true, force: true });
510
+ }
511
+ catch {
512
+ /* best-effort */
513
+ }
514
+ }
515
+ }
516
+ // Per-package opt-out: `package.json#gjsify.buildCache: false`. For packages
517
+ // whose build writes outside the conventional output dirs (e.g.
518
+ // `@gjsify/adwaita-web` generates `src/styles.generated.ts` plus per-file JS
519
+ // output inside `src/` that consumers resolve from SOURCE) — a cached skip
520
+ // would leave those generated files missing, so such packages always run
521
+ // uncached.
522
+ export function buildCacheOptedOut(ws) {
523
+ const gjsify = ws.manifest['gjsify'];
524
+ return gjsify?.buildCache === false;
525
+ }
526
+ /**
527
+ * Per-run orchestrator used by `gjsify workspace` / `gjsify foreach`:
528
+ * memoizes input hashes + keys across packages, restores on hit, snapshots +
529
+ * stores on miss. All entry points are fail-soft — any internal error logs a
530
+ * warning and reports a MISS so the script runs normally.
531
+ */
532
+ export class BuildCacheRunner {
533
+ options;
534
+ cacheRoot;
535
+ graph;
536
+ toolchain;
537
+ ownHashes = new Map();
538
+ closures = new Map();
539
+ keys = new Map();
540
+ log;
541
+ constructor(options) {
542
+ this.options = options;
543
+ this.cacheRoot = buildCacheRoot(options.root);
544
+ // Broadest edge set for KEY correctness: builds consume prod deps'
545
+ // outputs, type-check against dev deps, and may link optional/peer
546
+ // workspace siblings. The dev graph is cyclic in this monorepo —
547
+ // fine, the closure below is a BFS, not a topological sort.
548
+ this.graph = buildDependencyGraph(options.workspaces, {
549
+ includeDev: true,
550
+ includePeer: true,
551
+ includeOptional: true,
552
+ });
553
+ this.toolchain = resolveToolchainVersions(options.root);
554
+ this.log = options.log ?? ((line) => console.error(line));
555
+ // Pin every relevant input hash to the run-start tree state (see
556
+ // `targets` docs above). Keys derived later stay consistent even if
557
+ // an earlier build in this run mutates a package's src.
558
+ for (const target of options.targets ?? []) {
559
+ if (!this.graph.byName.has(target))
560
+ continue;
561
+ this.ownHashOf(target);
562
+ for (const dep of this.closureOf(target))
563
+ this.ownHashOf(dep);
564
+ }
565
+ }
566
+ ownHashOf(name) {
567
+ let hash = this.ownHashes.get(name);
568
+ if (hash === undefined) {
569
+ const ws = this.graph.byName.get(name);
570
+ hash = ws ? hashPackageInputs(ws.location) : 'unknown-workspace';
571
+ this.ownHashes.set(name, hash);
572
+ }
573
+ return hash;
574
+ }
575
+ /** BFS transitive workspace-dep closure (cycle-safe), excluding `name` itself. */
576
+ closureOf(name) {
577
+ let closure = this.closures.get(name);
578
+ if (closure === undefined) {
579
+ const seen = new Set();
580
+ const queue = [...(this.graph.edges.get(name) ?? [])];
581
+ while (queue.length > 0) {
582
+ const dep = queue.shift();
583
+ if (dep === name || seen.has(dep))
584
+ continue;
585
+ seen.add(dep);
586
+ for (const next of this.graph.edges.get(dep) ?? []) {
587
+ if (!seen.has(next) && next !== name)
588
+ queue.push(next);
589
+ }
590
+ }
591
+ closure = [...seen].sort();
592
+ this.closures.set(name, closure);
593
+ }
594
+ return closure;
595
+ }
596
+ /** Content-hash cache key for one workspace (memoized per run). */
597
+ keyFor(ws) {
598
+ let key = this.keys.get(ws.name);
599
+ if (key === undefined) {
600
+ const depHashes = new Map();
601
+ for (const dep of this.closureOf(ws.name))
602
+ depHashes.set(dep, this.ownHashOf(dep));
603
+ key = composeCacheKey({
604
+ script: this.options.script,
605
+ args: this.options.args,
606
+ toolchain: this.toolchain,
607
+ ownHash: this.ownHashOf(ws.name),
608
+ depHashes,
609
+ });
610
+ this.keys.set(ws.name, key);
611
+ }
612
+ return key;
613
+ }
614
+ /**
615
+ * Try to satisfy the script from the cache. Returns `true` on a hit
616
+ * (outputs restored / already current — the caller SKIPS the script).
617
+ */
618
+ tryRestore(ws) {
619
+ try {
620
+ if (buildCacheOptedOut(ws)) {
621
+ this.log(`[gjsify build-cache] ${ws.name}: opted out (gjsify.buildCache=false) — running uncached`);
622
+ return false;
623
+ }
624
+ const key = this.keyFor(ws);
625
+ const manifest = readCacheEntry(this.cacheRoot, ws.name, key);
626
+ if (!manifest || manifest.script !== this.options.script) {
627
+ this.log(`[gjsify build-cache] ${ws.name}: miss ${key} — running "${this.options.script}"`);
628
+ return false;
629
+ }
630
+ const how = restoreCacheEntry(this.cacheRoot, ws, manifest);
631
+ if (how === null) {
632
+ this.log(`[gjsify build-cache] ${ws.name}: entry ${key} unrestorable — running "${this.options.script}"`);
633
+ return false;
634
+ }
635
+ const what = manifest.dirs.length > 0 ? manifest.dirs.join(', ') : 'no output dirs';
636
+ this.log(`[gjsify build-cache] ${ws.name}: hit ${key} — ${how === 'restored' ? `restored ${what}` : `${what} up to date`} (skipped "${this.options.script}")`);
637
+ return true;
638
+ }
639
+ catch (err) {
640
+ this.log(`[gjsify build-cache] ${ws.name}: cache error (${err.message}) — running uncached`);
641
+ return false;
642
+ }
643
+ }
644
+ /** Snapshot the candidate output dirs BEFORE running the script. */
645
+ snapshotOutputs(ws) {
646
+ try {
647
+ return snapshotOutputDirs(ws.location);
648
+ }
649
+ catch {
650
+ return {};
651
+ }
652
+ }
653
+ /** After a SUCCESSFUL script run: store the dirs the build modified. */
654
+ storeAfterSuccess(ws, before) {
655
+ try {
656
+ if (buildCacheOptedOut(ws))
657
+ return;
658
+ const key = this.keyFor(ws);
659
+ const dirs = modifiedOutputDirs(ws.location, before);
660
+ const manifest = storeCacheEntry(this.cacheRoot, ws, key, this.options.script, dirs);
661
+ if (manifest) {
662
+ const what = dirs.length > 0 ? dirs.join(', ') : 'no output dirs modified';
663
+ this.log(`[gjsify build-cache] ${ws.name}: stored ${key} (${what})`);
664
+ }
665
+ }
666
+ catch (err) {
667
+ this.log(`[gjsify build-cache] ${ws.name}: store failed (${err.message}) — continuing`);
668
+ }
669
+ }
670
+ }