@vzn/vx 0.0.594 → 0.0.596

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.
Files changed (80) hide show
  1. package/README.md +1 -1
  2. package/package.json +5 -5
  3. package/src/bin.ts +26 -11
  4. package/src/cache/archive.ts +2 -2
  5. package/src/cache/cache.ts +235 -174
  6. package/src/cache/chained-cache.ts +25 -7
  7. package/src/cache/config-evals.ts +19 -4
  8. package/src/cache/git-inputs.ts +19 -8
  9. package/src/cache/index.ts +1 -0
  10. package/src/cache/inputs.ts +122 -49
  11. package/src/cache/layer.ts +7 -5
  12. package/src/cache/layered-cache.ts +25 -2
  13. package/src/cache/policy.ts +5 -0
  14. package/src/cache/tar-stream.ts +1 -1
  15. package/src/cache/zstd.ts +10 -4
  16. package/src/cli/cache.ts +11 -15
  17. package/src/cli/completions.ts +42 -6
  18. package/src/cli/foreign-flags.ts +26 -5
  19. package/src/cli/help.ts +5 -3
  20. package/src/cli/init.ts +1 -1
  21. package/src/cli/last.ts +4 -1
  22. package/src/cli/plan-format.ts +9 -0
  23. package/src/cli/plugin-templates.ts +2 -2
  24. package/src/cli/run.ts +60 -42
  25. package/src/cli/select.ts +30 -3
  26. package/src/cli/show.ts +42 -3
  27. package/src/cli/watch-filter.ts +77 -13
  28. package/src/cli/watch-fs.ts +15 -7
  29. package/src/cli/watch-judge.ts +16 -9
  30. package/src/cli/watch.ts +22 -15
  31. package/src/config.ts +26 -26
  32. package/src/exec/env.ts +5 -0
  33. package/src/exec/index.ts +1 -0
  34. package/src/exec/proc-sample.ts +6 -2
  35. package/src/exec/runner.ts +98 -40
  36. package/src/exec/sandbox-paths.ts +12 -6
  37. package/src/exec/sandbox-runtime.ts +136 -44
  38. package/src/exec/sandbox-violations.ts +58 -11
  39. package/src/graph/index.ts +1 -0
  40. package/src/graph/scheduler.ts +29 -13
  41. package/src/graph/task-graph.ts +134 -25
  42. package/src/index.ts +6 -0
  43. package/src/orchestrator/admission.ts +12 -3
  44. package/src/orchestrator/events.ts +10 -1
  45. package/src/orchestrator/execute-task.ts +65 -20
  46. package/src/orchestrator/failure-mode.ts +8 -2
  47. package/src/orchestrator/framed-output.ts +3 -3
  48. package/src/orchestrator/hit-restore.ts +6 -3
  49. package/src/orchestrator/local-shortcircuit.ts +4 -1
  50. package/src/orchestrator/lockfile-claim.ts +43 -46
  51. package/src/orchestrator/logger.ts +11 -6
  52. package/src/orchestrator/metrics.ts +19 -8
  53. package/src/orchestrator/miss-save.ts +2 -2
  54. package/src/orchestrator/options.ts +7 -0
  55. package/src/orchestrator/plan.ts +28 -4
  56. package/src/orchestrator/plugin-host.ts +25 -3
  57. package/src/orchestrator/plugin.ts +39 -17
  58. package/src/orchestrator/prepare.ts +13 -4
  59. package/src/orchestrator/run-artifacts.ts +2 -1
  60. package/src/orchestrator/run.ts +72 -12
  61. package/src/orchestrator/sandbox-request.ts +65 -9
  62. package/src/orchestrator/save-lane.ts +5 -5
  63. package/src/orchestrator/signals.ts +5 -2
  64. package/src/orchestrator/summary.ts +16 -5
  65. package/src/orchestrator/telemetry.ts +16 -7
  66. package/src/util/index.ts +7 -1
  67. package/src/util/paths.ts +23 -3
  68. package/src/util/secret-mask.ts +36 -0
  69. package/src/util/settle.ts +15 -0
  70. package/src/workspace/affected.ts +30 -8
  71. package/src/workspace/config-cache.ts +25 -4
  72. package/src/workspace/config-imports.ts +8 -6
  73. package/src/workspace/config-schema.ts +74 -9
  74. package/src/workspace/filter.ts +19 -0
  75. package/src/workspace/migrate-scripts.ts +5 -3
  76. package/src/workspace/migration.ts +1 -1
  77. package/src/workspace/package-graph.ts +15 -3
  78. package/src/workspace/project-loader.ts +76 -2
  79. package/src/workspace/repo-id.ts +42 -4
  80. package/src/workspace/workspace.ts +98 -20
