@vzn/vx 0.0.585 → 0.0.586

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.585",
3
+ "version": "0.0.586",
4
4
  "description": "The fastest task runner and build cache for JavaScript monorepos.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -21,10 +21,10 @@
21
21
  "node": ">=18"
22
22
  },
23
23
  "optionalDependencies": {
24
- "@vzn/vx-linux-x64": "0.0.585",
25
- "@vzn/vx-linux-arm64": "0.0.585",
26
- "@vzn/vx-darwin-x64": "0.0.585",
27
- "@vzn/vx-darwin-arm64": "0.0.585"
24
+ "@vzn/vx-linux-x64": "0.0.586",
25
+ "@vzn/vx-linux-arm64": "0.0.586",
26
+ "@vzn/vx-darwin-x64": "0.0.586",
27
+ "@vzn/vx-darwin-arm64": "0.0.586"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -30,6 +30,8 @@ export {
30
30
  } from './cache.js'
31
31
  export { ABSENT_INPUT, absentOr, CACHE_VERSION } from './key-fold.js'
32
32
  export {
33
+ addedInput,
34
+ type InputListing,
33
35
  cleanOutputs,
34
36
  cleanWorkspaceOutputs,
35
37
  declaresInput,
@@ -24,6 +24,7 @@ import {
24
24
  anyTaskGlob,
25
25
  asTrees,
26
26
  isExecutableMissing,
27
+ isInstalledPath,
27
28
  isLiteralPattern,
28
29
  normalizeGlob,
29
30
  outputMatcher,
@@ -35,6 +36,7 @@ import {
35
36
  UserError,
36
37
  } from '../util/index.js'
37
38
  import { GitFilesCache, runGitLsFiles } from './git-inputs.js'
39
+ import { FILE_HASH_RACY_MS, racyWindowMs } from './layer.js'
38
40
 
39
41
  // The git side lives in git-inputs.ts; its whole public surface is
40
42
  // re-exported here so a reader that reaches the resolver for it (the
@@ -93,6 +95,8 @@ const DEFAULT_FILE_GLOBS: readonly string[] = ['**/*']
93
95
 
94
96
  export interface ResolvedInputs {
95
97
  files: string[]
98
+ /** What `files` was filtered from, for {@link addedInput}. */
99
+ listings: InputListing[]
96
100
  envValues: Array<[name: string, value: string | undefined]>
97
101
  runtimeValues: Array<[command: string, output: string]>
98
102
  workspaceRuntimeValues: Array<[command: string, output: string]>
@@ -177,7 +181,7 @@ export type ProjectFilesCache = Map<
177
181
  >
178
182
 
179
183
  export async function resolveInputs(args: ResolveInputsArgs): Promise<ResolvedInputs> {
180
- const projectFiles = await resolveFiles({
184
+ const { files: projectFiles, listing } = await resolveFiles({
181
185
  projectDir: args.projectDir,
182
186
  workspaceRoot: args.workspaceRoot,
183
187
  files: args.inputs?.files,
@@ -187,15 +191,18 @@ export async function resolveInputs(args: ResolveInputsArgs): Promise<ResolvedIn
187
191
  ...(args.projectFilesCache !== undefined ? { projectFilesCache: args.projectFilesCache } : {}),
188
192
  })
189
193
  let files = projectFiles
194
+ const listings = listing === undefined ? [] : [listing]
190
195
  const wsDecl = args.inputs?.workspaceFiles
191
196
  if (wsDecl !== undefined && wsDecl.length > 0) {
192
- const wsFiles = await resolveWorkspaceFiles({
197
+ const ws = resolveWorkspaceFiles({
193
198
  workspaceRoot: args.workspaceRoot,
194
199
  workspaceFiles: wsDecl,
195
200
  ownWorkspaceOutputs: args.ownWorkspaceOutputs ?? [],
196
201
  ...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
197
202
  ...(args.workspaceFilesCache !== undefined ? { memo: args.workspaceFilesCache } : {}),
198
203
  })
204
+ if (ws !== undefined) listings.push(ws.listing)
205
+ const wsFiles = ws === undefined ? [] : await ws.files
199
206
  // Dedupe: when the project dir IS the workspace root (or a glob
200
207
  // overlaps), the same absolute path can arrive via both lists —
201
208
  // it must contribute to the key exactly once.
@@ -228,6 +235,7 @@ export async function resolveInputs(args: ResolveInputsArgs): Promise<ResolvedIn
228
235
  ])
229
236
  return {
230
237
  files,
238
+ listings,
231
239
  envValues: resolveEnvValues(args.inputs?.env ?? [], args.envSource),
232
240
  runtimeValues,
233
241
  workspaceRuntimeValues,
@@ -245,13 +253,13 @@ export async function resolveInputs(args: ResolveInputsArgs): Promise<ResolvedIn
245
253
  * codegen); the hard boundary continues to apply to project-relative
246
254
  * `files` globs only.
247
255
  */
248
- async function resolveWorkspaceFiles(args: {
256
+ function resolveWorkspaceFiles(args: {
249
257
  workspaceRoot: string
250
258
  workspaceFiles: readonly string[]
251
259
  ownWorkspaceOutputs: readonly string[]
252
260
  gitFilesCache?: GitFilesCache
253
261
  memo?: WorkspaceFilesCache
254
- }): Promise<string[]> {
262
+ }): { files: Promise<string[]>; listing: InputListing } | undefined {
255
263
  const positive: string[] = []
256
264
  const negative: string[] = []
257
265
  refuseOneAlternativeBrace(args.workspaceFiles, 'workspaceFiles')
@@ -259,7 +267,7 @@ async function resolveWorkspaceFiles(args: {
259
267
  if (entry.startsWith('!')) negative.push(entry.slice(1))
260
268
  else positive.push(entry)
261
269
  }
262
- if (positive.length === 0) return []
270
+ if (positive.length === 0) return undefined
263
271
 
264
272
  const isExcluded = anyTaskGlob([...ALWAYS_IGNORE, ...asTrees(negative)])
265
273
  // A path the task's own outputs take back with `!` is no output, so it
@@ -285,20 +293,30 @@ async function resolveWorkspaceFiles(args: {
285
293
  args.memo === undefined
286
294
  ? undefined
287
295
  : JSON.stringify([positive, negative, args.ownWorkspaceOutputs])
296
+ let isPositive: ((rel: string) => boolean) | undefined
297
+ const excluded = (rel: string): boolean => isExcluded(rel) || ownOutput(rel)
298
+ const listing: InputListing = {
299
+ root: args.workspaceRoot,
300
+ listed: gitFiles,
301
+ isInput: (rel) => (isPositive ??= anyTaskGlob(asTrees(positive)))(rel) && !excluded(rel),
302
+ nested: () => false,
303
+ ...reachOf(positive),
304
+ }
288
305
  if (memoKey !== undefined) {
289
306
  const hit = args.memo!.get(memoKey)
290
- if (hit !== undefined && hit.snapshot === gitFiles) return hit.result
307
+ if (hit !== undefined && hit.snapshot === gitFiles) return { files: hit.result, listing }
291
308
  }
309
+ isPositive ??= anyTaskGlob(asTrees(positive))
292
310
  const result = resolveWorkspaceFilesOver(
293
311
  args,
294
312
  gitFiles,
295
313
  positive,
296
- anyTaskGlob(asTrees(positive)),
297
- (rel) => isExcluded(rel) || ownOutput(rel),
314
+ isPositive,
315
+ excluded,
298
316
  undecodable,
299
317
  )
300
318
  if (memoKey !== undefined) args.memo!.set(memoKey, { snapshot: gitFiles, result })
301
- return result
319
+ return { files: result, listing }
302
320
  }
303
321
 
304
322
  async function resolveWorkspaceFilesOver(
@@ -1160,6 +1178,8 @@ interface FilesPlan {
1160
1178
  isPositive: (rel: string) => boolean
1161
1179
  /** The literal entries, each naming one path (see `unanswered`). */
1162
1180
  literals: string[]
1181
+ /** The directories a positive entry reaches; see `InputListing`. */
1182
+ prefixes: string[]
1163
1183
  /**
1164
1184
  * Whether a project-relative path is an input by the declaration alone:
1165
1185
  * a positive glob selects it, and no exclude and no own output takes it
@@ -1208,7 +1228,7 @@ function filesPlan(
1208
1228
  ownOutput: outputMatcher(ownOutputs),
1209
1229
  positiveGlobs: asTrees(positive).map(globFor),
1210
1230
  isPositive: anyTaskGlob(asTrees(positive)),
1211
- literals: positive.map(normalizeGlob).filter(isLiteralPattern).map(stripTrailingSlash),
1231
+ ...reachOf(positive),
1212
1232
  verdicts: new Map<string, boolean>(),
1213
1233
  }
1214
1234
  filesPlans.set(key, plan)
@@ -1264,9 +1284,11 @@ function workspaceMatcher(
1264
1284
  return m
1265
1285
  }
1266
1286
 
1267
- async function resolveFiles(args: ResolveFilesArgs): Promise<string[]> {
1287
+ async function resolveFiles(
1288
+ args: ResolveFilesArgs,
1289
+ ): Promise<{ files: string[]; listing?: InputListing }> {
1268
1290
  const plan = filesPlan(args.files, args.ownOutputs)
1269
- if (plan === null) return []
1291
+ if (plan === null) return { files: [] }
1270
1292
  const { positive, negative, isExcluded, ownOutput, positiveGlobs, isPositive } = plan
1271
1293
 
1272
1294
  const nested = inNestedProject(args.projectDir, args.nestedProjectDirs)
@@ -1289,7 +1311,12 @@ async function resolveFiles(args: ResolveFilesArgs): Promise<string[]> {
1289
1311
  // Identity, not equality: a re-enumeration hands back a new array even
1290
1312
  // when the file set is unchanged, and that is exactly when this task's
1291
1313
  // inputs must be walked again.
1292
- if (memo !== undefined && memo.snapshot === gitFiles) return [...memo.result]
1314
+ if (memo !== undefined && memo.snapshot === gitFiles) {
1315
+ return {
1316
+ files: [...memo.result],
1317
+ listing: listingFor(args.projectDir, gitFiles, plan, nested),
1318
+ }
1319
+ }
1293
1320
  }
1294
1321
  if (gitFiles === undefined) {
1295
1322
  // Mid-run re-enumeration — or a project the workspace-wide populate
@@ -1365,7 +1392,23 @@ async function resolveFiles(args: ResolveFilesArgs): Promise<string[]> {
1365
1392
  // Stored only on the way out: a declaration whose literal named an
1366
1393
  // invisible file threw above, and every task sharing it must throw too.
1367
1394
  args.projectFilesCache?.set(memoKey, { snapshot: gitFiles, result: resolved })
1368
- return [...resolved]
1395
+ return { files: [...resolved], listing: listingFor(args.projectDir, gitFiles, plan, nested) }
1396
+ }
1397
+
1398
+ function listingFor(
1399
+ root: string,
1400
+ listed: readonly string[],
1401
+ plan: FilesPlan,
1402
+ nested: (rel: string) => boolean,
1403
+ ): InputListing {
1404
+ return {
1405
+ root,
1406
+ listed,
1407
+ isInput: (rel) => inPlan(plan, rel),
1408
+ nested,
1409
+ prefixes: plan.prefixes,
1410
+ literals: plan.literals,
1411
+ }
1369
1412
  }
1370
1413
 
1371
1414
  /**
@@ -1417,6 +1460,135 @@ function undecodableOnDisk(abs: string): boolean {
1417
1460
  return false
1418
1461
  }
1419
1462
 
1463
+ /**
1464
+ * The listing a task's input files were filtered from, and what reading it
1465
+ * again takes: {@link addedInput} looks for a file the listing lacked.
1466
+ */
1467
+ export interface InputListing {
1468
+ root: string
1469
+ /** Root-relative paths, as git listed them. */
1470
+ listed: readonly string[]
1471
+ isInput: (rel: string) => boolean
1472
+ nested: (rel: string) => boolean
1473
+ /** Root-relative directories a positive entry reaches, `''` for the root. */
1474
+ prefixes: readonly string[]
1475
+ /** The literal entries, each naming one path. */
1476
+ literals: readonly string[]
1477
+ }
1478
+
1479
+ function reachOf(positive: readonly string[]): { prefixes: string[]; literals: string[] } {
1480
+ const prefixes = new Set<string>()
1481
+ for (const p of asTrees(positive)) {
1482
+ if (isLiteralPattern(p)) continue
1483
+ const prefix = staticPrefix(p)
1484
+ prefixes.add(prefix === '.' ? '' : prefix)
1485
+ }
1486
+ return {
1487
+ prefixes: [...prefixes],
1488
+ literals: positive.map(normalizeGlob).filter(isLiteralPattern).map(stripTrailingSlash),
1489
+ }
1490
+ }
1491
+
1492
+ /** Every directory holding a listed path, `''` for the root, once per listing. */
1493
+ const listedDirsMemo = new WeakMap<readonly string[], Set<string>>()
1494
+ function listedDirs(listed: readonly string[]): Set<string> {
1495
+ let dirs = listedDirsMemo.get(listed)
1496
+ if (dirs !== undefined) return dirs
1497
+ dirs = new Set([''])
1498
+ for (const rel of listed) {
1499
+ for (let i = rel.lastIndexOf('/'); i > 0; i = rel.lastIndexOf('/', i - 1)) {
1500
+ const dir = rel.slice(0, i)
1501
+ if (dirs.has(dir)) break
1502
+ dirs.add(dir)
1503
+ }
1504
+ }
1505
+ listedDirsMemo.set(listed, dirs)
1506
+ return dirs
1507
+ }
1508
+
1509
+ function reaches(prefixes: readonly string[], rel: string): boolean {
1510
+ return prefixes.some(
1511
+ (p) => p === '' || rel === p || rel.startsWith(`${p}/`) || p.startsWith(`${rel}/`),
1512
+ )
1513
+ }
1514
+
1515
+ /**
1516
+ * An input file that exists now and that the key did not fold, or undefined.
1517
+ * The facts re-check only the files the key folded, so a file ADDED under
1518
+ * an input glob while the command ran — and read by it — was saved under a
1519
+ * key without it, and replayed once the file was gone.
1520
+ *
1521
+ * Adding a name changes its directory's ctime, so the listing's directories
1522
+ * that a positive entry reaches are stat'ed, and only one changed since
1523
+ * `listedAt` (the listing's time, less the racy window) is read. A name
1524
+ * there that the key lacks and the declaration matches, or an unlisted
1525
+ * directory, is asked of git, which alone knows what is ignored; so is a
1526
+ * missing literal that now exists. Not seen: a file added inside a
1527
+ * directory that held no listed file before (only ignored ones, or none)
1528
+ * and was not itself created during the run — its parent's ctime stays.
1529
+ */
1530
+ export function addedInput(
1531
+ listings: readonly InputListing[],
1532
+ keyFiles: ReadonlySet<string>,
1533
+ listedAt: number,
1534
+ ): string | undefined {
1535
+ for (const l of listings) {
1536
+ const found = addedTo(l, keyFiles, listedAt)
1537
+ if (found !== undefined) return found
1538
+ }
1539
+ return undefined
1540
+ }
1541
+
1542
+ function addedTo(
1543
+ l: InputListing,
1544
+ keyFiles: ReadonlySet<string>,
1545
+ listedAt: number,
1546
+ ): string | undefined {
1547
+ const candidates: string[] = []
1548
+ for (const lit of l.literals) {
1549
+ if (l.nested(lit) || keyFiles.has(path.resolve(l.root, lit))) continue
1550
+ if (existsOnDisk(path.resolve(l.root, lit))) candidates.push(lit)
1551
+ }
1552
+ const known = listedDirs(l.listed)
1553
+ for (const dir of known) {
1554
+ if (!reaches(l.prefixes, dir) || (dir !== '' && l.nested(`${dir}/`))) continue
1555
+ const abs = dir === '' ? l.root : path.resolve(l.root, dir)
1556
+ let entries
1557
+ try {
1558
+ const st = lstatSync(abs, { throwIfNoEntry: false })
1559
+ if (st === undefined) continue
1560
+ if (st.ctimeMs < listedAt - racyWindowMs(st.ctimeMs, FILE_HASH_RACY_MS)) continue
1561
+ entries = readdirSync(abs, { withFileTypes: true })
1562
+ } catch {
1563
+ // Unreadable here: git answers for the whole directory instead.
1564
+ candidates.push(dir)
1565
+ continue
1566
+ }
1567
+ for (const e of entries) {
1568
+ const rel = dir === '' ? e.name : `${dir}/${e.name}`
1569
+ if (e.isDirectory()) {
1570
+ if (known.has(rel) || e.name === 'node_modules' || l.nested(`${rel}/`)) continue
1571
+ if (reaches(l.prefixes, rel)) candidates.push(rel)
1572
+ } else if (
1573
+ l.isInput(rel) &&
1574
+ !l.nested(rel) &&
1575
+ !isInstalledPath(rel) &&
1576
+ !keyFiles.has(path.resolve(abs, e.name))
1577
+ ) {
1578
+ candidates.push(rel)
1579
+ }
1580
+ }
1581
+ }
1582
+ if (candidates.length === 0) return undefined
1583
+ for (const rel of runGitLsFiles(l.root).files) {
1584
+ if (!candidates.some((c) => c === '' || rel === c || rel.startsWith(`${c}/`))) continue
1585
+ if (isInstalledPath(rel) || l.nested(rel) || !l.isInput(rel)) continue
1586
+ const abs = path.resolve(l.root, rel)
1587
+ if (!keyFiles.has(abs) && isInputOnDisk(abs)) return abs
1588
+ }
1589
+ return undefined
1590
+ }
1591
+
1420
1592
  const FATAL_UTF8 = new TextDecoder('utf-8', { fatal: true })
1421
1593
 
1422
1594
  /**
@@ -1330,14 +1330,14 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1330
1330
  }
1331
1331
 
1332
1332
  /**
1333
- * The input that no longer holds what the key folded: `null` when the
1334
- * key re-derived just before the command already differed (the file
1335
- * unnamed), undefined when none moved.
1333
+ * The input that no longer holds what the key folded, or one the key did
1334
+ * not fold that is there now: `null` when the key re-derived just before
1335
+ * the command already differed (the file unnamed), undefined when none
1336
+ * moved.
1336
1337
  */
1337
1338
  async function movedSinceKey(): Promise<string | null | undefined> {
1338
- return described!.hash !== hash
1339
- ? null
1340
- : await movedInput(described!.facts, cache, described!.describedAt)
1339
+ if (described!.hash !== hash) return null
1340
+ return (await movedInput(described!.facts, cache, described!.describedAt)) ?? described!.added()
1341
1341
  }
1342
1342
 
1343
1343
  /**
@@ -5,6 +5,8 @@ import type { ProjectFilesCache, WorkspaceFilesCache } from '../cache/index.js'
5
5
  import {
6
6
  ABSENT_INPUT,
7
7
  absentOr,
8
+ addedInput,
9
+ type InputListing,
8
10
  type CacheKeyInput,
9
11
  type CacheLayer,
10
12
  FILE_HASH_RACY_MS,
@@ -111,7 +113,7 @@ export interface ComputeHashArgs {
111
113
  export async function computeTaskHash(args: ComputeHashArgs): Promise<string> {
112
114
  const end = span('task hash')
113
115
  try {
114
- return await args.cache.key(await resolveKeyInput(args))
116
+ return await args.cache.key((await resolveKeyInput(args)).input)
115
117
  } finally {
116
118
  end()
117
119
  }
@@ -138,11 +140,16 @@ export interface InputFact {
138
140
  * same sources as the fold: the index-OID map for clean tracked files,
139
141
  * else `hashFile`'s stat memo (already warm after `key()`).
140
142
  */
141
- export async function describeTaskInputs(
142
- args: ComputeHashArgs,
143
- ): Promise<{ hash: string; inputs: TaskInputs; facts: InputFact[]; describedAt: number }> {
143
+ export async function describeTaskInputs(args: ComputeHashArgs): Promise<{
144
+ hash: string
145
+ inputs: TaskInputs
146
+ facts: InputFact[]
147
+ describedAt: number
148
+ /** An input file the key did not fold that exists now (see `addedInput`). */
149
+ added: () => string | undefined
150
+ }> {
144
151
  const hashedAt = Date.now()
145
- const input = await resolveKeyInput(args)
152
+ const { input, listings } = await resolveKeyInput(args)
146
153
  const hash = await args.cache.key(input)
147
154
  const sorted = [...input.inputFiles].sort()
148
155
  const digests = await Promise.all(
@@ -194,10 +201,14 @@ export async function describeTaskInputs(
194
201
  })
195
202
  }
196
203
  let upstream: TaskInputs['upstream'] | undefined
204
+ // A listing from the run's enumeration is as old as it; one listed with
205
+ // no run cache was listed during this describe.
206
+ const listedAt = args.gitFilesCache === undefined ? hashedAt : indexedAt
197
207
  return {
198
208
  hash,
199
209
  facts,
200
210
  describedAt: hashedAt,
211
+ added: () => addedInput(listings, new Set(input.inputFiles), listedAt),
201
212
  inputs: {
202
213
  files,
203
214
  env: input.envValues.map(([name, value]) => ({ name, value })),
@@ -260,7 +271,9 @@ export async function movedInput(
260
271
  return undefined
261
272
  }
262
273
 
263
- async function resolveKeyInput(args: ComputeHashArgs): Promise<CacheKeyInput> {
274
+ async function resolveKeyInput(
275
+ args: ComputeHashArgs,
276
+ ): Promise<{ input: CacheKeyInput; listings: InputListing[] }> {
264
277
  const cfg = args.node.config
265
278
  const cacheCfg: CacheConfig | undefined = cfg.cache
266
279
  const outputs = cacheCfg?.outputs.files ?? []
@@ -327,7 +340,7 @@ async function resolveKeyInput(args: ComputeHashArgs): Promise<CacheKeyInput> {
327
340
 
328
341
  const effectiveForwardArgs = args.node.requested ? (args.forwardArgs ?? []) : []
329
342
 
330
- return {
343
+ const input: CacheKeyInput = {
331
344
  taskId: args.node.id,
332
345
  taskConfigHash,
333
346
  projectPackageJsonHash,
@@ -345,6 +358,7 @@ async function resolveKeyInput(args: ComputeHashArgs): Promise<CacheKeyInput> {
345
358
  ...(args.node.keyParts !== undefined ? { pluginParts: args.node.keyParts } : {}),
346
359
  ...(args.captureInto !== undefined ? { captureInto: args.captureInto } : {}),
347
360
  }
361
+ return { input, listings: resolved.listings }
348
362
  }
349
363
 
350
364
  /**