@visulima/task-runner 1.0.0-alpha.5 → 1.0.0-alpha.7

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.
Files changed (42) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/archive.d.ts +38 -0
  3. package/dist/cache.d.ts +31 -3
  4. package/dist/chrome-trace.d.ts +53 -0
  5. package/dist/file-access-tracker.d.ts +7 -1
  6. package/dist/fingerprint.d.ts +9 -0
  7. package/dist/incremental-hasher.d.ts +18 -0
  8. package/dist/index.d.ts +8 -3
  9. package/dist/index.js +22 -19
  10. package/dist/life-cycle.d.ts +2 -0
  11. package/dist/log-reporter.d.ts +34 -0
  12. package/dist/output-resolver.d.ts +20 -0
  13. package/dist/packem_shared/{Cache-iAjRMV2d.js → Cache-CWaX_c8U.js} +135 -45
  14. package/dist/packem_shared/{CompositeLifeCycle-7AtYw1dv.js → CompositeLifeCycle-CSVbRC_5.js} +10 -0
  15. package/dist/packem_shared/{FileAccessTracker-CrtBAt5D.js → FileAccessTracker-CQ5Ot7Hd.js} +68 -16
  16. package/dist/packem_shared/{FingerprintManager-Cu-ta9ee.js → FingerprintManager-CV7U4f4f.js} +22 -1
  17. package/dist/packem_shared/{IncrementalFileHasher-Cm_kJY5V.js → IncrementalFileHasher-BRS76-mb.js} +26 -0
  18. package/dist/packem_shared/LogReporter-BDt52HLu.js +44 -0
  19. package/dist/packem_shared/{RemoteCache-BFceSe4a.js → RemoteCache-DSU3lc87.js} +77 -37
  20. package/dist/packem_shared/{TaskOrchestrator-lLn-PH1m.js → TaskOrchestrator-rf45vW5c.js} +94 -15
  21. package/dist/packem_shared/{TerminalBuffer-CnPyFgPB.js → TerminalBuffer-qVJvbRQZ.js} +1 -1
  22. package/dist/packem_shared/{TrackedTaskExecutor-BGUKFE-7.js → TrackedTaskExecutor-CFPpQfXF.js} +1 -1
  23. package/dist/packem_shared/archive-UQHAnZUa.js +102 -0
  24. package/dist/packem_shared/{buildForwardDependencyMap-0BJFMMPv.js → buildForwardDependencyMap-DLPgKEto.js} +2 -1
  25. package/dist/packem_shared/{computeTaskHash-B2SVZqgp.js → computeTaskHash-DYqfrDGq.js} +122 -6
  26. package/dist/packem_shared/{createTaskGraph-CcsFaSrz.js → createTaskGraph-B7nH0kY_.js} +2 -2
  27. package/dist/packem_shared/{defaultTaskRunner-BdFTifsh.js → defaultTaskRunner-Cp7jCmIl.js} +28 -6
  28. package/dist/packem_shared/{extractPackageName-CbVNW-dr.js → extractPackageName-BllKetnz.js} +2 -1
  29. package/dist/packem_shared/{generateRunSummary-qn-_jKwt.js → generateRunSummary-BE1jnQ3H.js} +19 -1
  30. package/dist/packem_shared/{parsePartition-C4-P5RjK.js → parsePartition-BfLbHGAx.js} +18 -0
  31. package/dist/packem_shared/{projectGraphToDot-C8uYeaPo.js → projectGraphToDot-DU1lSe-c.js} +1 -1
  32. package/dist/packem_shared/resolveOutputs-n6MCKoTe.js +111 -0
  33. package/dist/packem_shared/{runConcurrentFallback-CGHz_f-Q.js → runConcurrentFallback-BTmgGV1H.js} +1 -1
  34. package/dist/packem_shared/{runConcurrently-qrkWyzXW.js → runConcurrently-CmfC4r-f.js} +1 -1
  35. package/dist/packem_shared/toChromeTrace-B2tZoJ-7.js +121 -0
  36. package/dist/remote-cache.d.ts +45 -0
  37. package/dist/run-summary.d.ts +26 -4
  38. package/dist/task-hasher.d.ts +37 -0
  39. package/dist/task-orchestrator.d.ts +2 -2
  40. package/dist/types.d.ts +137 -3
  41. package/index.js +52 -52
  42. package/package.json +13 -13
@@ -18,40 +18,45 @@ const __cjs_getBuiltinModule = (module) => {
18
18
  };
19
19
 
20
20
  const {
21
- execFile
22
- } = __cjs_getBuiltinModule("node:child_process");
21
+ createHmac,
22
+ timingSafeEqual
23
+ } = __cjs_getBuiltinModule("node:crypto");
23
24
  const {
24
- createWriteStream
25
+ createWriteStream,
26
+ createReadStream
25
27
  } = __cjs_getBuiltinModule("node:fs");