@@ -13,6 +13,7 @@ import {
13
13
  lstatSync,
14
14
  openSync,
15
15
  readFileSync,
16
+ realpathSync,
16
17
  statSync,
17
18
  } from 'node:fs'
18
19
  import path from 'node:path'
@@ -25,12 +26,20 @@ import path from 'node:path'
25
26
  * git: those share nothing.
26
27
  */
27
28
  export async function repoIdOf(root: string): Promise<string | null> {
28
- const located = locateGitDir(root)
29
+ // Git walks the physical path; the logical one found no `.git` through a
30
+ // symlink from outside the repository, and an alias inside it another id.
31
+ let real: string
32
+ try {
33
+ real = realpathSync(root)
34
+ } catch {
35
+ real = path.resolve(root)
36
+ }
37
+ const located = locateGitDir(real)
29
38
  if (located === null) return null
30
39
  const identity =
31
40
  (await remoteIdentity(root, located.commonDir)) ?? (await firstCommit(located.commonDir, root))
32
41
  if (identity === null) return null
33
- const rel = path.relative(located.gitRoot, path.resolve(root)).split(path.sep).join('/')
42
+ const rel = path.relative(located.gitRoot, real).split(path.sep).join('/')
34
43
  const key = sha256(`${identity}#${rel}`)
35
44
  return sha256(key).slice(0, 16)
36
45
  }
@@ -108,11 +117,40 @@ function readOwnedFile(p: string): string | null {
108
117
  }
109
118
  }
110
119
 
