@vzn/vx 0.0.524 → 0.0.525

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.524",
3
+ "version": "0.0.525",
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.524",
25
- "@vzn/vx-linux-arm64": "0.0.524",
26
- "@vzn/vx-darwin-x64": "0.0.524",
27
- "@vzn/vx-darwin-arm64": "0.0.524"
24
+ "@vzn/vx-linux-x64": "0.0.525",
25
+ "@vzn/vx-linux-arm64": "0.0.525",
26
+ "@vzn/vx-darwin-x64": "0.0.525",
27
+ "@vzn/vx-darwin-arm64": "0.0.525"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -21,6 +21,7 @@ import { lstatSync, readdirSync, realpathSync, rmdirSync, rmSync } from 'node:fs
21
21
  import { rm, rmdir } from 'node:fs/promises'
22
22
  import type { CacheConfig, CacheInputs } from '../config.js'
23
23
  import {
24
+ anyTaskGlob,
24
25
  asTrees,
25
26
  isExecutableMissing,
26
27
  isLiteralPattern,
@@ -249,10 +250,10 @@ async function resolveWorkspaceFiles(args: {
249
250
  }
250
251
  if (positive.length === 0) return []
251
252
 
252
- const excludeGlobs = [...ALWAYS_IGNORE, ...asTrees(negative)].map(globFor)
253
+ const isExcluded = anyTaskGlob([...ALWAYS_IGNORE, ...asTrees(negative)])
253
254
  // A path the task's own outputs take back with `!` is no output, so it
254
255
  // stays an input (A-44).
255
- const ownOutput = outputMatcher(args.ownWorkspaceOutputs, globFor)
256
+ const ownOutput = outputMatcher(args.ownWorkspaceOutputs)
256
257
  const positiveGlobs = asTrees(positive).map(globFor)
257
258
  // Workspace-wide partition, keyed by the workspace root. Populated
258
259
  // up-front by `populateGitFilesCache(..., workspaceWide: true)` when
@@ -281,8 +282,8 @@ async function resolveWorkspaceFiles(args: {
281
282
  args,
282
283
  gitFiles,
283
284
  positive,
284
- positiveGlobs,
285
- (rel) => excludeGlobs.some((g) => g.match(rel)) || ownOutput(rel),
285
+ anyTaskGlob(asTrees(positive)),
286
+ (rel) => isExcluded(rel) || ownOutput(rel),
286
287
  undecodable,
287
288
  )
288
289
  if (memoKey !== undefined) args.memo!.set(memoKey, { snapshot: gitFiles, result })
@@ -293,7 +294,7 @@ async function resolveWorkspaceFilesOver(
293
294
  args: { workspaceRoot: string; gitFilesCache?: GitFilesCache },
294
295
  gitFiles: readonly string[],
295
296
  positive: readonly string[],
296
- positiveGlobs: readonly Bun.Glob[],
297
+ isPositive: (rel: string) => boolean,
297
298
  excluded: (rel: string) => boolean,
298
299
  undecodable: ReadonlySet<string> | undefined,
299
300
  ): Promise<string[]> {
@@ -301,13 +302,13 @@ async function resolveWorkspaceFilesOver(
301
302
  // carries its own copy of the filter-over-git-set design, so the same
302
303
  // silently-folds-nothing hazard exists here — and a fix applied only to the
303
304
  // project half would pass that half's tests while leaving this one live.
304
- const unmatchedLiterals = new Set(
305
+ const unmatchedLiterals = unanswered(
305
306
  positive.map(normalizeGlob).filter(isLiteralPattern).map(stripTrailingSlash),
307
+ gitFiles,
306
308
  )
307
309
  const candidates: string[] = []
308
310
  for (const rel of gitFiles) {
309
- if (unmatchedLiterals.size > 0) settleLiterals(unmatchedLiterals, rel)
310
- if (!positiveGlobs.some((g) => g.match(rel))) continue
311
+ if (!isPositive(rel)) continue
311
312
  if (excluded(rel)) continue
312
313
  candidates.push(path.resolve(args.workspaceRoot, rel))
313
314
  }
@@ -935,11 +936,37 @@ function stripTrailingSlash(p: string): string {
935
936
  // unchanged.
936
937
  export { asTrees } from '../util/index.js'
937
938
 
938
- /** A literal is answered by the path itself or by anything under it. */
939
- function settleLiterals(unmatched: Set<string>, rel: string): void {
940
- for (const lit of unmatched) {
941
- if (rel === lit || rel.startsWith(`${lit}/`)) unmatched.delete(lit)
939
+ /**
940
+ * The literals no path in `files` answers; a literal is answered by the
941
+ * path itself or by anything under it. Git lists in order, so a binary
942
+ * search finds the answer; a listing out of code-unit order (git sorts
943
+ * bytes) can hide one from it, so only a walk says a literal is
944
+ * unanswered. Testing every literal against every file was ~1.5 ms of a
945
+ * 1,800-file all-cached run (2026-10-06).
946
+ */
947
+ function unanswered(literals: readonly string[], files: readonly string[]): Set<string> {
948
+ const left = new Set<string>()
949
+ for (const lit of literals) {
950
+ const under = `${lit}/`
951
+ const answered =
952
+ files[lowerBound(files, lit)] === lit ||
953
+ files[lowerBound(files, under)]?.startsWith(under) === true ||
954
+ files.some((rel) => rel === lit || rel.startsWith(under))
955
+ if (!answered) left.add(lit)
942
956
  }
957
+ return left
958
+ }
959
+
960
+ /** The first index whose path is not below `key`. */
961
+ function lowerBound(files: readonly string[], key: string): number {
962
+ let lo = 0
963
+ let hi = files.length
964
+ while (lo < hi) {
965
+ const mid = (lo + hi) >>> 1
966
+ if (files[mid]! < key) lo = mid + 1
967
+ else hi = mid
968
+ }
969
+ return lo
943
970
  }
944
971
 
945
972
  /**
@@ -1065,10 +1092,11 @@ function refuseOneAlternativeBrace(
1065
1092
  interface FilesPlan {
1066
1093
  positive: string[]
1067
1094
  negative: string[]
1068
- excludeGlobs: Bun.Glob[]
1095
+ isExcluded: (rel: string) => boolean
1069
1096
  ownOutput: (rel: string) => boolean
1070
1097
  positiveGlobs: Bun.Glob[]
1071
- /** The literal entries, each naming one path (see `unmatchedLiterals`). */
1098
+ isPositive: (rel: string) => boolean
1099
+ /** The literal entries, each naming one path (see `unanswered`). */
1072
1100
  literals: string[]
1073
1101
  /**
1074
1102
  * Whether a project-relative path is an input by the declaration alone:
@@ -1112,11 +1140,12 @@ function filesPlan(
1112
1140
  : {
1113
1141
  positive,
1114
1142
  negative,
1115
- excludeGlobs: [...ALWAYS_IGNORE, ...asTrees(negative)].map(globFor),
1143
+ isExcluded: anyTaskGlob([...ALWAYS_IGNORE, ...asTrees(negative)]),
1116
1144
  // A path the task's own outputs take back with `!` is no output, so it
1117
1145
  // stays an input: a tracked file under `dist` the build reads (A-44).
1118
- ownOutput: outputMatcher(ownOutputs, globFor),
1146
+ ownOutput: outputMatcher(ownOutputs),
1119
1147
  positiveGlobs: asTrees(positive).map(globFor),
1148
+ isPositive: anyTaskGlob(asTrees(positive)),
1120
1149
  literals: positive.map(normalizeGlob).filter(isLiteralPattern).map(stripTrailingSlash),
1121
1150
  verdicts: new Map<string, boolean>(),
1122
1151
  }
@@ -1149,10 +1178,7 @@ export function declaresInput(
1149
1178
  function inPlan(plan: FilesPlan, rel: string): boolean {
1150
1179
  let input = plan.verdicts.get(rel)
1151
1180
  if (input === undefined) {
1152
- input =
1153
- plan.positiveGlobs.some((g) => g.match(rel)) &&
1154
- !plan.excludeGlobs.some((g) => g.match(rel)) &&
1155
- !plan.ownOutput(rel)
1181
+ input = plan.isPositive(rel) && !plan.isExcluded(rel) && !plan.ownOutput(rel)
1156
1182
  plan.verdicts.set(rel, input)
1157
1183
  }
1158
1184
  return input
@@ -1168,13 +1194,10 @@ function workspaceMatcher(
1168
1194
  let m = workspaceMatchers.get(key)
1169
1195
  if (m !== undefined) return m
1170
1196
  const { positive, negative } = splitNegations(decl)
1171
- const positiveGlobs = asTrees(positive).map(globFor)
1172
- const excludeGlobs = [...ALWAYS_IGNORE, ...asTrees(negative)].map(globFor)
1173
- const ownOutput = outputMatcher(ownOutputs, globFor)
1174
- m = (rel) =>
1175
- positiveGlobs.some((g) => g.match(rel)) &&
1176
- !excludeGlobs.some((g) => g.match(rel)) &&
1177
- !ownOutput(rel)
1197
+ const isPositive = anyTaskGlob(asTrees(positive))
1198
+ const isExcluded = anyTaskGlob([...ALWAYS_IGNORE, ...asTrees(negative)])
1199
+ const ownOutput = outputMatcher(ownOutputs)
1200
+ m = (rel) => isPositive(rel) && !isExcluded(rel) && !ownOutput(rel)
1178
1201
  workspaceMatchers.set(key, m)
1179
1202
  return m
1180
1203
  }
@@ -1182,7 +1205,7 @@ function workspaceMatcher(
1182
1205
  async function resolveFiles(args: ResolveFilesArgs): Promise<string[]> {
1183
1206
  const plan = filesPlan(args.files, args.ownOutputs)
1184
1207
  if (plan === null) return []
1185
- const { positive, negative, excludeGlobs, ownOutput, positiveGlobs } = plan
1208
+ const { positive, negative, isExcluded, ownOutput, positiveGlobs, isPositive } = plan
1186
1209
 
1187
1210
  const nested = inNestedProject(args.projectDir, args.nestedProjectDirs)
1188
1211
 
@@ -1233,7 +1256,6 @@ async function resolveFiles(args: ResolveFilesArgs): Promise<string[]> {
1233
1256
  // an artifact built from an older version of a file the config explicitly
1234
1257
  // claims as an input. See the refusal below for why this is not simply
1235
1258
  // honoured instead.
1236
- const unmatchedLiterals = new Set(plan.literals)
1237
1259
  // First pass: glob-filter to candidate absolute paths (no I/O). Git
1238
1260
  // prints normalized relative paths, so under an absolute, normalized
1239
1261
  // project dir a join is a concatenation: `path.resolve` per file was
@@ -1249,18 +1271,15 @@ async function resolveFiles(args: ResolveFilesArgs): Promise<string[]> {
1249
1271
  const candidates: string[] = []
1250
1272
  const verdicts = plan.verdicts
1251
1273
  for (const rel of gitFiles) {
1252
- if (unmatchedLiterals.size > 0) settleLiterals(unmatchedLiterals, rel)
1253
1274
  let input = verdicts.get(rel)
1254
1275
  if (input === undefined) {
1255
- input =
1256
- positiveGlobs.some((g) => g.match(rel)) &&
1257
- !excludeGlobs.some((g) => g.match(rel)) &&
1258
- !ownOutput(rel)
1276
+ input = isPositive(rel) && !isExcluded(rel) && !ownOutput(rel)
1259
1277
  verdicts.set(rel, input)
1260
1278
  }
1261
1279
  if (!input || nested(rel)) continue
1262
1280
  candidates.push(base === undefined ? path.resolve(args.projectDir, rel) : base + rel)
1263
1281
  }
1282
+ const unmatchedLiterals = unanswered(plan.literals, gitFiles)
1264
1283
  if (unmatchedLiterals.size > 0) {
1265
1284
  await assertNoInvisibleLiteralInputs(unmatchedLiterals, args.projectDir, 'files')
1266
1285
  }
package/src/util/index.ts CHANGED
@@ -42,6 +42,7 @@ export {
42
42
  relPosix,
43
43
  splitNegations,
44
44
  staticPrefix,
45
+ anyTaskGlob,
45
46
  taskGlob,
46
47
  wholeSubtreePrefixes,
47
48
  slashBraceExpansions,
package/src/util/paths.ts CHANGED
@@ -196,8 +196,69 @@ export const EXTGLOB = /[!@+*?]\(/
196
196
  */
197
197
  export function taskGlob(pattern: string): Bun.Glob {
198
198
  // A `]` with no `[` before it is already literal to `Bun.Glob`.
199
- if (!pattern.includes('[')) return new Bun.Glob(pattern)
200
- return new Bun.Glob(pattern.replace(/(?<!\\)[[\]]/g, '\\$&'))
199
+ const source = pattern.includes('[') ? pattern.replace(/(?<!\\)[[\]]/g, '\\$&') : pattern
200
+ const glob = new Bun.Glob(source)
201
+ const re = regExpSource(pattern)
202
+ if (re === null) return glob
203
+ const compiled = new RegExp(`^${re}$`, 'u')
204
+ const native = Object.getPrototypeOf(glob) as Bun.Glob
205
+ // A path with a slash on the end names no file vx lists; `Bun.Glob` reads
206
+ // it by rules of its own (`src/**` takes `src/`, never `src`). `scan`
207
+ // stays `Bun.Glob`'s. Set on the instance, not by a subclass, so the glob
208
+ // is built by whatever `Bun.Glob` is at the call (the playground's port).
209
+ glob.match = (rel: string): boolean =>
210
+ rel.endsWith('/') ? native.match.call(glob, rel) : compiled.test(rel)
211
+ return glob
212
+ }
213
+
214
+ /**
215
+ * Whether a path matches any of `patterns`, each a task glob: one compiled
216
+ * RegExp for the lot when every pattern has one, else a glob each. An input
217
+ * file was matched against a dozen globs (its positives, the always-ignored
218
+ * list, its negatives) one native call at a time.
219
+ */
220
+ export function anyTaskGlob(patterns: readonly string[]): (rel: string) => boolean {
221
+ if (patterns.length === 0) return () => false
222
+ const sources = patterns.map(regExpSource)
223
+ const globs = patterns.map(taskGlob)
224
+ if (sources.every((r) => r !== null)) {
225
+ const re = new RegExp(`^(?:${sources.join('|')})$`, 'u')
226
+ return (rel) => (rel.endsWith('/') ? globs.some((g) => g.match(rel)) : re.test(rel))
227
+ }
228
+ return (rel) => globs.some((g) => g.match(rel))
229
+ }
230
+
231
+ const REGEXP_SYNTAX = /[\\^$.*+?()[\]{}|]/g
232
+
233
+ /**
234
+ * The RegExp source of a task glob, matching what `Bun.Glob` matches
235
+ * (`tests/util-paths.test.ts` holds the two to the same verdict on every
236
+ * shape): `*` any run within a name, `?` one character, a `**` segment any
237
+ * number of directories, a bracket itself. Null for the syntax left to
238
+ * `Bun.Glob`: a brace, an escape, a leading `!`, an empty, `.` or `..`
239
+ * segment, a `**` inside a name, or two `**` segments in a row.
240
+ */
241
+ function regExpSource(pattern: string): string | null {
242
+ if (/[\\{}]/.test(pattern) || pattern.startsWith('!')) return null
243
+ const segments = pattern.split('/')
244
+ let out = ''
245
+ for (let i = 0; i < segments.length; i++) {
246
+ const seg = segments[i]!
247
+ const last = i === segments.length - 1
248
+ if (seg === '**') {
249
+ if (segments[i - 1] === '**') return null
250
+ // A globstar is zero or more whole directories; at the end, one or
251
+ // more names (`src/**` does not match `src`).
252
+ if (segments.length === 1) out += '[^]*'
253
+ else if (last) out += '/[^]+'
254
+ else out += i === 0 ? '(?:[^]*/)?' : '/(?:[^]*/)?'
255
+ continue
256
+ }
257
+ if (seg === '' || seg === '.' || seg === '..' || seg.includes('**')) return null
258
+ if (i > 0 && segments[i - 1] !== '**') out += '/'
259
+ out += seg.replace(REGEXP_SYNTAX, (c) => (c === '*' ? '[^/]*' : c === '?' ? '[^/]' : `\\${c}`))
260
+ }
261
+ return out
201
262
  }
202
263
 
203
264
  function stripTrailingSlash(p: string): string {
@@ -263,15 +324,12 @@ export function splitNegations(globs: readonly string[]): {
263
324
  * matcher as-is would be `Bun.Glob`'s own negation, true of every OTHER
264
325
  * path (A-44).
265
326
  */
266
- export function outputMatcher(
267
- globs: readonly string[],
268
- compile: (pattern: string) => Bun.Glob = taskGlob,
269
- ): (rel: string) => boolean {
327
+ export function outputMatcher(globs: readonly string[]): (rel: string) => boolean {
270
328
  const { positive, negative } = splitNegations(globs)
271
329
  if (positive.length === 0) return () => false
272
- const pos = asTrees(positive).map(compile)
273
- const neg = asTrees(negative).map(compile)
274
- return (rel) => pos.some((g) => g.match(rel)) && !neg.some((g) => g.match(rel))
330
+ const pos = anyTaskGlob(asTrees(positive))
331
+ const neg = anyTaskGlob(asTrees(negative))
332
+ return (rel) => pos(rel) && !neg(rel)
275
333
  }
276
334
 
277
335
  /**