@vzn/vx 0.0.347 → 0.0.349

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.347",
3
+ "version": "0.0.349",
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.347",
25
- "@vzn/vx-linux-arm64": "0.0.347",
26
- "@vzn/vx-darwin-x64": "0.0.347",
27
- "@vzn/vx-darwin-arm64": "0.0.347"
24
+ "@vzn/vx-linux-x64": "0.0.349",
25
+ "@vzn/vx-linux-arm64": "0.0.349",
26
+ "@vzn/vx-darwin-x64": "0.0.349",
27
+ "@vzn/vx-darwin-arm64": "0.0.349"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -35,16 +35,18 @@ type SrtModule = typeof import('@anthropic-ai/sandbox-runtime')
35
35
  export function bindableWrites(paths: readonly string[]): string[] {
36
36
  if (process.platform !== 'linux') return [...paths]
37
37
  return unique(
38
- paths.map((p) => {
39
- if (!isMountableLiteral(p)) return p
40
- try {
41
- if (statSync(p).isDirectory()) return p
42
- } catch {
43
- // Does not exist yet: `prepareOutputsForBind` creates a file for a
44
- // file-shaped grant, so treat it as one.
45
- }
46
- return path.dirname(p)
47
- }),
38
+ paths
39
+ .filter((p) => !holdsBracket(p))
40
+ .map((p) => {
41
+ if (!isMountableLiteral(p)) return p
42
+ try {
43
+ if (statSync(p).isDirectory()) return p
44
+ } catch {
45
+ // Does not exist yet: `prepareOutputsForBind` creates a file for a
46
+ // file-shaped grant, so treat it as one.
47
+ }
48
+ return path.dirname(p)
49
+ }),
48
50
  )
49
51
  }
50
52
 
@@ -188,6 +190,32 @@ export function punchWritePaths(readPath: string, writePaths: readonly string[])
188
190
  return out
189
191
  }
190
192
 
193
+ /** Grants already reported — once per process, not per spawn. */
194
+ const warnedBracket = new Set<string>()
195
+
196
+ /**
197
+ * SRT drops every Linux write path holding a bracket (it reads one as a
198
+ * glob, and a write path must be a path), and no spelling keeps it. Left
199
+ * in, the read grants were punched around a bind that never came, the
200
+ * directory vanished from the task's view, its write read "Directory
201
+ * nonexistent" with no word of the grant, and the refused write went
202
+ * unreported, judged against it. Dropped from the binds instead, and said
203
+ * once: the remedy is the deepest directory above it whose name holds none.
204
+ */
205
+ function holdsBracket(grant: string): boolean {
206
+ const at = grant.search(/[[\]]/)
207
+ if (at === -1) return false
208
+ if (!warnedBracket.has(grant)) {
209
+ warnedBracket.add(grant)
210
+ process.stderr.write(
211
+ `[vx] sandbox: the write grant ${grant} holds a bracket, and the Linux sandbox mounts no ` +
212
+ `write path that does, so a write under it is refused. Grant the directory above it ` +
213
+ `instead: ${grant.slice(0, grant.lastIndexOf(path.sep, at) + 1)}\n`,
214
+ )
215
+ }
216
+ return true
217
+ }
218
+
191
219
  /**
192
220
  * Merge the orchestrator-provided baseline (declared inputs / outputs /
193
221
  * workspace-root anchor) with the user's resolved sandbox block to
@@ -37,7 +37,7 @@ import {
37
37
  import { isGroupTask, RestoreDemoted, type TaskNode, type TaskOutcome } from '../graph/index.js'
38
38
  import { killGraceMs, maskedEmitter, printable, relPosix, secretMask, span } from '../util/index.js'
39
39
  import { SIGNAL_SHUTDOWN_GRACE_MS } from './signals.js'
40
- import { executorLabel } from './plugin-host.js'
40
+ import { executorLabel, nameExecutorFailure } from './plugin-host.js'
41
41
  import {
42
42
  mayWriteFingerprint,
43
43
  type Placeholder,
@@ -791,7 +791,8 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
791
791
  assertExecuteResult(args.executor.name, node.id, r)
792
792
  return r
793
793
  })
794
- .catch(async (err: unknown) => {
794
+ .catch(async (raw: unknown) => {
795
+ const err = nameExecutorFailure(args.executor, raw)
795
796
  const message = err instanceof Error ? err.message : String(err)
796
797
  log.taskStderr(node, `${message}\n`)
797
798
  await sweepPlaceholders(placeholders)
@@ -439,6 +439,23 @@ export async function resolveCache(
439
439
  /** The plugin each resolved executor came from, so a hook of it that throws is named. */