111
- /** The remote's identity from the config file, else from `git remote -v`. */
120
+ /**
121
+ * The remote's identity from the config file, else from `git remote -v`
122
+ * when the file cannot settle it: a remote none of whose urls parse (a
123
+ * rewrite may live in a file vx does not read, as Nx asks git), or
124
+ * remotes git could read from `config.worktree`.
125
+ */
112
126
  async function remoteIdentity(root: string, commonDir: string): Promise<string | null> {
113
127
  const config = readOwnedFile(path.join(commonDir, 'config'))
114
128
  const remotes = config === null ? null : parseGitConfigRemotes(config)
115
- const urls = remotes ?? (await remotesFromGit(root))
129
+ if (remotes !== null) {
130
+ const picked = pickRemote(remotes)
131
+ // A remote-less repo with nothing to pull one from elsewhere spawns
132
+ // nothing: git would answer the same, and that spawn was a cost per run.
133
+ if (picked !== null || (remotes.length === 0 && !worktreeConfigOn(config!))) return picked
134
+ }
135
+ return pickRemote(await remotesFromGit(root))
136
+ }
137
+
138
+ /** `extensions.worktreeConfig` set: git reads `config.worktree` too. */
139
+ function worktreeConfigOn(contents: string): boolean {
140
+ let inExtensions = false
141
+ for (const raw of contents.split('\n')) {
142
+ const line = raw.trim()
143
+ if (line.startsWith('[')) {
144
+ inExtensions = /^\[\s*extensions\s*\]/i.test(line)
145
+ continue
146
+ }
147
+ const m = /^worktreeconfig\s*(?:=\s*(\S*))?/i.exec(line)
148
+ if (inExtensions && m !== null && !/^(false|no|off|0)$/i.test(m[1] ?? 'true')) return true
149
+ }
150
+ return false
151
+ }
152
+
153
+ function pickRemote(urls: [string, string][]): string | null {
116
154
  const found = new Map<string, string>()
117
155
  let first: string | null = null
118
156
  for (const [name, url] of urls) {
@@ -1,4 +1,4 @@
1
- import { constants, type Dirent } from 'node:fs'
1
+ import { constants, existsSync, type Dirent } from 'node:fs'
2
2
  import { access, readdir, realpath, stat } from 'node:fs/promises'
3
3
  import { homedir } from 'node:os'
4
4
  import path from 'node:path'
@@ -31,8 +31,18 @@ export interface Workspace {
31
31
  root: string
32
32
  /** Glob patterns relative to root that match project directories. */
33
33
  packageGlobs: string[]
34
+ /** The package manager's catalogs, when the root declares any. */
35
+ catalogs?: Catalogs
34
36
  }
35
37
 
38
+ /**
39
+ * Catalog name → dependency → the spec a `catalog:<name>` entry stands
40
+ * for; `catalog:` alone is `default`. pnpm's from `pnpm-workspace.yaml`
41
+ * (`catalog`, `catalogs`), bun's from the root `package.json` (the same
42
+ * keys, top level or under `workspaces`).
43
+ */
44
+ export type Catalogs = ReadonlyMap<string, Readonly<Record<string, unknown>>>
45
+
36
46
  export interface ProjectMeta {
37
47
  /** Canonical name from package.json. */
38
48
  name: string
@@ -41,6 +51,8 @@ export interface ProjectMeta {
41
51
  packageJson: PackageJson
42
52
  /** Absolute path to vx.config.{ts,mts,js,mjs,cts,cjs} or null. */
43
53
  configPath: string | null
54
+ /** The workspace's catalogs, which the package graph resolves `catalog:` specs through. */
55
+ catalogs?: Catalogs
44
56
  }
45
57
 
46
58
  /** A discovered project joined with its loaded vx config. */
@@ -168,9 +180,12 @@ async function claimsMember(
168
180
  const normalized = pattern.replace(/\/+$/, '')
169
181
  // `.` means the root itself is the project — never a directory below it.
170
182
  if (normalized === '' || normalized === '.') continue
171
- const glob = new Bun.Glob(normalized)
183
+ // Matched as discovery scans, on the manifest: `packages/**` lists
184
+ // `packages/package.json`, while against the directory `**` needs a
185
+ // segment below `packages`, and a run from there was its own root.
186
+ const glob = new Bun.Glob(`${normalized}/package.json`)
172
187
  for (const rel of rels) {
173
- if (!glob.match(rel)) continue
188
+ if (!glob.match(`${rel}/package.json`)) continue
174
189
  // `match` has no `dot: false`: `packages/*` matched `packages/.tpl`,
175
190
  // which discovery skips, and a run from inside it found a workspace
176
191
  // that does not list it. Such a path asks discovery's own walk.
@@ -231,19 +246,34 @@ function excludedBy(rel: string, negative: readonly string[]): boolean {
231
246
  * the root itself is the only project, hence `['.']`.
232
247
  */
233
248
  async function readPackageGlobs(dir: string, reads?: LoadReads): Promise<string[] | null> {
249
+ return (await readRootManifests(dir, reads)).globs
250
+ }
251
+
252
+ /** `readPackageGlobs`, and the catalogs the same manifests declare. */
253
+ async function readRootManifests(
254
+ dir: string,
255
+ reads?: LoadReads,
256
+ ): Promise<{ globs: string[] | null; catalogs: Catalogs | undefined }> {
234
257
  const yamlPath = path.join(dir, 'pnpm-workspace.yaml')
235
258
  const yaml = await readOnce(reads, yamlPath)
236
259
  let yamlWithoutPackages = false
260
+ // pnpm reads its catalogs from this file alone, bun from package.json.
261
+ let catalogs: Catalogs | undefined
237
262
  if (yaml !== null) {
238
263
  const parsed = parseManifest(decoder.decode(yaml), yamlPath, Bun.YAML.parse)
239
264
  if (parsed !== null && parsed !== undefined) {
240
265
  if (typeof parsed !== 'object' || Array.isArray(parsed)) {
241
266
  throw new UserError(`${yamlPath}: must be a mapping (\`packages:\` and pnpm's settings)`)
242
267
  }
268
+ catalogs = catalogsOf([parsed])
243
269
  const packages = (parsed as { packages?: unknown }).packages
244
- if (packages !== undefined) return assertGlobList(packages, yamlPath, 'packages')
270
+ // A list commented out leaves `packages:` null, which pnpm reads as
271
+ // absent; vx refused it as "must be an array" (D-149).
272
+ if (packages !== undefined && packages !== null) {
273
+ return { globs: assertGlobList(packages, yamlPath, 'packages'), catalogs }
274
+ }
245
275
  }
246
- // No `packages:`: pnpm 10 keeps its settings and catalogs in this file
276
+ // No `packages:` (or an empty one): pnpm 10 keeps its settings and catalogs in this file
247
277
  // for a single-package repo too. The root's package.json decides, as it
248
278
  // would without the file; read as an empty list, the root found zero
249
279
  // projects and every verb ran nothing and exited 0 (item 984).
@@ -251,18 +281,46 @@ async function readPackageGlobs(dir: string, reads?: LoadReads): Promise<string[
251
281
  }
252
282
  const pkgPath = path.join(dir, 'package.json')
253
283
  const pkgBytes = await readOnce(reads, pkgPath)
254
- if (pkgBytes === null) return yamlWithoutPackages ? [] : null
284
+ if (pkgBytes === null) return { globs: yamlWithoutPackages ? [] : null, catalogs }
255
285
  const pkg = parsePackageJson(decoder.decode(pkgBytes), pkgPath)
256
286
  const ws = pkg.workspaces as unknown
257
- if (ws === undefined || ws === null) return ['.']
287
+ if (yaml === null) {
288
+ catalogs = catalogsOf(
289
+ ws !== null && typeof ws === 'object' && !Array.isArray(ws) ? [pkg, ws] : [pkg],
290
+ )
291
+ }
292
+ if (ws === undefined || ws === null) return { globs: ['.'], catalogs }
258
293
  if (ws && typeof ws === 'object' && !Array.isArray(ws) && 'packages' in ws) {
259
- return assertGlobList(
294
+ const globs = assertGlobList(
260
295
  (ws as { packages?: unknown }).packages ?? [],
261
296
  pkgPath,
262
297
  'workspaces.packages',
263
298
  )
299
+ return { globs, catalogs }
300
+ }
301
+ return { globs: assertGlobList(ws, pkgPath, 'workspaces'), catalogs }
302
+ }
303
+
304
+ /**
305
+ * The `catalog` and `catalogs` keys of the manifests (bun reads them at
306
+ * the top level and under `workspaces`), or undefined when none declares
307
+ * one. An entry that is not a mapping is no catalog: a `catalog:` spec
308
+ * through it keeps its edge, as one through an unknown catalog does.
309
+ */
310
+ function catalogsOf(manifests: readonly object[]): Catalogs | undefined {
311
+ const isMap = (v: unknown): v is Record<string, unknown> =>
312
+ typeof v === 'object' && v !== null && !Array.isArray(v)
313
+ let out: Map<string, Readonly<Record<string, unknown>>> | undefined
314
+ for (const m of manifests) {
315
+ const { catalog, catalogs } = m as { catalog?: unknown; catalogs?: unknown }
316
+ if (isMap(catalogs)) {
317
+ for (const [name, c] of Object.entries(catalogs))
318
+ if (isMap(c)) (out ??= new Map()).set(name, c)
319
+ }
320
+ // `catalog` is the default catalog to pnpm and bun.
321
+ if (isMap(catalog)) (out ??= new Map()).set('default', catalog)
264
322
  }
265
- return assertGlobList(ws, pkgPath, 'workspaces')
323
+ return out
266
324
  }
267
325
 
268
326
  /**
@@ -292,8 +350,13 @@ export async function unreachedPackages(workspace: Workspace): Promise<string[]>
292
350
  return found.sort()
293
351
  }
294
352
 
295
- /** The line for `unreachedPackages`' finding: the cause, the packages, the glob to add. */
296
- export function unreachedHint(unreached: readonly string[]): string {
353
+ /**
354
+ * The line for `unreachedPackages`' finding: the cause, the packages, the
355
+ * glob to add, and where. A `pnpm-workspace.yaml` with no `packages:` (pnpm
356
+ * keeps its settings there) is where pnpm reads the globs: the hint said
357
+ * there was no such file and named package.json, beside ngrx's (2026-10-07).
358
+ */
359
+ export function unreachedHint(unreached: readonly string[], root: string): string {
297
360
  const n = unreached.length
298
361
  const shown = unreached.slice(0, 3).join(', ') + (n > 3 ? ` and ${n - 3} more` : '')
299
362
  const globs = [
@@ -301,11 +364,11 @@ export function unreachedHint(unreached: readonly string[]): string {
301
364
  ]
302
365
  .map((g) => `"${g}"`)
303
366
  .join(', ')
304
- return (
305
- `package.json declares no \`workspaces\` (and there is no pnpm-workspace.yaml), so the root is the only project ` +
306
- `and ${n} package.json below it ${n === 1 ? 'is' : 'are'} not: ${shown}. ` +
307
- `Add \`"workspaces": [${globs}]\` to package.json and re-run.`
308
- )
367
+ const rest = `so the root is the only project and ${n} package.json below it ${n === 1 ? 'is' : 'are'} not: ${shown}.`
368
+ return existsSync(path.join(root, 'pnpm-workspace.yaml'))
369
+ ? `pnpm-workspace.yaml lists no \`packages\`, ${rest} Add \`packages: [${globs}]\` to pnpm-workspace.yaml and re-run.`
370
+ : `package.json declares no \`workspaces\` (and there is no pnpm-workspace.yaml), ${rest} ` +
371
+ `Add \`"workspaces": [${globs}]\` to package.json and re-run.`
309
372
  }
310
373
 
311
374
  /**
@@ -435,14 +498,14 @@ const repoIds = new Map<string, Promise<string | null>>()
435
498
  * itself is treated as a single-project workspace.
436
499
  */
437
500
  export async function loadWorkspace(root: string, reads?: LoadReads): Promise<Workspace> {
438
- const packageGlobs = await readPackageGlobs(root, reads)
501
+ const { globs: packageGlobs, catalogs } = await readRootManifests(root, reads)
439
502
  if (packageGlobs === null) {
440
503
  // The CLI never lands here (findWorkspaceRoot returns only a dir that
441
504
  // passes one of the two checks); a direct call of this public function
442
505
  // on a bare dir does (D-58).
443
506
  throw new UserError(`workspace root ${root} has neither pnpm-workspace.yaml nor package.json`)
444
507
  }
445
- return { root, packageGlobs }
508
+ return catalogs === undefined ? { root, packageGlobs } : { root, packageGlobs, catalogs }
446
509
  }
447
510
 
448
511
  // `<dir>/*` — the shape of nearly every workspace glob. Anything with another
@@ -720,6 +783,16 @@ export async function discoverProjects(
720
783
  for (const entry of loaded) {
721
784
  if (entry === null) continue
722
785
  const { dir, pkg, configPath } = entry
786
+ // npm and pnpm take `../ext/*`, but `--affected` asks git from the root
787
+ // and sees nothing outside it: an edit there moved the task's key and
788
+ // selected nothing, green.
789
+ const rel = relPosix(workspace.root, dir)
790
+ if (rel === '..' || rel.startsWith('../') || path.isAbsolute(rel)) {
791
+ throw new UserError(
792
+ `workspace member ${rel} (${dir}) is outside the workspace root ${workspace.root}: ` +
793
+ 'vx keeps every project under the root. Move the workspace root up to a directory that holds every member.',
794
+ )
795
+ }
723
796
  if (!pkg.name) {
724
797
  nameless?.push(dir)
725
798
  // A nameless manifest can't be addressed, filtered, or made affected —
@@ -753,7 +826,7 @@ export async function discoverProjects(
753
826
  }
754
827
  if (group.length === 1) {
755
828
  const { dir, pkg, configPath } = group[0]!
756
- projects.push({ name, dir, packageJson: pkg, configPath })
829
+ projects.push(withCatalogs({ name, dir, packageJson: pkg, configPath }, workspace))
757
830
  continue
758
831
  }
759
832
  // The root's own manifest is '' relative to itself, and the refusal read
@@ -884,5 +957,10 @@ export async function namedProject(
884
957
  `${where} "${named.name}" at ${shown}: its package.json names it "${pkg.name ?? ''}"`,
885
958
  )
886
959
  }
887
- return { name: named.name, dir, packageJson: pkg, configPath }
960
+ return withCatalogs({ name: named.name, dir, packageJson: pkg, configPath }, workspace)
961
+ }
962
+
963
+ function withCatalogs(meta: ProjectMeta, workspace: Workspace): ProjectMeta {
964
+ if (workspace.catalogs !== undefined) meta.catalogs = workspace.catalogs
965
+ return meta
888
966
  }