@vzn/vx 0.0.21 → 0.0.23

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 (123) hide show
  1. package/README.md +21 -18
  2. package/package.json +6 -6
  3. package/src/bin.ts +52 -3
  4. package/src/cache/archive.ts +170 -21
  5. package/src/cache/cache.ts +667 -288
  6. package/src/cache/chained-cache.ts +14 -2
  7. package/src/cache/config-evals.ts +29 -6
  8. package/src/cache/file-hashes.ts +63 -35
  9. package/src/cache/git-inputs.ts +398 -69
  10. package/src/cache/index.ts +8 -1
  11. package/src/cache/inputs.ts +403 -102
  12. package/src/cache/key-fold.ts +232 -0
  13. package/src/cache/layer.ts +81 -11
  14. package/src/cache/layered-cache.ts +199 -32
  15. package/src/cache/output-index.ts +109 -10
  16. package/src/cache/run-history.ts +8 -3
  17. package/src/cache/zstd.ts +53 -30
  18. package/src/cli/cache.ts +51 -21
  19. package/src/cli/format.ts +4 -15
  20. package/src/cli/help.ts +13 -3
  21. package/src/cli/index.ts +35 -9
  22. package/src/cli/info.ts +49 -7
  23. package/src/cli/init.ts +17 -5
  24. package/src/cli/last.ts +96 -21
  25. package/src/cli/lock.ts +34 -17
  26. package/src/cli/plan-format.ts +4 -4
  27. package/src/cli/plugin-commands.ts +20 -5
  28. package/src/cli/run.ts +55 -12
  29. package/src/cli/select.ts +23 -13
  30. package/src/cli/show.ts +9 -4
  31. package/src/cli/upgrade.ts +134 -14
  32. package/src/cli/watch.ts +622 -102
  33. package/src/cli/why.ts +8 -16
  34. package/src/cli/workspace-config.ts +21 -9
  35. package/src/config.ts +46 -9
  36. package/src/exec/executor.ts +51 -5
  37. package/src/exec/index.ts +10 -0
  38. package/src/exec/kill-tree.ts +281 -0
  39. package/src/exec/local-executor.ts +1 -1
  40. package/src/exec/runner.ts +327 -82
  41. package/src/exec/sandbox-binds.ts +34 -5
  42. package/src/exec/sandbox-paths.ts +22 -2
  43. package/src/exec/sandbox-runtime.ts +627 -102
  44. package/src/exec/sandbox-violations.ts +36 -23
  45. package/src/graph/dependency-spec.ts +14 -14
  46. package/src/graph/index.ts +12 -2
  47. package/src/graph/priorities.ts +130 -0
  48. package/src/graph/scheduler.ts +182 -134
  49. package/src/graph/task-graph.ts +552 -188
  50. package/src/index.ts +16 -2
  51. package/src/orchestrator/admission.ts +41 -17
  52. package/src/orchestrator/colors.ts +8 -3
  53. package/src/orchestrator/deferred-outputs.ts +22 -7
  54. package/src/orchestrator/doctor.ts +122 -14
  55. package/src/orchestrator/events.ts +71 -2
  56. package/src/orchestrator/excluded-keys.ts +155 -0
  57. package/src/orchestrator/execute-task.ts +331 -37
  58. package/src/orchestrator/failure-recap.ts +121 -0
  59. package/src/orchestrator/fingerprint-watch.ts +89 -0
  60. package/src/orchestrator/framed-output.ts +81 -14
  61. package/src/orchestrator/history.ts +6 -7
  62. package/src/orchestrator/hit-restore.ts +82 -18
  63. package/src/orchestrator/index.ts +7 -1
  64. package/src/orchestrator/keyed-projects.ts +89 -0
  65. package/src/orchestrator/local-shortcircuit.ts +83 -23
  66. package/src/orchestrator/lockfile-claim.ts +152 -32
  67. package/src/orchestrator/logger.ts +116 -20
  68. package/src/orchestrator/metrics.ts +156 -60
  69. package/src/orchestrator/miss-save.ts +106 -43
  70. package/src/orchestrator/options.ts +30 -4
  71. package/src/orchestrator/persistent.ts +41 -21
  72. package/src/orchestrator/placement.ts +55 -25
  73. package/src/orchestrator/plan.ts +24 -11
  74. package/src/orchestrator/plugin-host.ts +17 -23
  75. package/src/orchestrator/plugin.ts +31 -8
  76. package/src/orchestrator/prepare.ts +99 -13
  77. package/src/orchestrator/projects.ts +19 -8
  78. package/src/orchestrator/remote-prefetch.ts +21 -1
  79. package/src/orchestrator/run-artifacts.ts +9 -0
  80. package/src/orchestrator/run-context.ts +19 -4
  81. package/src/orchestrator/run-lock.ts +349 -0
  82. package/src/orchestrator/run-records.ts +6 -23
  83. package/src/orchestrator/run-report.ts +4 -2
  84. package/src/orchestrator/run.ts +264 -57
  85. package/src/orchestrator/sandbox-request.ts +388 -62
  86. package/src/orchestrator/save-lane.ts +4 -7
  87. package/src/orchestrator/shell-verdict.ts +117 -0
  88. package/src/orchestrator/signals.ts +86 -30
  89. package/src/orchestrator/stable-keys.ts +202 -10
  90. package/src/orchestrator/status-line.ts +33 -1
  91. package/src/orchestrator/summary.ts +49 -2
  92. package/src/orchestrator/task-hash.ts +87 -7
  93. package/src/orchestrator/task-log-buffer.ts +7 -7
  94. package/src/orchestrator/telemetry-host.ts +16 -7
  95. package/src/orchestrator/telemetry.ts +69 -28
  96. package/src/orchestrator/upstream.ts +91 -45
  97. package/src/util/bun-version.ts +43 -0
  98. package/src/util/errors.ts +86 -0
  99. package/src/util/hash.ts +10 -1
  100. package/src/util/index.ts +32 -3
  101. package/src/util/paths.ts +147 -5
  102. package/src/util/procfs.ts +20 -0
  103. package/src/util/settle.ts +4 -2
  104. package/src/util/size.ts +33 -0
  105. package/src/util/task-id.ts +20 -0
  106. package/src/util/timing.ts +11 -1
  107. package/src/util/which.ts +48 -0
  108. package/src/workspace/affected.ts +272 -64
  109. package/src/workspace/config-cache.ts +262 -32
  110. package/src/workspace/config-eval.ts +49 -12
  111. package/src/workspace/config-imports.ts +126 -5
  112. package/src/workspace/config-schema.ts +237 -41
  113. package/src/workspace/filter.ts +33 -18
  114. package/src/workspace/fingerprint.ts +29 -11
  115. package/src/workspace/index.ts +7 -0
  116. package/src/workspace/json-data.ts +90 -0
  117. package/src/workspace/load-reads.ts +32 -0
  118. package/src/workspace/lockfile.ts +33 -11
  119. package/src/workspace/migrate-scripts.ts +60 -7
  120. package/src/workspace/migration.ts +40 -1
  121. package/src/workspace/package-graph.ts +154 -16
  122. package/src/workspace/project-loader.ts +227 -77
  123. package/src/workspace/workspace.ts +173 -23
