@vzn/vx 0.0.341 → 0.0.342

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/README.md CHANGED
@@ -100,7 +100,7 @@ key by key: [Turbo and Nx config support](https://vznjs.github.io/vx/compare/tur
100
100
  ## Install
101
101
 
102
102
  ```sh
103
- npm install -D @vzn/vx # or: pnpm add -D · yarn add -D · bun add -d
103
+ npm install -D @vzn/vx # or: pnpm add -D -w · yarn add -D (-W on Yarn 1) · bun add -d
104
104
  ```
105
105
 
106
106
  The package ships a prebuilt binary for Linux and macOS, x64 and arm64
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.341",
3
+ "version": "0.0.342",
4
4
  "description": "A fast, correct 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.341",
25
- "@vzn/vx-linux-arm64": "0.0.341",
26
- "@vzn/vx-darwin-x64": "0.0.341",
27
- "@vzn/vx-darwin-arm64": "0.0.341"
24
+ "@vzn/vx-linux-x64": "0.0.342",
25
+ "@vzn/vx-linux-arm64": "0.0.342",
26
+ "@vzn/vx-darwin-x64": "0.0.342",
27
+ "@vzn/vx-darwin-arm64": "0.0.342"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -784,6 +784,8 @@ export function droppedOutputLine(dropped: number): string {
784
784
  )
785
785
  }
786
786
 
