@vzn/vx 0.0.524 → 0.0.526

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.526",
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.526",
25
+ "@vzn/vx-linux-arm64": "0.0.526",
26
+ "@vzn/vx-darwin-x64": "0.0.526",
27
+ "@vzn/vx-darwin-arm64": "0.0.526"
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
  }
@@ -158,13 +158,20 @@ function trailingCommentStart(command: string): number {
158
158
  /**
159
159
  * The command a task runs with the args after `--` appended, shell-quoted.
160
160
  * They go before a trailing comment: appended after it, `echo args: # show`
161
- * ran without them and said nothing (item 1060).
161
+ * ran without them and said nothing (item 1060). Trailing blanks go first.
162
162
  */
163
163
  export function withForwardArgs(command: string, args: readonly string[] | undefined): string {
164
164
  if (!args || args.length === 0) return command
165
165
  const quoted = args.map(shellQuote).join(' ')
166
166
  let comment = trailingCommentStart(command)
167
- if (comment < 0) return `${command} ${quoted}`
167
+ if (comment < 0) {
168
+ // A command that ends in a newline (a template literal's closing line)
169
+ // ran the args as a command of their own: `--watch: not found`. Kept
170
+ // when an odd run of backslashes ends it: that escapes what follows.
171
+ const body = command.trimEnd()
172
+ const escaped = (/\\+$/.exec(body)?.[0].length ?? 0) % 2 === 1
173
+ return `${escaped ? command : body} ${quoted}`
174
+ }
168
175
  // A comment-only last line after a commented line: the args go before
169
176
  // the earliest comment, or they land inside it (`echo one # c\n# two`).
170
177
  for (let c = comment; c >= 0; c = trailingCommentStart(command.slice(0, c).trimEnd())) comment = c
@@ -169,17 +169,21 @@ export async function applyProjectHooks(
169
169
  * deleting each other's outputs). A violation is reported
170
170
  * against the LAST plugin that ran: usually the one whose edit made it so,
171
171
  * but an earlier plugin's edit that a later one left in place is blamed on
172
- * the later one, since nothing is checked between plugins.
172
+ * the later one, since the structure is not checked between plugins.
173
+ * Each node's task config is (`afterEach`), like the `project` stage's.
173
174
  */
174
175
  export async function applyGraphHooks(
175
176
  plugins: readonly VxPlugin[],
176
177
  nodes: Map<string, TaskNode>,
177
178
  ctx: GraphHookContext,
179
+ /** Runs after EACH plugin's edit, so a refused task config names the plugin that wrote it. */
180
+ afterEach?: (plugin: VxPlugin) => void,
178
181
  ): Promise<void> {
179
182
  let last: VxPlugin | undefined
180
183
  for (const plugin of plugins) {
181
184
  if (plugin.graph === undefined) continue
182
185
  await safe(plugin, 'graph', () => plugin.graph!(nodes, ctx))
186
+ afterEach?.(plugin)
183
187
  last = plugin
184
188
  }
185
189
  if (last === undefined) return
@@ -39,6 +39,7 @@ import {
39
39
  resolveCacheDir,
40
40
  resolveStoreRoot,
41
41
  type ProjectEntry,
42
+ validateProjectConfig,
42
43
  } from '../workspace/index.js'
43
44
  import {
44
45
  buildTaskGraph,
@@ -61,6 +62,7 @@ import {
61
62
  discoverProjects,
62
63
  gitOfDiscovery,
63
64
  graphOfDiscovery,
65
+ isDefaultBuild,
64
66
  loadProjects,
65
67
  loadWorkspacePlugins,
66
68
  type LoadedProjects,
@@ -597,12 +599,30 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
597
599
  )
598
600
  mark('git enumeration')
599
601
  if (hasHook(plugins, 'graph')) {
600
- await applyGraphHooks(plugins, nodes, {
601
- workspaceRoot,
602
- cacheDir,
603
- warn: (m) => log.status(m),
604
- requested: [...nodes.values()].filter((n) => n.requested).map((n) => n.id),
605
- })
602
+ // A hook edits each node's config in place, as `project` does, so the
603
+ // edit is held to what the loader accepts from a user: a misspelled
604
+ // `exec` field ran as an empty command and failed with no reason.
605
+ const configPaths = new Map(projectMetas.map((m) => [m.name, m.configPath]))
606
+ await applyGraphHooks(
607
+ plugins,
608
+ nodes,
609
+ {
610
+ workspaceRoot,
611
+ cacheDir,
612
+ warn: (m) => log.status(m),
613
+ requested: [...nodes.values()].filter((n) => n.requested).map((n) => n.id),
614
+ },
615
+ (plugin) => {
616
+ for (const n of nodes.values()) {
617
+ if (isDefaultBuild(n.config)) continue
618
+ const where = configPaths.get(n.projectName) ?? `${n.projectName} (no config file)`
619
+ validateProjectConfig(
620
+ { tasks: { [n.taskName]: n.config } },
621
+ `${where} (after plugin '${plugin.name}')`,
622
+ )
623
+ }
624
+ },
625
+ )
606
626
  }
607
627
  if (hasHook(plugins, 'key')) {
608
628
  await applyKeyHooks(plugins, nodes, { workspaceRoot, cacheDir, warn: (m) => log.status(m) })
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
  /**