@@ -2,7 +2,8 @@ import type { Dirent } from 'node:fs'
2
2
  import { readdir, stat } from 'node:fs/promises'
3
3
  import path from 'node:path'
4
4
  import type { ProjectConfig, WorkspaceConfig } from '../config.js'
5
- import { relPosix, UserError, normalizeGlob } from '../util/index.js'
5
+ import { BUN_GLOB_WILDCARDS, relPosix, UserError, normalizeBunGlob } from '../util/index.js'
6
+ import { type LoadReads, readOnce } from './load-reads.js'
6
7
 
7
8
  export interface PackageJson {
8
9
  name: string
@@ -38,7 +39,14 @@ export interface ProjectEntry {
38
39
  config: ProjectConfig
39
40
  }
40
41
 
41
- const CONFIG_FILENAMES = ['vx.config.ts', 'vx.config.mts', 'vx.config.js', 'vx.config.mjs']
42
+ export const PROJECT_CONFIG_FILENAMES = [
43
+ 'vx.config.ts',
44
+ 'vx.config.mts',
45
+ 'vx.config.js',
46
+ 'vx.config.mjs',
47
+ ]
48
+
49
+ const decoder = new TextDecoder()
42
50
 
43
51
  /**
44
52
  * Walk up from `start` to find the workspace root. A directory is a root
@@ -56,15 +64,20 @@ const CONFIG_FILENAMES = ['vx.config.ts', 'vx.config.mts', 'vx.config.js', 'vx.c
56
64
  * When no candidate claims `start` — a standalone package, or a subdirectory
57
65
  * of a single-project repo — the nearest candidate wins (the root itself IS
58
66
  * the project). Throws a `UserError` when there is no candidate before `/`.
67
+ * A load that goes on to `loadWorkspace` passes its `reads`, so the
68
+ * root's manifest is read once for both.
59
69
  */
60
- export async function findWorkspaceRoot(start: string): Promise<string> {
70
+ export async function findWorkspaceRoot(
71
+ start: string,
72
+ reads: LoadReads = new Map(),
73
+ ): Promise<string> {
61
74
  let dir = path.resolve(start)
62
75
  const below: string[] = []
63
76
  let nearest: string | null = null
64
77
  while (true) {
65
78
  let globs: string[] | null
66
79
  try {
67
- globs = await readPackageGlobs(dir)
80
+ globs = await readPackageGlobs(dir, reads)
68
81
  } catch {
69
82
  // An unparseable manifest is still a root SIGNAL (the pre-existing
70
83
  // behaviour probed only for existence); it just can't claim members.
@@ -74,10 +87,19 @@ export async function findWorkspaceRoot(start: string): Promise<string> {
74
87
  if (globs !== null) {
75
88
  nearest ??= dir
76
89
  if (claimsMember(dir, below, globs)) return dir
90
+ // pnpm takes the nearest `pnpm-workspace.yaml` as the root, listed or
91
+ // not. Walking past it, `apps/inner` resolved to the outer workspace
92
+ // while `apps/inner/pkgs/x` resolved to the inner one: two roots and
93
+ // two caches for one tree (item 990). Already read: `reads` holds it.
94
+ if ((await readOnce(reads, path.join(dir, 'pnpm-workspace.yaml'))) !== null) return dir
77
95
  }
78
96
  const parent = path.dirname(dir)
79
97
  if (parent === dir) break
80
- below.push(dir)
98
+ // Only a directory holding a manifest can be a member: a glob that
99
+ // matched `packages/tools`, which has none, claimed the standalone
100
+ // package below it, and `packages/tools/standalone` ran as a stranger
101
+ // in a workspace that does not list it (item 989).
102
+ if (globs !== null) below.push(dir)
81
103
  dir = parent
82
104
  }
83
105
  if (nearest !== null) return nearest
@@ -117,7 +139,11 @@ function splitPackageGlobs(globs: readonly string[]): { positive: string[]; nega
117
139
  // `!./packages/legacy` and `!packages//legacy` excluded nothing, silently
118
140
  // (2026-09-10): the same spellings the input globs normalize.
119
141
  for (const raw of globs) {
120
- const g = normalizeGlob(raw)
142
+ // A member glob names directories, so a trailing slash says nothing:
143
+ // npm and pnpm read `packages/*/` as `packages/*`. `normalizeBunGlob`
144
+ // gives it a task glob's meaning, `/**`, and `packages/*/` found every
145
+ // example and fixture package at any depth below the members (item 985).
146
+ const g = normalizeBunGlob(raw.replace(/(.)\/+$/, '$1'))
121
147
  if (g.startsWith('!')) negative.push(g.slice(1).replace(/\/+$/, ''))
122
148
  else positive.push(g)
123
149
  }
@@ -128,7 +154,13 @@ function excludedBy(rel: string, negative: readonly string[]): boolean {
128
154
  for (const neg of negative) {
129
155
  if (neg.length === 0) continue
130
156
  if (rel === neg || rel.startsWith(`${neg}/`)) return true
131
- if (/[*?[\]{}]/.test(neg) && new Bun.Glob(neg).match(rel)) return true
157
+ if (!BUN_GLOB_WILDCARDS.test(neg)) continue
158
+ if (new Bun.Glob(neg).match(rel)) return true
159
+ // pnpm matches the MANIFEST (`<pattern>/package.json`), so its documented
160
+ // `!**/test/**` excludes `packages/test` itself; matched against the
161
+ // directory, `**/test/**` needs something below `test` and the package
162
+ // stayed a member (item 986).
163
+ if (new Bun.Glob(`${neg}/package.json`).match(`${rel}/package.json`)) return true
132
164
  }
133
165
  return false
134
166
  }
@@ -138,16 +170,29 @@ function excludedBy(rel: string, negative: readonly string[]): boolean {
138
170
  * candidate. A bare `package.json` (no `workspaces`) is single-project mode:
139
171
  * the root itself is the only project, hence `['.']`.
140
172
  */
141
- async function readPackageGlobs(dir: string): Promise<string[] | null> {
173
+ async function readPackageGlobs(dir: string, reads?: LoadReads): Promise<string[] | null> {
142
174
  const yamlPath = path.join(dir, 'pnpm-workspace.yaml')
143
- if (await Bun.file(yamlPath).exists()) {
144
- const parsed = (parseManifest(await Bun.file(yamlPath).text(), yamlPath, Bun.YAML.parse) ??
145
- {}) as { packages?: unknown }
146
- return assertGlobList(parsed.packages ?? [], yamlPath, 'packages')
175
+ const yaml = await readOnce(reads, yamlPath)
176
+ let yamlWithoutPackages = false
177
+ if (yaml !== null) {
178
+ const parsed = parseManifest(decoder.decode(yaml), yamlPath, Bun.YAML.parse)
179
+ if (parsed !== null && parsed !== undefined) {
180
+ if (typeof parsed !== 'object' || Array.isArray(parsed)) {
181
+ throw new UserError(`${yamlPath}: must be a mapping (\`packages:\` and pnpm's settings)`)
182
+ }
183
+ const packages = (parsed as { packages?: unknown }).packages
184
+ if (packages !== undefined) return assertGlobList(packages, yamlPath, 'packages')
185
+ }
186
+ // No `packages:`: pnpm 10 keeps its settings and catalogs in this file
187
+ // for a single-package repo too. The root's package.json decides, as it
188
+ // would without the file; read as an empty list, the root found zero
189
+ // projects and every verb ran nothing and exited 0 (item 984).
190
+ yamlWithoutPackages = true
147
191
  }
148
192
  const pkgPath = path.join(dir, 'package.json')
149
- if (!(await Bun.file(pkgPath).exists())) return null
150
- const pkg = parseManifest(await Bun.file(pkgPath).text(), pkgPath, JSON.parse) as PackageJson
193
+ const pkgBytes = await readOnce(reads, pkgPath)
194
+ if (pkgBytes === null) return yamlWithoutPackages ? [] : null
195
+ const pkg = parsePackageJson(decoder.decode(pkgBytes), pkgPath)
151
196
  const ws = pkg.workspaces as unknown
152
197
  if (ws === undefined || ws === null) return ['.']
153
198
  if (ws && typeof ws === 'object' && !Array.isArray(ws) && 'packages' in ws) {
@@ -160,6 +205,49 @@ async function readPackageGlobs(dir: string): Promise<string[] | null> {
160
205
  return assertGlobList(ws, pkgPath, 'workspaces')
161
206
  }
162
207
 
208
+ /**
209
+ * Package manifests the globs never reach, in single-project mode only: a
210
+ * root `package.json` with no `workspaces` field makes the root the one
211
+ * project, and a `packages/app/package.json` full of scripts beside it is
212
+ * then invisible — `vx init` said "no package.json scripts" and `vx run`
213
+ * said "run vx init" (2026-09-16). One shallow scan, two levels, on the
214
+ * failure path only; `node_modules` and dot directories are skipped.
215
+ */
216
+ export async function unreachedPackages(workspace: Workspace): Promise<string[]> {
217
+ if (workspace.packageGlobs.length !== 1 || workspace.packageGlobs[0] !== '.') return []
218
+ const found: string[] = []
219
+ // Two scans: Bun.Glob does not expand a brace whose alternatives hold a
220
+ // slash (`{*,*/*}/package.json` matched nothing, measured 2026-09-16).
221
+ for (const pattern of ['*/package.json', '*/*/package.json']) {
222
+ for await (const rel of new Bun.Glob(pattern).scan({
223
+ cwd: workspace.root,
224
+ onlyFiles: true,
225
+ dot: false,
226
+ })) {
227
+ const posix = rel.split(path.sep).join('/')
228
+ if (posix.startsWith('node_modules/') || posix.includes('/node_modules/')) continue
229
+ found.push(posix.slice(0, -'/package.json'.length))
230
+ }
231
+ }
232
+ return found.sort()
233
+ }
234
+
235
+ /** The line for `unreachedPackages`' finding: the cause, the packages, the glob to add. */
236
+ export function unreachedHint(unreached: readonly string[]): string {
237
+ const n = unreached.length
238
+ const shown = unreached.slice(0, 3).join(', ') + (n > 3 ? ` and ${n - 3} more` : '')
239
+ const globs = [
240
+ ...new Set(unreached.map((d) => (d.includes('/') ? `${path.posix.dirname(d)}/*` : d))),
241
+ ]
242
+ .map((g) => `"${g}"`)
243
+ .join(', ')
244
+ return (
245
+ `package.json declares no \`workspaces\` (and there is no pnpm-workspace.yaml), so the root is the only project ` +
246
+ `and ${n} package.json below it ${n === 1 ? 'is' : 'are'} not: ${shown}. ` +
247
+ `Add \`"workspaces": [${globs}]\` to package.json and re-run.`
248
+ )
249
+ }
250
+
163
251
  /**
164
252
  * Parse a workspace manifest, naming the FILE on failure. The raw parser
165
253
  * errors (`Failed to parse JSON`) carry no path, so in a 1000-package
@@ -174,6 +262,25 @@ function parseManifest(text: string, file: string, parse: (t: string) => unknown
174
262
  }
175
263
  }
176
264
 
265
+ /**
266
+ * A `package.json` as discovery reads it: a JSON object whose `name`, when
267
+ * present, is a string with no surrounding whitespace (npm refuses one).
268
+ * Parsed and cast unchecked, `null` crashed with a TypeError and a stack,
269
+ * `{"name":123}` planned `123#build`, and `" a"` beside `"a"` made two
270
+ * projects (item 988).
271
+ */
272
+ function parsePackageJson(text: string, file: string): PackageJson {
273
+ const pkg = parseManifest(text, file, JSON.parse)
274
+ if (pkg === null || typeof pkg !== 'object' || Array.isArray(pkg)) {
275
+ throw new UserError(`${file}: must be a JSON object`)
276
+ }
277
+ const name = (pkg as { name?: unknown }).name
278
+ if (name !== undefined && (typeof name !== 'string' || name.trim() !== name)) {
279
+ throw new UserError(`${file}: "name" must be a string with no surrounding whitespace`)
280
+ }
281
+ return pkg as PackageJson
282
+ }
283
+
177
284
  /**
178
285
  * A glob list is user input from a manifest: `packages: "packages/*"`
179
286
  * (a bare string instead of a list) is an easy YAML slip, and without
@@ -183,9 +290,46 @@ function assertGlobList(value: unknown, file: string, field: string): string[] {
183
290
  if (!Array.isArray(value) || value.some((p) => typeof p !== 'string')) {
184
291
  throw new UserError(`${file}: \`${field}\` must be an array of glob strings`)
185
292
  }
293
+ for (const pattern of value as string[]) {
294
+ if (EXTGLOB.test(pattern)) throw extglobRefusal(pattern, file, field)
295
+ }
186
296
  return value as string[]
187
297
  }
188
298
 
299
+ /**
300
+ * An extglob group: `!(…)`, `@(…)`, `+(…)`, `*(…)`, `?(…)`. npm (minimatch)
301
+ * and yarn (micromatch) read `packages/!(x)` as "every directory in
302
+ * packages/ but x"; `Bun.Glob` has no extglob, and its scan read the
303
+ * segment as a wildcard, so the excluded package became a project
304
+ * (turborepo#3766) while its match read the same text literally. vx cannot
305
+ * honour the pattern, so it refuses it rather than read it either way.
306
+ */
307
+ const EXTGLOB = /[!@+*?]\(/
308
+
309
+ function extglobRefusal(pattern: string, file: string, field: string): UserError {
310
+ const head = `${file}: \`${field}\` entry "${pattern}" is an extglob, which vx's glob engine does not read`
311
+ // The common shape, a whole segment excluding plain names, has an exact
312
+ // rewrite in the `!` entries every package manager takes.
313
+ const segments = pattern.split('/')
314
+ const at = segments.findIndex((s) => /^!\([^()]*\)$/.test(s))
315
+ const names = at === -1 ? [] : segments[at]!.slice(2, -1).split('|')
316
+ const plain = names.every((n) => n !== '' && !BUN_GLOB_WILDCARDS.test(n) && !/[()!@+]/.test(n))
317
+ const rest = segments.filter((_s, i) => i !== at).join('/')
318
+ if (at === -1 || !plain || EXTGLOB.test(rest)) {
319
+ return new UserError(
320
+ `${head}. List the members with \`*\`, braces or \`!\` exclusions instead.`,
321
+ )
322
+ }
323
+ const before = segments.slice(0, at)
324
+ const rewrite = [
325
+ [...before, '*', ...segments.slice(at + 1)].join('/'),
326
+ ...names.map((n) => `!${[...before, n].join('/')}`),
327
+ ]
328
+ return new UserError(
329
+ `${head} (npm and yarn read \`!(…)\` as an exclusion). List the exclusion as its own entry: [${rewrite.map((r) => JSON.stringify(r)).join(', ')}].`,
330
+ )
331
+ }
332
+
189
333
  /**
190
334
  * Resolve the cache directory for a workspace. Respects the user's
191
335
  * `defineWorkspace({ cacheDir })` override (relative to the workspace
@@ -206,8 +350,8 @@ export function resolveCacheDir(root: string, config: WorkspaceConfig | null): s
206
350
  * If a `package.json` exists with no `workspaces` field, the root
207
351
  * itself is treated as a single-project workspace.
208
352
  */
209
- export async function loadWorkspace(root: string): Promise<Workspace> {
210
- const packageGlobs = await readPackageGlobs(root)
353
+ export async function loadWorkspace(root: string, reads?: LoadReads): Promise<Workspace> {
354
+ const packageGlobs = await readPackageGlobs(root, reads)
211
355
  if (packageGlobs === null) {
212
356
  // Should be unreachable: findWorkspaceRoot only returns dirs that
213
357
  // pass at least one of the two existence checks.
@@ -242,8 +386,9 @@ export function memberBaseDirs(workspace: Workspace): string[] {
242
386
  * The directories a workspace glob names. For the `<dir>/*` shape this is
243
387
  * one readdir of `<dir>` — the same answer `Bun.Glob` gives, at a third of
244
388
  * the cost (measured 2026-09-02: 25 ms → ~2 ms for 1000 members). A
245
- * symlinked member is followed when it points at a directory, as the glob
246
- * did. Dot-directories are skipped (`dot: false`), so are `node_modules`.
389
+ * symlinked member is followed when it points at a directory, and so is
390
+ * it by the scan any other shape takes, unless the glob holds `**` (item
391
+ * 987). Dot-directories are skipped (`dot: false`), so are `node_modules`.
247
392
  */
248
393
  async function memberDirs(root: string, pattern: string): Promise<string[]> {
249
394
  // Normalize: `"."` -> the root itself; `"foo/"` -> `"foo"`.
@@ -274,7 +419,12 @@ async function memberDirs(root: string, pattern: string): Promise<string[]> {
274
419
  const globPattern = normalized === '' ? 'package.json' : `${normalized}/package.json`
275
420
  const glob = new Bun.Glob(globPattern)
276
421
  const dirs: string[] = []
277
- for await (const rel of glob.scan({ cwd: root, onlyFiles: true, dot: false })) {
422
+ // A linked member is a member, as the readdir path above has it:
423
+ // `packages/*` found `packages/b -> ../ext/b` while `packages/{a,b}` and
424
+ // `pack*/*` did not (item 987). Followed only where the depth is bounded:
425
+ // under `**` the scan would walk every pnpm `node_modules` link.
426
+ const followSymlinks = !normalized.includes('**')
427
+ for await (const rel of glob.scan({ cwd: root, onlyFiles: true, dot: false, followSymlinks })) {
278
428
  // Skip nested node_modules — workspace package globs shouldn't
279
429
  // ever reach into them, but a pathological pattern like `**`
280
430
  // would. Avoid splitting the path on the hot loop.
@@ -286,7 +436,7 @@ async function memberDirs(root: string, pattern: string): Promise<string[]> {
286
436
  }
287
437
 
288
438
  /**
289
- * The project's config file, by `CONFIG_FILENAMES` precedence, or null.
439
+ * The project's config file, by `PROJECT_CONFIG_FILENAMES` precedence, or null.
290
440
  *
291
441
  * Two shapes, one per platform, each the measured winner there across
292
442
  * 1,000 projects with the manifest read in flight alongside:
@@ -309,7 +459,7 @@ async function findConfigFile(dir: string): Promise<string | null> {
309
459
  return null
310
460
  }
311
461
  const byName = new Map(entries.map((e) => [e.name, e]))
312
- for (const name of CONFIG_FILENAMES) {
462
+ for (const name of PROJECT_CONFIG_FILENAMES) {
313
463
  const entry = byName.get(name)
314
464
  if (entry === undefined) continue
315
465
  const candidate = path.join(dir, name)
@@ -318,7 +468,7 @@ async function findConfigFile(dir: string): Promise<string | null> {
318
468
  }
319
469
  return null
320
470
  }
321
- for (const name of CONFIG_FILENAMES) {
471
+ for (const name of PROJECT_CONFIG_FILENAMES) {
322
472
  const candidate = path.join(dir, name)
323
473
  if (await isFile(candidate)) return candidate
324
474
  }
@@ -364,7 +514,7 @@ export async function listProjects(workspace: Workspace): Promise<ProjectMeta[]>
364
514
  .catch(() => null),
365
515
  ])
366
516
  if (text === null) return null
367
- const pkg = parseManifest(text, pkgJsonPath, JSON.parse) as PackageJson
517
+ const pkg = parsePackageJson(text, pkgJsonPath)
368
518
  return { dir, pkg, configPath }
369
519
  }),
370
520
  )