26
28
  const {
27
29
  mkdir,
28
30
  rm,
29
- stat,
30
- readFile
31
+ stat
31
32
  } = __cjs_getBuiltinModule("node:fs/promises");
32
33
  const {
33
34
  pipeline
34
35
  } = __cjs_getBuiltinModule("node:stream/promises");
35
36
  import { join } from '@visulima/path';
37
+ import { e as extractTarBrotli, a as extractTarGz, c as createTarBrotli, b as createTarGz } from './archive-UQHAnZUa.js';
36
38
 
37
- const createTarGz = (sourceDirectory, outputPath) => new Promise((resolve, reject) => {
38
- execFile("tar", ["-czf", outputPath, "-C", sourceDirectory, "."], (error) => {
39
- if (error) {
40
- reject(error);
41
- } else {
42
- resolve();
43
- }
44
- });
45
- });
46
- const extractTarGz = (archivePath, destinationDirectory) => new Promise((resolve, reject) => {
47
- execFile("tar", ["-xzf", archivePath, "-C", destinationDirectory], (error) => {
48
- if (error) {
49
- reject(error);
50
- } else {
51
- resolve();
52
- }
53
- });
54
- });
39
+ const SIGNATURE_HEADER = "X-Artifact-Signature";
40
+ const MIN_SECRET_LENGTH = 16;
41
+ const computeArtifactSignatureStream = async (secret, hash, archivePath) => {
42
+ const hmac = createHmac("sha256", secret);
43
+ hmac.update(hash);
44
+ const source = createReadStream(archivePath);
45
+ for await (const chunk of source) {
46
+ hmac.update(chunk);
47
+ }
48
+ return hmac.digest("hex");
49
+ };
50
+ const signaturesMatch = (a, b) => {
51
+ if (a.length !== b.length) {
52
+ return false;
53
+ }
54
+ try {
55
+ return timingSafeEqual(Buffer.from(a, "hex"), Buffer.from(b, "hex"));
56
+ } catch {
57
+ return false;
58
+ }
59
+ };
55
60
  class RemoteCache {
56
61
  #url;
57
62
  #token;
@@ -60,6 +65,9 @@ class RemoteCache {
60
65
  #read;
61
66
  #write;
62
67
  #onUploadError;
68
+ #compression;
69
+ #signingSecret;
70
+ #verifyOnDownload;
63
71
  constructor(options) {
64
72
  this.#url = options.url.replace(/\/$/, "");
65
73
  this.#token = options.token;
@@ -68,6 +76,17 @@ class RemoteCache {
68
76
  this.#read = options.read ?? true;
69
77
  this.#write = options.write ?? true;
70
78
  this.#onUploadError = options.onUploadError;
79
+ this.#compression = options.compression ?? "gzip";
80
+ if (options.signing) {
81
+ if (options.signing.secret.length < MIN_SECRET_LENGTH) {
82
+ throw new Error(`Remote cache signing secret must be at least ${String(MIN_SECRET_LENGTH)} characters.`);
83
+ }
84
+ this.#signingSecret = options.signing.secret;
85
+ this.#verifyOnDownload = options.signing.verifyOnDownload ?? false;
86
+ } else {
87
+ this.#signingSecret = void 0;
88
+ this.#verifyOnDownload = false;
89
+ }
71
90
  }
72
91
  /**
73
92
  * Retrieves a cached artifact from the remote cache.
@@ -89,15 +108,23 @@ class RemoteCache {
89
108
  if (!response.ok) {
90
109
  return false;
91
110
  }
92
- await mkdir(localCacheDirectory, { recursive: true });
93
- const { body } = response;
94
- if (!body) {
111
+ const receivedSignature = this.#signingSecret ? response.headers.get(SIGNATURE_HEADER.toLowerCase()) ?? response.headers.get(SIGNATURE_HEADER) : null;
112
+ if (this.#signingSecret && !receivedSignature && this.#verifyOnDownload) {
113
+ return false;
114
+ }
115
+ if (!response.body) {
95
116
  return false;
96
117
  }
97
- const fileStream = createWriteStream(archivePath);
98
- await pipeline(body, fileStream);
118
+ await mkdir(localCacheDirectory, { recursive: true });
119
+ await pipeline(response.body, createWriteStream(archivePath));
120
+ if (this.#signingSecret && receivedSignature) {
121
+ const expected = await computeArtifactSignatureStream(this.#signingSecret, hash, archivePath);
122
+ if (!signaturesMatch(receivedSignature, expected)) {
123
+ return false;
124
+ }
125
+ }
99
126
  await mkdir(entryDirectory, { recursive: true });
100
- await extractTarGz(archivePath, entryDirectory);
127
+ await (this.#compression === "brotli" ? extractTarBrotli(archivePath, entryDirectory) : extractTarGz(archivePath, entryDirectory));
101
128
  await rm(archivePath, { force: true });
102
129
  return true;
103
130
  } catch {
@@ -119,16 +146,29 @@ class RemoteCache {
119
146
  const archivePath = join(localCacheDirectory, `.upload-${hash}.tar.gz`);
120
147
  try {
121
148
  await stat(join(entryDirectory, ".commit"));
122
- await createTarGz(entryDirectory, archivePath);
149
+ await (this.#compression === "brotli" ? createTarBrotli(entryDirectory, archivePath) : createTarGz(entryDirectory, archivePath));
123
150
  const artifactUrl = this.#buildUrl(`/v8/artifacts/${hash}`);
124
- const archiveContent = await readFile(archivePath);
151
+ const { size } = await stat(archivePath);
152
+ const uploadHeaders = {
153
+ ...this.#buildHeaders(),
154
+ "Content-Length": String(size),
155
+ // Advertise the compression format in a custom header so
156
+ // spec-compatible servers (and the matching download side)
157
+ // can branch if needed. The body is still an opaque blob
158
+ // from the server's perspective.
159
+ "Content-Type": "application/octet-stream",
160
+ "X-Artifact-Compression": this.#compression
161
+ };
162
+ if (this.#signingSecret) {
163
+ uploadHeaders[SIGNATURE_HEADER] = await computeArtifactSignatureStream(this.#signingSecret, hash, archivePath);
164
+ }
125
165
  const response = await fetch(artifactUrl, {
126
- body: archiveContent,
127
- headers: {
128
- ...this.#buildHeaders(),
129
- "Content-Length": String(archiveContent.length),
130
- "Content-Type": "application/octet-stream"
131
- },
166
+ body: createReadStream(archivePath),
167
+ // @ts-expect-error — `duplex` is a Node-specific fetch
168
+ // option required when the body is a stream. The DOM
169
+ // `RequestInit` type doesn't include it yet.
170
+ duplex: "half",
171
+ headers: uploadHeaders,
132
172
  method: "PUT",
133
173
  signal: AbortSignal.timeout(this.#timeout)
134
174
  });
@@ -1,9 +1,9 @@
1
1
  import { d as resolveTaskCwd, a as createFailureResult, e as createXxh3Hasher } from './utils-zO0ZRgtf.js';
2
- import { join } from '@visulima/path';
3
- import { FingerprintManager } from './FingerprintManager-Cu-ta9ee.js';
4
- import { generateRunSummary, writeRunSummary } from './generateRunSummary-qn-_jKwt.js';
5
- import { computeTaskHash } from './computeTaskHash-B2SVZqgp.js';
6
- import { TrackedTaskExecutor } from './TrackedTaskExecutor-BGUKFE-7.js';
2
+ import { resolve, join } from '@visulima/path';
3
+ import { FingerprintManager } from './FingerprintManager-CV7U4f4f.js';
4
+ import { generateRunSummary, writeLastRunSummary, writeRunSummary } from './generateRunSummary-BE1jnQ3H.js';
5
+ import { computeTaskHash } from './computeTaskHash-DYqfrDGq.js';
6
+ import { TrackedTaskExecutor } from './TrackedTaskExecutor-CFPpQfXF.js';
7
7
 
8
8
  const hashFingerprint = (fingerprint) => {
9
9
  const hash = createXxh3Hasher();
@@ -26,11 +26,11 @@ const hashFingerprint = (fingerprint) => {
26
26
  return hash.digest();
27
27
  };
28
28
  const createDeferred = () => {
29
- let resolve;
29
+ let resolve2;
30
30
  const promise = new Promise((r) => {
31
- resolve = r;
31
+ resolve2 = r;
32
32
  });
33
- return { promise, resolve };
33
+ return { promise, resolve: resolve2 };
34
34
  };
35
35
  class TaskOrchestrator {
36
36
  #taskHasher;
@@ -101,9 +101,12 @@ class TaskOrchestrator {
101
101
  process.removeListener("SIGTERM", signalHandler);
102
102
  this.#lifeCycle.endCommand?.();
103
103
  }
104
- if (this.#summarize && this.#taskGraph) {
104
+ if (this.#taskGraph && !this.#aborted) {
105
105
  const summary = generateRunSummary(this.#results, this.#taskGraph, this.#startTime);
106
- await writeRunSummary(summary, this.#workspaceRoot);
106
+ await writeLastRunSummary(summary, this.#workspaceRoot);
107
+ if (this.#summarize) {
108
+ await writeRunSummary(summary, this.#workspaceRoot);
109
+ }
107
110
  }
108
111
  return this.#results;
109
112
  }
@@ -243,7 +246,13 @@ class TaskOrchestrator {
243
246
  };
244
247
  this.#results.set(task.id, result);
245
248
  if (code === 0 && task.cache !== false && task.hash) {
246
- await this.#cache.put(task.hash, terminalOutput, task.outputs, code);
249
+ const modified = await this.#detectSelfModifiedInputs(task);
250
+ if (modified.length > 0) {
251
+ result.selfModified = true;
252
+ this.#lifeCycle.printSelfModifyingSkip?.(task, modified);
253
+ } else {
254
+ await this.#cache.put(task.hash, terminalOutput, task.outputs, code);
255
+ }
247
256
  }
248
257
  return result;
249
258
  } catch (error) {
@@ -252,6 +261,28 @@ class TaskOrchestrator {
252
261
  return result;
253
262
  }
254
263
  }
264
+ /**
265
+ * Re-hashes every file recorded in `task.hashDetails.nodes` and returns
266
+ * the workspace-relative paths whose post-execution hash differs from the
267
+ * pre-execution hash. A non-empty result means the task wrote to its own
268
+ * tracked inputs and the cache entry would be unsafe to persist.
269
+ */
270
+ async #detectSelfModifiedInputs(task) {
271
+ const nodes = task.hashDetails?.nodes;
272
+ if (!nodes || typeof this.#taskHasher.rehashFile !== "function") {
273
+ return [];
274
+ }
275
+ const rehash = this.#taskHasher.rehashFile.bind(this.#taskHasher);
276
+ const entries = Object.entries(nodes);
277
+ const checks = await Promise.all(
278
+ entries.map(async ([path, priorHash]) => {
279
+ const absolute = resolve(this.#workspaceRoot, path);
280
+ const fresh = await rehash(absolute);
281
+ return fresh !== void 0 && fresh !== priorHash ? path : void 0;
282
+ })
283
+ );
284
+ return checks.filter((path) => path !== void 0);
285
+ }
255
286
  async #executeTaskWithTracking(task, startTime) {
256
287
  if (!this.#fingerprintManager) {
257
288
  return this.#executeTask(task, startTime);
@@ -262,12 +293,18 @@ class TaskOrchestrator {
262
293
  let code;
263
294
  let terminalOutput;
264
295
  let fingerprint;
296
+ let trackerAccessCount = 0;
297
+ let usedRealTracker = false;
298
+ let autoWrites;
265
299
  const shellCommand = this.#resolveCommand?.(task);
266
300
  const canTrack = shellCommand && this.#trackedExecutor?.isTrackingSupported;
267
301
  if (canTrack && this.#trackedExecutor) {
268
302
  const trackedResult = await this.#trackedExecutor.execute(task, { captureOutput: this.#captureOutput, cwd }, shellCommand);
269
303
  code = trackedResult.code;
270
304
  terminalOutput = trackedResult.terminalOutput;
305
+ trackerAccessCount = trackedResult.accesses.length;
306
+ usedRealTracker = true;
307
+ autoWrites = trackedResult.accesses.filter((a) => a.type === "write").map((a) => a.path);
271
308
  fingerprint = await this.#fingerprintManager.createFingerprint(
272
309
  trackedResult.accesses,
273
310
  taskCommand,
@@ -309,10 +346,20 @@ class TaskOrchestrator {
309
346
  };
310
347
  this.#results.set(task.id, result);
311
348
  if (code === 0 && task.cache !== false && fingerprint) {
312
- const hash = hashFingerprint(fingerprint);
313
- Object.assign(task, { hash });
314
- await this.#cache.put(hash, terminalOutput, task.outputs, code, fingerprint);
315
- await this.#cache.setTaskIndex(task.id, hash);
349
+ const modified = this.#detectSelfModifiedFingerprint(fingerprint);
350
+ const emptyFingerprintReason = this.#describeEmptyFingerprint(fingerprint, usedRealTracker, trackerAccessCount);
351
+ if (modified.length > 0) {
352
+ result.selfModified = true;
353
+ this.#lifeCycle.printSelfModifyingSkip?.(task, modified);
354
+ } else if (emptyFingerprintReason) {
355
+ result.emptyFingerprint = true;
356
+ this.#lifeCycle.printEmptyFingerprintWarning?.(task, emptyFingerprintReason);
357
+ } else {
358
+ const hash = hashFingerprint(fingerprint);
359
+ Object.assign(task, { hash });
360
+ await this.#cache.put(hash, terminalOutput, task.outputs, code, fingerprint, autoWrites);
361
+ await this.#cache.setTaskIndex(task.id, hash);
362
+ }
316
363
  }
317
364
  return result;
318
365
  } catch (error) {
@@ -321,6 +368,38 @@ class TaskOrchestrator {
321
368
  return result;
322
369
  }
323
370
  }
371
+ /**
372
+ * In auto-fingerprint mode, returns the workspace-relative paths the
373
+ * task both read *and* wrote during execution. {@link FingerprintManager}
374
+ * populates `modifiedInputs` from the tracker's `"write"`-typed accesses;
375
+ * backends that don't yet emit write accesses leave it empty.
376
+ */
377
+ // eslint-disable-next-line class-methods-use-this
378
+ #detectSelfModifiedFingerprint(fingerprint) {
379
+ return fingerprint.modifiedInputs ?? [];
380
+ }
381
+ /**
382
+ * Returns a human-readable reason string when a fingerprint looks
383
+ * suspiciously empty (tracker ran but observed no workspace files),
384
+ * or `undefined` when the fingerprint is trustworthy. Caching is
385
+ * skipped for empty fingerprints — silently persisting one would
386
+ * guarantee false cache hits on every subsequent run.
387
+ *
388
+ * Only flags results from the real tracker; the glob-based fallback
389
+ * path legitimately produces wide fingerprints and isn't at risk.
390
+ */
391
+ // eslint-disable-next-line class-methods-use-this
392
+ #describeEmptyFingerprint(fingerprint, usedRealTracker, trackerAccessCount) {
393
+ if (!usedRealTracker) {
394
+ return void 0;
395
+ }
396
+ const hasAnyAccess = Object.keys(fingerprint.fileHashes).length > 0 || Object.keys(fingerprint.directoryListings).length > 0 || fingerprint.missingFiles.length > 0;
397
+ if (hasAnyAccess) {
398
+ return void 0;
399
+ }
400
+ const zeroAccesses = trackerAccessCount === 0;
401
+ return zeroAccesses ? "Tracker observed no workspace file accesses — likely a static binary on a platform without strace. Caching skipped." : "Tracker returned accesses but none fell inside the workspace. Caching skipped to avoid false cache hits.";
402
+ }
324
403
  #dryRunResult(task, startTime) {
325
404
  const cacheStatus = task.hash ? `[hash: ${task.hash.slice(0, 12)}...]` : "[no hash]";
326
405
  const result = {
@@ -57,7 +57,7 @@ class TerminalBuffer {
57
57
  let j = start;
58
58
  let params = "";
59
59
  while (j < data.length && (data[j] >= "0" && data[j] <= "9" || data[j] === ";")) {
60
- params += data[j];
60
+ params += data[j] ?? "";
61
61
  j++;
62
62
  }
63
63
  if (j >= data.length) {
@@ -27,7 +27,7 @@ const {
27
27
  rm
28
28
  } = __cjs_getBuiltinModule("node:fs/promises");
29
29
  import { join } from '@visulima/path';
30
- import { FileAccessTracker, generatePreloadScript } from './FileAccessTracker-CrtBAt5D.js';
30
+ import { FileAccessTracker, generatePreloadScript } from './FileAccessTracker-CQ5Ot7Hd.js';
31
31
  import { u as uniqueId } from './utils-zO0ZRgtf.js';
32
32
 
33
33
  class TrackedTaskExecutor {
@@ -0,0 +1,102 @@
1
+ import { createRequire as __cjs_createRequire } from "node:module";
2
+
3
+ const __cjs_require = __cjs_createRequire(import.meta.url);
4
+
5
+ const __cjs_getProcess = typeof globalThis !== "undefined" && typeof globalThis.process !== "undefined" ? globalThis.process : process;
6
+
7
+ const __cjs_getBuiltinModule = (module) => {
8
+ // Check if we're in Node.js and version supports getBuiltinModule
9
+ if (typeof __cjs_getProcess !== "undefined" && __cjs_getProcess.versions && __cjs_getProcess.versions.node) {
10
+ const [major, minor] = __cjs_getProcess.versions.node.split(".").map(Number);
11
+ // Node.js 20.16.0+ and 22.3.0+
12
+ if (major > 22 || (major === 22 && minor >= 3) || (major === 20 && minor >= 16)) {
13
+ return __cjs_getProcess.getBuiltinModule(module);
14
+ }
15
+ }
16
+ // Fallback to createRequire
17
+ return __cjs_require(module);
18
+ };
19
+
20
+ const {
21
+ execFile
22
+ } = __cjs_getBuiltinModule("node:child_process");
23
+ const {
24
+ createReadStream,
25
+ createWriteStream
26
+ } = __cjs_getBuiltinModule("node:fs");
27
+ const {
28
+ rm
29
+ } = __cjs_getBuiltinModule("node:fs/promises");
30
+ const {
31
+ pipeline
32
+ } = __cjs_getBuiltinModule("node:stream/promises");
33
+ const {
34
+ createBrotliDecompress,
35
+ createBrotliCompress,
36
+ constants
37
+ } = __cjs_getBuiltinModule("node:zlib");
38
+
39
+ const BROTLI_COMPRESS_OPTIONS = {
40
+ params: {
41
+ [constants.BROTLI_PARAM_MODE]: constants.BROTLI_MODE_TEXT,
42
+ [constants.BROTLI_PARAM_QUALITY]: 4
43
+ }
44
+ };
45
+ const createTar = (sourceDirectory, outputPath) => new Promise((resolve, reject) => {
46
+ execFile("tar", ["-cf", outputPath, "-C", sourceDirectory, "."], (error) => {
47
+ if (error) {
48
+ reject(error);
49
+ } else {
50
+ resolve();
51
+ }
52
+ });
53
+ });
54
+ const extractTar = (archivePath, destinationDirectory) => new Promise((resolve, reject) => {
55
+ execFile("tar", ["-xf", archivePath, "-C", destinationDirectory], (error) => {
56
+ if (error) {
57
+ reject(error);
58
+ } else {
59
+ resolve();
60
+ }
61
+ });
62
+ });
63
+ const createTarGz = (sourceDirectory, outputPath) => new Promise((resolve, reject) => {
64
+ execFile("tar", ["-czf", outputPath, "-C", sourceDirectory, "."], (error) => {
65
+ if (error) {
66
+ reject(error);
67
+ } else {
68
+ resolve();
69
+ }
70
+ });
71
+ });
72
+ const extractTarGz = (archivePath, destinationDirectory) => new Promise((resolve, reject) => {
73
+ execFile("tar", ["-xzf", archivePath, "-C", destinationDirectory], (error) => {
74
+ if (error) {
75
+ reject(error);
76
+ } else {
77
+ resolve();
78
+ }
79
+ });
80
+ });
81
+ const createTarBrotli = async (sourceDirectory, outputPath) => {
82
+ const tarPath = `${outputPath}.tar`;
83
+ try {
84
+ await createTar(sourceDirectory, tarPath);
85
+ await pipeline(createReadStream(tarPath), createBrotliCompress(BROTLI_COMPRESS_OPTIONS), createWriteStream(outputPath));
86
+ } finally {
87
+ await rm(tarPath, { force: true }).catch(() => {
88
+ });
89
+ }
90
+ };
91
+ const extractTarBrotli = async (archivePath, destinationDirectory) => {
92
+ const tarPath = `${archivePath}.tar`;
93
+ try {
94
+ await pipeline(createReadStream(archivePath), createBrotliDecompress(), createWriteStream(tarPath));
95
+ await extractTar(tarPath, destinationDirectory);
96
+ } finally {
97
+ await rm(tarPath, { force: true }).catch(() => {
98
+ });
99
+ }
100
+ };
101
+
102
+ export { extractTarGz as a, createTarGz as b, createTarBrotli as c, extractTarBrotli as e };
@@ -33,7 +33,8 @@ const findProjectForFile = (filePath, projects) => {
33
33
  let bestLength = 0;
34
34
  for (const [name, config] of Object.entries(projects)) {
35
35
  const { root } = config;
36
- if ((filePath.startsWith(`${root}/`) || filePath === root) && root.length > bestLength) {
36
+ if ((filePath.startsWith(`${root}/`) || filePath === root) && // Prefer the most specific (longest) match
37
+ root.length > bestLength) {
37
38
  bestMatch = name;
38
39
  bestLength = root.length;
39
40
  }
@@ -21,12 +21,13 @@ const {
21
21
  execFile
22
22
  } = __cjs_getBuiltinModule("node:child_process");
23
23
  const {
24
+ stat,
24
25
  readFile
25
26
  } = __cjs_getBuiltinModule("node:fs/promises");
26
27
  import { b as hashStrings, s as sortObjectKeys, c as collectFiles, x as xxh3Hash, e as createXxh3Hasher } from './utils-zO0ZRgtf.js';
27
28
  import { join, resolve, relative } from '@visulima/path';
28
29
  import { getFrameworkEnvVariables } from './detectFrameworks-CeFzKE6J.js';
29
- import { LockfileHasher } from './extractPackageName-CbVNW-dr.js';
30
+ import { LockfileHasher } from './extractPackageName-BllKetnz.js';
30
31
  import { loadNativeBindings } from './isNativeAvailable-BpD28A6Z.js';
31
32
 
32
33
  const DEFAULT_GLOBAL_INPUTS = ["package-lock.json", "pnpm-lock.yaml", "yarn.lock", "tsconfig.base.json", "tsconfig.json", ".env"];
@@ -73,7 +74,38 @@ const hashRuntimeValue = async (runtime) => {
73
74
  }
74
75
  return hashStrings(runtime, output);
75
76
  };
77
+ const SHELL_SPECIAL_VARS = /* @__PURE__ */ new Set(["0", "!", "#", "$", "*", "-", "?", "@", "_"]);
78
+ const extractReferencedEnvVars = (command) => {
79
+ const found = /* @__PURE__ */ new Set();
80
+ const pattern = /\$(?:\{([^}]+)\}|([A-Z_]\w*))/gi;
81
+ let match;
82
+ while ((match = pattern.exec(command)) !== null) {
83
+ const raw = match[1] ?? match[2];
84
+ if (!raw) {
85
+ continue;
86
+ }
87
+ const name = raw.split(/[#%:/?+\-=,^]/)[0]?.trim();
88
+ if (!name || SHELL_SPECIAL_VARS.has(name) || /^\d+$/.test(name)) {
89
+ continue;
90
+ }
91
+ if (!/^[A-Z_]\w*$/i.test(name)) {
92
+ continue;
93
+ }
94
+ found.add(name);
95
+ }
96
+ return [...found];
97
+ };
76
98
  const isFileSetInput = (input) => "fileset" in input;
99
+ const normalizeFileset = (fileset) => {
100
+ if (typeof fileset === "string") {
101
+ return fileset;
102
+ }
103
+ const token = fileset.base === "workspace" ? "{workspaceRoot}" : "{projectRoot}";
104
+ if (fileset.pattern.startsWith("!")) {
105
+ return `!${token}/${fileset.pattern.slice(1)}`;
106
+ }
107
+ return `${token}/${fileset.pattern}`;
108
+ };
77
109
  const isRuntimeInput = (input) => "runtime" in input;
78
110
  const isEnvironmentInput = (input) => "env" in input;
79
111
  const isExternalDependencyInput = (input) => "externalDependencies" in input;
@@ -90,6 +122,8 @@ class InProcessTaskHasher {
90
122
  #smartLockfileHashing;
91
123
  #lockfileHasher;
92
124
  #frameworkInference;
125
+ #autoEnvVars;
126
+ #incrementalHasher;
93
127
  #globalHash = void 0;
94
128
  constructor(options) {
95
129
  this.#workspaceRoot = options.workspaceRoot;
@@ -103,6 +137,8 @@ class InProcessTaskHasher {
103
137
  this.#smartLockfileHashing = options.smartLockfileHashing ?? false;
104
138
  this.#lockfileHasher = this.#smartLockfileHashing ? new LockfileHasher(options.workspaceRoot) : void 0;
105
139
  this.#frameworkInference = options.frameworkInference ?? false;
140
+ this.#autoEnvVars = options.autoEnvVars ?? false;
141
+ this.#incrementalHasher = options.incrementalHasher;
106
142
  }
107
143
  async hashTask(task) {
108
144
  const commandHash = this.#hashCommand(task);
@@ -117,7 +153,7 @@ class InProcessTaskHasher {
117
153
  const negationPatterns = this.#collectNegationPatterns(inputs, task.target.project);
118
154
  for (const input of inputs) {
119
155
  if (isFileSetInput(input)) {
120
- const fileHashes = await this.#hashFileSet(task, input.fileset, negationPatterns);
156
+ const fileHashes = await this.#hashFileSet(task, normalizeFileset(input.fileset), negationPatterns);
121
157
  for (const [filePath, hash] of Object.entries(fileHashes)) {
122
158
  nodes[filePath] = hash;
123
159
  }
@@ -135,6 +171,17 @@ class InProcessTaskHasher {
135
171
  for (const envVariable of this.#envVars) {
136
172
  runtime[`env:${envVariable}`] = hashStrings(envVariable, process.env[envVariable] ?? "");
137
173
  }
174
+ if (this.#autoEnvVars) {
175
+ const command = this.#resolveCommandText(task);
176
+ if (command) {
177
+ for (const name of extractReferencedEnvVars(command)) {
178
+ const key = `env:${name}`;
179
+ if (runtime[key] === void 0) {
180
+ runtime[key] = hashStrings(name, process.env[name] ?? "");
181
+ }
182
+ }
183
+ }
184
+ }
138
185
  const project = this.#projects[task.target.project];
139
186
  if (project) {
140
187
  if (this.#lockfileHasher) {
@@ -177,6 +224,21 @@ class InProcessTaskHasher {
177
224
  hash.update(overridesJson);
178
225
  return hash.digest();
179
226
  }
227
+ /**
228
+ * Looks up the command string associated with a task so referenced
229
+ * env vars can be extracted. Checks `task.overrides.command` first
230
+ * (consumers like vis stash the resolved command there), then falls
231
+ * back to the project's target config.
232
+ */
233
+ #resolveCommandText(task) {
234
+ const override = task.overrides["command"];
235
+ if (typeof override === "string") {
236
+ return override;
237
+ }
238
+ const project = this.#projects[task.target.project];
239
+ const command = project?.targets?.[task.target.target]?.command ?? this.#targetDefaults[task.target.target]?.command;
240
+ return typeof command === "string" ? command : void 0;
241
+ }
180
242
  #resolveInputs(task) {
181
243
  const project = this.#projects[task.target.project];
182
244
  const targetConfig = project?.targets?.[task.target.target];
@@ -216,7 +278,7 @@ class InProcessTaskHasher {
216
278
  if (!isFileSetInput(input)) {
217
279
  continue;
218
280
  }
219
- const resolved = input.fileset.replace("{projectRoot}", projectRoot).replace("{workspaceRoot}", ".");
281
+ const resolved = normalizeFileset(input.fileset).replace("{projectRoot}", projectRoot).replace("{workspaceRoot}", ".");
220
282
  if (resolved.startsWith("!")) {
221
283
  patterns.push(
222
284
  resolved.slice(1).replace(/\/\*\*\/\*$/, "").replace(/\/\*$/, "")
@@ -243,13 +305,30 @@ class InProcessTaskHasher {
243
305
  try {
244
306
  if (this.#native) {
245
307
  const fileHashes = this.#native.hashFilesInDirectory(absoluteBase, this.#workspaceRoot);
308
+ const incremental = this.#incrementalHasher;
309
+ const recordPromises = [];
246
310
  for (const { hash, path } of fileHashes) {
247
311
  const absPath = resolve(this.#workspaceRoot, path);
248
- if (!isExcluded(absPath)) {
249
- result[path] = hash;
250
- this.#fileHashCache.set(absPath, hash);
312
+ if (isExcluded(absPath)) {
313
+ continue;
314
+ }
315
+ result[path] = hash;
316
+ this.#fileHashCache.set(absPath, hash);
317
+ if (incremental) {
318
+ recordPromises.push(
319
+ stat(absPath).then((s) => {
320
+ if (s.isFile()) {
321
+ incremental.recordSnapshot(absPath, hash, s.mtimeMs, s.size);
322
+ }
323
+ return void 0;
324
+ }).catch(() => {
325
+ })
326
+ );
251
327
  }
252
328
  }
329
+ if (recordPromises.length > 0) {
330
+ await Promise.all(recordPromises);
331
+ }
253
332
  return result;
254
333
  }
255
334
  const files = await collectFiles(absoluteBase, IGNORED_DIRS);
@@ -272,6 +351,24 @@ class InProcessTaskHasher {
272
351
  if (cached) {
273
352
  return cached;
274
353
  }
354
+ if (this.#incrementalHasher) {
355
+ try {
356
+ const fileStat = await stat(filePath);
357
+ if (fileStat.isFile()) {
358
+ const snapshotHit = this.#incrementalHasher.getSnapshotHash(filePath, fileStat.mtimeMs, fileStat.size);
359
+ if (snapshotHit) {
360
+ this.#fileHashCache.set(filePath, snapshotHit);
361
+ return snapshotHit;
362
+ }
363
+ const content = await readFile(filePath);
364
+ const hash = xxh3Hash(content);
365
+ this.#incrementalHasher.recordSnapshot(filePath, hash, fileStat.mtimeMs, fileStat.size);
366
+ this.#fileHashCache.set(filePath, hash);
367
+ return hash;
368
+ }
369
+ } catch {
370
+ }
371
+ }
275
372
  try {
276
373
  const content = await readFile(filePath);
277
374
  const hash = xxh3Hash(content);
@@ -326,6 +423,25 @@ class InProcessTaskHasher {
326
423
  this.#globalHash = void 0;
327
424
  this.#lockfileHasher?.clearCache();
328
425
  }
426
+ /**
427
+ * Reads `filePath` fresh and returns its content hash, bypassing the
428
+ * in-memory cache used during the initial `hashTask` pass.
429
+ *
430
+ * Used to detect tasks that modify their own tracked inputs: compare
431
+ * a pre-execution hash (from `task.hashDetails.nodes`) against the
432
+ * post-execution result of this method.
433
+ * @param filePath Absolute path to the file.
434
+ * @returns The fresh xxh3 hash, or `undefined` if the file cannot be read.
435
+ */
436
+ // eslint-disable-next-line class-methods-use-this
437
+ async rehashFile(filePath) {
438
+ try {
439
+ const content = await readFile(filePath);
440
+ return xxh3Hash(content);
441
+ } catch {
442
+ return void 0;
443
+ }
444
+ }
329
445
  }
330
446
  const computeTaskHash = (hashDetails) => {
331
447
  const native = getNativeBindings();