787
+ const isHighSurrogate = (code: number): boolean => code >= 0xd800 && code <= 0xdbff
788
+
787
789
  /**
788
790
  * A head-and-tail accumulator: the head fills once, the tail is a ring
789
791
  * of chunks trimmed from the front, so memory is bounded by the two
@@ -797,11 +799,14 @@ class BoundedCapture {
797
799
 
798
800
  push(chunk: string): void {
799
801
  if (this.head.length < CAPTURE_HEAD_CHARS) {
800
- const room = CAPTURE_HEAD_CHARS - this.head.length
802
+ let room = CAPTURE_HEAD_CHARS - this.head.length
801
803
  if (chunk.length <= room) {
802
804
  this.head += chunk
803
805
  return
804
806
  }
807
+ // Never between a surrogate pair's halves: the bounds count UTF-16
808
+ // units, and a halved character read U+FFFD in the replay.
809
+ if (isHighSurrogate(chunk.charCodeAt(room - 1))) room--
805
810
  this.head += chunk.slice(0, room)
806
811
  chunk = chunk.slice(room)
807
812
  }
@@ -815,9 +820,10 @@ class BoundedCapture {
815
820
  this.tailLen -= first.length
816
821
  this.dropped += first.length
817
822
  } else {
818
- this.tail[0] = first.slice(excess)
819
- this.tailLen -= excess
820
- this.dropped += excess
823
+ const cut = isHighSurrogate(first.charCodeAt(excess - 1)) ? excess + 1 : excess
824
+ this.tail[0] = first.slice(cut)
825
+ this.tailLen -= cut
826
+ this.dropped += cut
821
827
  }
822
828
  }
823
829
  }
@@ -61,7 +61,7 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
61
61
  // The directories the positive globs cover whole. A `!` entry inside one
62
62
  // leaves the snapshot sound: a file added or removed there still bumps a
63
63
  // recorded directory, and the walk that follows applies the `!` (A-46).
64
- const dirPrefixes = wholeSubtreePrefixes(splitNegations(outputs).positive)
64
+ const dirPrefixes = dirPrefixesOf(outputs)
65
65
  const cleanArgs = {
66
66
  projectDir: node.projectDir,
67
67
  outputs,
@@ -133,52 +133,54 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
133
133
  setKnown = covers && (await args.cache.outputDirsCurrent!(node.projectDir, dirRows))
134
134
  endDirs()
135
135
  }
136
- let actualAbs: string[]
137
- let actualWsAbs: string[]
136
+ let treeMatches: boolean
138
137
  if (setKnown) {
139
- actualAbs = projExpected.map((e) => path.join(node.projectDir, e.path))
140
- actualWsAbs = []
138
+ // The snapshot proved the tree holds exactly the entry's project
139
+ // rows (one per path, the table's key) and no workspace output is
140
+ // declared, so the comparisons below hold by construction: mapping
141
+ // each row to a path and back was ~5 ms per 1,000 warm hits.
142
+ treeMatches = wsExpected.length === 0
141
143
  } else {
142
144
  const endGlob = span('output glob')
143
- actualAbs = await resolveOutputs({
145
+ const actualAbs = await resolveOutputs({
144
146
  projectDir: node.projectDir,
145
147
  outputs,
146
148
  nestedProjectDirs: args.nestedProjectDirs,
147
149
  })
148
- actualWsAbs = await resolveWorkspaceOutputs({
150
+ const actualWsAbs = await resolveWorkspaceOutputs({
149
151
  workspaceRoot: args.workspaceRoot,
150
152
  outputs: wsOutputs,
151
153
  })
152
154
  endGlob()
155
+ const setsMatch = (
156
+ actual: readonly string[],
157
+ exp: ReadonlyArray<{ path: string }>,
158
+ ): boolean => {
159
+ const expSet = new Set(exp.map((e) => e.path))
160
+ return actual.length === expSet.size && actual.every((r) => expSet.has(r))
161
+ }
162
+ // A stray a dependant's glob could have added is not a stray; a path
163
+ // this entry recorded is never dropped, even where that glob covers
164
+ // the whole tree (strapi's shape: both on `dist`).
165
+ const expectedRels = new Set(projExpected.map((e) => e.path))
166
+ const expectedWsRels = new Set(wsExpected.map((e) => e.path))
167
+ const actualRels = actualAbs
168
+ .map((p) => path.relative(node.projectDir, p).split(path.sep).join('/'))
169
+ .filter((rel) => expectedRels.has(rel) || !isAddition(rel))
170
+ const actualWsRels = actualWsAbs
171
+ .map((p) => path.relative(args.workspaceRoot, p).split(path.sep).join('/'))
172
+ .filter((rel) => expectedWsRels.has(rel) || !isAddition(rel))
173
+ const rowsPresent = (
174
+ actual: readonly string[],
175
+ exp: ReadonlyArray<{ path: string }>,
176
+ ): boolean => {
177
+ const have = new Set(actual)
178
+ return exp.every((e) => have.has(e.path))
179
+ }
180
+ treeMatches = additive
181
+ ? rowsPresent(actualRels, projExpected) && rowsPresent(actualWsRels, wsExpected)
182
+ : setsMatch(actualRels, projExpected) && setsMatch(actualWsRels, wsExpected)
153
183
  }
154
- const setsMatch = (
155
- actual: readonly string[],
156
- exp: ReadonlyArray<{ path: string }>,
157
- ): boolean => {
158
- const expSet = new Set(exp.map((e) => e.path))
159
- return actual.length === expSet.size && actual.every((r) => expSet.has(r))
160
- }
161
- // A stray a dependant's glob could have added is not a stray; a path
162
- // this entry recorded is never dropped, even where that glob covers
163
- // the whole tree (strapi's shape: both on `dist`).
164
- const expectedRels = new Set(projExpected.map((e) => e.path))
165
- const expectedWsRels = new Set(wsExpected.map((e) => e.path))
166
- const actualRels = actualAbs
167
- .map((p) => path.relative(node.projectDir, p).split(path.sep).join('/'))
168
- .filter((rel) => expectedRels.has(rel) || !isAddition(rel))
169
- const actualWsRels = actualWsAbs
170
- .map((p) => path.relative(args.workspaceRoot, p).split(path.sep).join('/'))
171
- .filter((rel) => expectedWsRels.has(rel) || !isAddition(rel))
172
- const rowsPresent = (
173
- actual: readonly string[],
174
- exp: ReadonlyArray<{ path: string }>,
175
- ): boolean => {
176
- const have = new Set(actual)
177
- return exp.every((e) => have.has(e.path))
178
- }
179
- const treeMatches = additive
180
- ? rowsPresent(actualRels, projExpected) && rowsPresent(actualWsRels, wsExpected)
181
- : setsMatch(actualRels, projExpected) && setsMatch(actualWsRels, wsExpected)
182
184
  if (treeMatches) {
183
185
  const endStat = span('output stat')
184
186
  skipRestore =
@@ -307,3 +309,21 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
307
309
  wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
308
310
  }
309
311
  }
312
+
313
+ /**
314
+ * The whole-subtree directories of a task's output globs, memoised by the
315
+ * list: a function of the declared globs alone, and a workspace declares a
316
+ * handful of output lists over thousands of tasks (~5 ms of a 1,000-hit
317
+ * warm run, unmemoised). Callers only read the result.
318
+ */
319
+ function dirPrefixesOf(outputs: readonly string[]): readonly string[] | null {
320
+ const key = JSON.stringify(outputs)
321
+ let prefixes = prefixMemo.get(key)
322
+ if (prefixes === undefined) {
323
+ prefixes = wholeSubtreePrefixes(splitNegations(outputs).positive)
324
+ prefixMemo.set(key, prefixes)
325
+ }
326
+ return prefixes
327
+ }
328
+
329
+ const prefixMemo = new Map<string, readonly string[] | null>()
@@ -219,12 +219,29 @@ export async function run(options: RunOptions): Promise<RunSummary> {
219
219
  // it twice (item 635). Otherwise a fresh internal bus.
220
220
  const bus = options.bus ?? createEventBus()
221
221
  const unsubscribeTerminal = bus.subscribe(terminalSubscriber(sink))
222
- try {
223
- return await runOnBus(options, bus, colors, () => terminal?.failureRecap() ?? [])
224
- } finally {
222
+ const detach = (): void => {
225
223
  unsubscribeTerminal()
226
224
  terminal?.settle()
227
225
  }
226
+ let held = false
227
+ try {
228
+ const summary = await runOnBus(options, bus, colors, () => terminal?.failureRecap() ?? [])
229
+ if (summary.persistent === undefined) return summary
230
+ // Servers handed back still running still write: `vx watch dev` printed
231
+ // none of its server's log while it idled, the renderer gone with this
232
+ // return (C-57). It stays until the caller stops them.
233
+ held = true
234
+ const { stop } = summary.persistent
235
+ return {
236
+ ...summary,
237
+ persistent: {
238
+ ...summary.persistent,
239
+ stop: (signal) => stop(signal).finally(detach),
240
+ },
241
+ }
242
+ } finally {
243
+ if (!held) detach()
244
+ }
228
245
  }
229
246
 
230
247
  async function runOnBus(
@@ -419,9 +419,12 @@ export function formatSkippedSection(outcomes: readonly TaskOutcome[]): string[]
419
419
  list.push(o.node.id)
420
420
  groups.set(cause, list)
421
421
  }
422
+ // "Blocked upstream" only when every skip was: a fail-fast skip had no
423
+ // blocker, and the header claimed one over its own cause line (C-55).
424
+ const blocked = skipped.every((o) => o.blockedBy !== undefined) ? ' \u2014 blocked upstream' : ''
422
425
  const lines = [
423
426
  '',
424
- ` Skipped: ${skipped.length} task${skipped.length === 1 ? '' : 's'} never started \u2014 blocked upstream`,
427
+ ` Skipped: ${skipped.length} task${skipped.length === 1 ? '' : 's'} never started${blocked}`,
425
428
  ]
426
429
  const NAMES = 8
427
430
  for (const [cause, ids] of [...groups].sort((a, b) => b[1].length - a[1].length)) {
@@ -49,6 +49,8 @@ export interface ParsedFilter {
49
49
  sinceViaDeps?: true
50
50
  /** A path form carrying a glob (`./packages/*`): matched over the root-relative project dir. */
51
51
  pathGlob?: Bun.Glob
52
+ /** `./a/**`'s `a`: a trailing `**` matches zero dirs, and `Bun.Glob` does not (D-84). */
53
+ pathGlobBase?: Bun.Glob
52
54
  /** The workspace root `pathGlob` is relative to. */
53
55
  pathRoot?: string
54
56
  /** `//`: the project at `matcher` itself, never the ones under it. */
@@ -142,6 +144,7 @@ export function parseFilter(raw: string, workspaceRoot: string): ParsedFilter {
142
144
  let isPath = false
143
145
  let matcher = s
144
146
  let pathGlob: Bun.Glob | undefined
147
+ let pathGlobBase: Bun.Glob | undefined
145
148
  const pathForm =
146
149
  s.startsWith('./') || s === '.'
147
150
  ? s
@@ -157,7 +160,9 @@ export function parseFilter(raw: string, workspaceRoot: string): ParsedFilter {
157
160
  // A member glob, not a task glob: `Bun.Glob`'s alphabet, the class included.
158
161
  if (BUN_GLOB_WILDCARDS.test(pathForm)) {
159
162
  const rel = path.relative(workspaceRoot, matcher).split(path.sep).join('/')
160
- pathGlob = new Bun.Glob(rel.replace(/\/+$/, ''))
163
+ const glob = rel.replace(/\/+$/, '')
164
+ pathGlob = new Bun.Glob(glob)
165
+ if (glob.endsWith('/**')) pathGlobBase = new Bun.Glob(glob.slice(0, -3))
161
166
  }
162
167
  }
163
168
 
@@ -171,6 +176,7 @@ export function parseFilter(raw: string, workspaceRoot: string): ParsedFilter {
171
176
  isPath,
172
177
  matcher,
173
178
  ...(pathGlob !== undefined ? { pathGlob, pathRoot: workspaceRoot } : {}),
179
+ ...(pathGlobBase !== undefined ? { pathGlobBase } : {}),
174
180
  ...(gitSince !== undefined ? { gitSince } : {}),
175
181
  ...(sinceViaDeps ? { sinceViaDeps: true as const } : {}),
176
182
  }
@@ -221,13 +227,14 @@ function matchSelector(filter: ParsedFilter, projects: ProjectMeta[]): string[]
221
227
  if (filter.pathGlob !== undefined) {
222
228
  // The glob is matched against the project's own dir, as pnpm and
223
229
  // Turbo do: `./packages/*` is the packages directly under `packages`,
224
- // `./packages/**` reaches the nested ones too.
230
+ // `./packages/**` reaches the nested ones too, and `./packages/kit/**`
231
+ // kit itself (kit's `check`: pnpm and Turbo read `**` as zero dirs too).
225
232
  for (const p of projects) {
226
233
  const rel = path
227
234
  .relative(filter.pathRoot ?? '', p.dir)
228
235
  .split(path.sep)
229
236
  .join('/')
230
- if (filter.pathGlob.match(rel)) out.push(p.name)
237
+ if (filter.pathGlob.match(rel) || filter.pathGlobBase?.match(rel) === true) out.push(p.name)
231
238
  }
232
239
  }
233
240
  return out
@@ -253,7 +253,7 @@ function upstreamBuildOnWorker(tasks: GeneratedTask[]): void {
253
253
  * task again beside the member's own (D-45).
254
254
  */
255
255
  const RUNS_MEMBERS =
256
- /(^|[\s;&|(])(turbo|nx|lerna|ultra|wireit|nps|moon|rush|vx)(\s|$)|\bworkspaces?\s+(foreach|run)\b|\byarn\s+workspace\s|\bvp\s+run\s|\bcd\s/
256
+ /(^|[\s;&|(])(turbo|nx|lerna|ultra|wireit|nps|moon|rush|vx)(\s|$)|\bworkspaces?\s+(foreach|run)\b|\byarn\s+workspace\s|\bvp\s+run\s/
257
257
 
258
258
  /** A workspace flag, read only where a package manager takes it (`pmRunsMembers`). */
259
259
  const MEMBER_FLAG =
@@ -263,14 +263,15 @@ const MEMBER_FLAG =
263
263
  * The workspace flags are a package manager's: on the program it runs they
264
264
  * mean something else, and berry's `bench` (`yarn node -r ./setup.ts …`,
265
265
  * node's `--require`) was left out as running the members (D-81). With no
266
- * manager named, a `run` verb is one.
266
+ * manager named, `node <bin> run` is one (npm/cli's own npm), and not
267
+ * `docker run -w` (D-83).
267
268
  */
268
269
  function pmRunsMembers(script: string): boolean {
269
270
  for (const segment of script.split(/&&|\|\||[;|()]/)) {
270
271
  const words = segment.trim().split(/\s+/)
271
272
  let i = words.findIndex((w) => /^(pnpm|npm|yarn|bun)$/.test(w))
272
273
  // npm/cli runs itself: `node . run test --workspaces`.
273
- if (i < 0) i = words.indexOf('run')
274
+ if (i < 0) i = words.findIndex((w, j) => w === 'run' && words[j - 2] === 'node')
274
275
  if (i < 0) continue
275
276
  // `exec`, `dlx` and `x` take the manager's flags up to the program name.
276
277
  let program = false
@@ -285,6 +286,25 @@ function pmRunsMembers(script: string): boolean {
285
286
  return false
286
287
  }
287
288
 
289
+ const CD = /(?:^|[\s;&|(])cd\s+("[^"]*"|'[^']*'|[^\s;&|()]+)/g
290
+
291
+ /**
292
+ * A `cd` into a member's directory, or one holding members, runs that
293
+ * member's work (kit's `cd packages/kit && vitest run`); bun's
294
+ * `typecheck` (`cd test && bun run typecheck`) enters no member and was
295
+ * left out (D-83). A target vx cannot read (`$DIR`, `~`) still counts.
296
+ */
297
+ function cdsToMembers(script: string, rootDir: string, memberDirs: readonly string[]): boolean {
298
+ for (const m of script.matchAll(CD)) {
299
+ const target = m[1]!.replace(/^(["'])(.*)\1$/, '$2')
300
+ if (/[$`~*?]/.test(target) || target === '-') return true
301
+ const dir = path.resolve(rootDir, target)
302
+ const inside = (a: string, b: string): boolean => a === b || a.startsWith(b + path.sep)
303
+ if (memberDirs.some((d) => inside(d, dir) || inside(dir, d))) return true
304
+ }
305
+ return false
306
+ }
307
+
288
308
  /** A package manager running a script by name: `pnpm build`, `npm run x`, `bun run x`. */
289
309
  const RUNS_SCRIPT =
290
310
  /(?:^|[\s;&|(])(?:pnpm|npm|yarn|bun)\s+(?:run(?:-script)?\s+)?([^\s;&|()'"-][^\s;&|()'"]*)/g
@@ -294,12 +314,21 @@ const RUNS_SCRIPT =
294
314
  * scripts: vite's `ci-docs` (`pnpm build && pnpm docs-build`) runs each
295
315
  * member's build through the root's own `build` (`pnpm -r … run build`).
296
316
  */
297
- function runningMembers(scripts: Readonly<Record<string, unknown>>): Set<string> {
317
+ function runningMembers(
318
+ scripts: Readonly<Record<string, unknown>>,
319
+ rootDir: string,
320
+ memberDirs: readonly string[],
321
+ ): Set<string> {
298
322
  const text = Object.entries(scripts).filter(
299
323
  (e): e is [string, string] => typeof e[1] === 'string',
300
324
  )
301
325
  const out = new Set(
302
- text.filter(([, v]) => RUNS_MEMBERS.test(` ${v}`) || pmRunsMembers(v)).map(([n]) => n),
326
+ text
327
+ .filter(
328
+ ([, v]) =>
329
+ RUNS_MEMBERS.test(` ${v}`) || pmRunsMembers(v) || cdsToMembers(v, rootDir, memberDirs),
330
+ )
331
+ .map(([n]) => n),
303
332
  )
304
333
  for (let grew = true; grew;) {
305
334
  grew = false
@@ -358,7 +387,11 @@ export function migrateScripts(
358
387
  )
359
388
  const rootScripts = (meta: ProjectMeta): Record<string, unknown> => {
360
389
  const scripts = scriptsOf(meta)
361
- const runs = runningMembers(scripts)
390
+ const runs = runningMembers(
391
+ scripts,
392
+ meta.dir,
393
+ metas.filter((m) => m !== root).map((m) => m.dir),
394
+ )
362
395
  return Object.fromEntries(
363
396
  Object.entries(scripts).filter(
364
397
  ([n, v]) => typeof v === 'string' && !memberTasks.has(n) && !runs.has(n),