440
440
  const executorPlugin = new WeakMap<TaskExecutor, string>()
441
441
 
442
+ /**
443
+ * A plugin executor's throw from `execute`, named as every other hook's
444
+ * is: `plugin 'p' (executor 'e') failed in execute: …`. It read `internal
445
+ * error in <task>: pool down`, naming neither (C-63). The error itself is
446
+ * kept, its class, cause and code with it, so the scheduler still tells a
447
+ * refusal from a bug; the floor's own throw is vx's and stays as it is.
448
+ */
449
+ export function nameExecutorFailure(executor: TaskExecutor, err: unknown): unknown {
450
+ if (!(err instanceof Error) || !executorPlugin.has(executor)) return err
451
+ try {
452
+ err.message = `${executorLabel(executor)} failed in execute: ${err.message}`
453
+ } catch {
454
+ // A frozen error keeps its own words.
455
+ }
456
+ return err
457
+ }
458
+
442
459
  /** How a message names `executor`: its plugin and its own name, or its name alone (the floor). */
443
460
  export function executorLabel(executor: TaskExecutor): string {
444
461
  const plugin = executorPlugin.get(executor)
@@ -107,7 +107,41 @@ const OWN_COMMANDS: Readonly<Record<string, ReadonlySet<string>>> = {
107
107
  * `enablePrePostScripts: false` in `pnpm-workspace.yaml` (D-33); Bun and
108
108
  * Yarn 1 run them whatever those say. No manager found is npm's default.
109
109
  */
110
- function runsScriptHooks(dir: string, memo: Map<string, string | null>): string | null {
110
+ function runsScriptHooks(dir: string, memo: Map<string, Owner>): string | null {
111
+ const { manager, at } = ownerOf(dir, memo)
112
+ const read = (f: string): string | undefined => {
113
+ try {
114
+ return readFileSync(path.join(at, f), 'utf8')
115
+ } catch {
116
+ return undefined
117
+ }
118
+ }
119
+ const npmrc = read('.npmrc') ?? ''
120
+ const set = (key: string, value: string): boolean =>
121
+ new RegExp(`^\\s*${key}\\s*=\\s*${value}\\s*$`, 'm').test(npmrc)
122
+ const runs =
123
+ manager === 'berry'
124
+ ? false
125
+ : manager === 'npm'
126
+ ? !set('ignore-scripts', 'true')
127
+ : manager === 'pnpm'
128
+ ? !set('enable-pre-post-scripts', 'false') &&
129
+ !/^enablePrePostScripts:\s*false\s*$/m.test(read('pnpm-workspace.yaml') ?? '')
130
+ : true
131
+ return runs ? manager : null
132
+ }
133
+
134
+ /** The package manager that owns a directory, and the directory that says so. */
135
+ interface Owner {
136
+ manager: string
137
+ at: string
138
+ }
139
+
140
+ /**
141
+ * The nearest `packageManager` field or lockfile from `dir` up: `berry` for
142
+ * Yarn 2+, `yarn` for Yarn 1, else the manager's name; none found is npm.
143
+ */
144
+ function ownerOf(dir: string, memo: Map<string, Owner>): Owner {
111
145
  const known = memo.get(dir)
112
146
  if (known !== undefined) return known
113
147
  const read = (f: string): string | undefined => {
@@ -131,27 +165,26 @@ function runsScriptHooks(dir: string, memo: Map<string, string | null>): string
131
165
  else if (existsSync(path.join(dir, 'package-lock.json'))) manager = 'npm'
132
166
  else if (['bun.lock', 'bun.lockb'].some((f) => existsSync(path.join(dir, f)))) manager = 'bun'
133
167
  }
134
- let answer: string | null
135
- if (manager === undefined) {
136
- const up = path.dirname(dir)
137
- answer = up === dir ? 'npm' : runsScriptHooks(up, memo)
138
- } else {
139
- const npmrc = read('.npmrc') ?? ''
140
- const set = (key: string, value: string): boolean =>
141
- new RegExp(`^\\s*${key}\\s*=\\s*${value}\\s*$`, 'm').test(npmrc)
142
- const runs =
143
- manager === 'berry'
144
- ? false
145
- : manager === 'npm'
146
- ? !set('ignore-scripts', 'true')
147
- : manager === 'pnpm'
148
- ? !set('enable-pre-post-scripts', 'false') &&
149
- !/^enablePrePostScripts:\s*false\s*$/m.test(read('pnpm-workspace.yaml') ?? '')
150
- : true
151
- answer = runs ? manager : null
152
- }
153
- memo.set(dir, answer)
154
- return answer
168
+ const up = path.dirname(dir)
169
+ const owner =
170
+ manager !== undefined
171
+ ? { manager, at: dir }
172
+ : up === dir
173
+ ? { manager: 'npm', at: dir }
174
+ : ownerOf(up, memo)
175
+ memo.set(dir, owner)
176
+ return owner
177
+ }
178
+
179
+ /**
180
+ * Yarn 2+ runs a script in its own shell, where `run <script>` is `yarn
181
+ * run <script>`; vx's shell has no `run`, and 23 of berry's scripts
182
+ * (`run build:zip:worker`, `run test:unit packages/…`) mapped to tasks
183
+ * that failed "command not found" (D-92). Spelled out, at the head of
184
+ * each `&&` / `||` / `;` / `|` segment.
185
+ */
186
+ function berryRun(command: string): string {
187
+ return command.replace(/(^|&&|\|\||[;|(])(\s*)run(?=\s)/g, '$1$2yarn run')
155
188
  }
156
189
 
157
190
  const MANIFEST_IMPORT = "import pkg from './package.json' with { type: 'json' }"
@@ -354,7 +387,7 @@ export function migrateScripts(
354
387
  outsideDir?: string,
355
388
  ): MigrationPlan {
356
389
  const projects: GeneratedProject[] = []
357
- const hookMemo = new Map<string, string | null>()
390
+ const hookMemo = new Map<string, Owner>()
358
391
  // The workspace root among members: many of its scripts run the
359
392
  // workspace (`npm run build --workspaces`, `pnpm -r build`), and a root
360
393
  // task made of one ran every member's build again under `--all`. Since
@@ -454,6 +487,7 @@ export function migrateScripts(
454
487
  const scripts = meta === rootMeta ? rootScripts(meta) : scriptsOf(meta)
455
488
  const hooksBy = runsScriptHooks(meta.dir, hookMemo)
456
489
  const runsHooks = hooksBy !== null
490
+ const berry = ownerOf(meta.dir, hookMemo).manager === 'berry'
457
491
  const runnable = Object.keys(scripts).filter(
458
492
  (n) => typeof scripts[n] === 'string' && scripts[n] !== '',
459
493
  )
@@ -482,7 +516,7 @@ export function migrateScripts(
482
516
  if (!isTask(name)) continue
483
517
 
484
518
  const todos: string[] = []
485
- const own = scripts[name] as string
519
+ const own = berry ? berryRun(scripts[name] as string) : (scripts[name] as string)
486
520
  const delegate = delegatedScript(own)
487
521
  // npm lifecycle hooks (`prepack`, `prepublishOnly`, …) belong to the
488
522
  // package manager and never ride inside a task — `pack` stays alone.
@@ -81,12 +81,57 @@ const BUILD_OUTPUTS: readonly [RegExp, readonly string[]][] = [
81
81
  [/\s(?:storybook build|build-storybook)\s/, ['storybook-static/**']],
82
82
  ]
83
83
 
84
+ /** A directory a TODO may name: relative, plain, no hidden or tool dir. */
85
+ const OUTPUT_DIR = /^(?:\.\/)?((?![./])[\w@-][\w@./-]*?)\/?$/
86
+
87
+ /**
88
+ * The directory the command says it writes, when no tool above names one:
89
+ * an out-dir flag (`tsc --outDir lib`, `babel -d lib`), else the
90
+ * directories it cleans first (`del-cli distribution`, `rimraf dist
91
+ * types`). ky's build writes `distribution/`, and the TODO said `dist/**`
92
+ * (D-90). A file (`tsconfig.tsbuildinfo`) and `node_modules` are no output.
93
+ */
94
+ function namedOutputs(command: string): string[] | undefined {
95
+ const dir = (w: string | undefined): string | undefined => {
96
+ const m = w === undefined ? null : OUTPUT_DIR.exec(w.replace(/^(["'])(.*)\1$/, '$2'))
97
+ if (m === null) return undefined
98
+ const d = m[1]!
99
+ return /\.[a-z]+$/i.test(d) || d.split('/').includes('node_modules') ? undefined : d
100
+ }
101
+ const flag = /\s(?:--outDir|--out-dir|--outdir|-d)[\s=](\S+)/.exec(` ${command} `)
102
+ const out = dir(flag?.[1])
103
+ if (out !== undefined) return [`${out}/**`]
104
+ for (const segment of command.split(/[;&|()]+/)) {
105
+ const words = segment.trim().split(/\s+/)
106
+ const at =
107
+ words[0] === 'rimraf' || words[0] === 'del-cli' || words[0] === 'del'
108
+ ? 1
109
+ : words[0] === 'rm' || (words[0] === 'shx' && words[1] === 'rm')
110
+ ? words.indexOf('rm') + 1
111
+ : -1
112
+ if (at < 0) continue
113
+ const args = words.slice(at).filter((w) => !w.startsWith('-'))
114
+ // A dir the command makes again (kit's `rm -rf ./missing_ids && mkdir
115
+ // ./missing_ids && vite build`) is a scratch dir it prepares, not output.
116
+ const made = new Set([...command.matchAll(/\bmkdir\s+(?:-p\s+)?(\S+)/g)].map((m) => dir(m[1])))
117
+ const dirs = args
118
+ .map(dir)
119
+ .filter((d): d is string => d !== undefined)
120
+ .filter((d) => !made.has(d))
121
+ // Every cleaned path a directory vx can name, or the guess is not this.
122
+ return dirs.length > 0 && dirs.length === args.filter((w) => !/\.[a-z]+$/i.test(w)).length
123
+ ? dirs.map((d) => `${d}/**`)
124
+ : undefined
125
+ }
126
+ return undefined
127
+ }
128
+
84
129
  /** The cache block the task that builds `command` should declare, as a TODO on it. */
85
130
  export function cacheTodo(command: string): string {
86
131
  // Padded, separators as spaces: `next build && …` and `(next build)` match.
87
132
  const words = ` ${command.replace(/[;&|()]/g, ' ')} `
88
133
  const hit = BUILD_OUTPUTS.find(([re]) => re.test(words))
89
- const outputs = (hit?.[1] ?? ['dist/**']).map((o) => `'${o}'`).join(', ')
134
+ const outputs = (hit?.[1] ?? namedOutputs(command) ?? ['dist/**']).map((o) => `'${o}'`).join(', ')
90
135
  return `cache: add \`cache: { inputs: { files: ['src/**'] }, outputs: { files: [${outputs}] } }\` with this package's real inputs and outputs — without it the task always runs and every file here, what it writes included, folds into the key its dependents fold; a block with EMPTY outputs would be a cached no-op, not an uncached task`
91
136
  }
92
137