@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.
- package/README.md +21 -18
- package/package.json +6 -6
- package/src/bin.ts +52 -3
- package/src/cache/archive.ts +170 -21
- package/src/cache/cache.ts +667 -288
- package/src/cache/chained-cache.ts +14 -2
- package/src/cache/config-evals.ts +29 -6
- package/src/cache/file-hashes.ts +63 -35
- package/src/cache/git-inputs.ts +398 -69
- package/src/cache/index.ts +8 -1
- package/src/cache/inputs.ts +403 -102
- package/src/cache/key-fold.ts +232 -0
- package/src/cache/layer.ts +81 -11
- package/src/cache/layered-cache.ts +199 -32
- package/src/cache/output-index.ts +109 -10
- package/src/cache/run-history.ts +8 -3
- package/src/cache/zstd.ts +53 -30
- package/src/cli/cache.ts +51 -21
- package/src/cli/format.ts +4 -15
- package/src/cli/help.ts +13 -3
- package/src/cli/index.ts +35 -9
- package/src/cli/info.ts +49 -7
- package/src/cli/init.ts +17 -5
- package/src/cli/last.ts +96 -21
- package/src/cli/lock.ts +34 -17
- package/src/cli/plan-format.ts +4 -4
- package/src/cli/plugin-commands.ts +20 -5
- package/src/cli/run.ts +55 -12
- package/src/cli/select.ts +23 -13
- package/src/cli/show.ts +9 -4
- package/src/cli/upgrade.ts +134 -14
- package/src/cli/watch.ts +622 -102
- package/src/cli/why.ts +8 -16
- package/src/cli/workspace-config.ts +21 -9
- package/src/config.ts +46 -9
- package/src/exec/executor.ts +51 -5
- package/src/exec/index.ts +10 -0
- package/src/exec/kill-tree.ts +281 -0
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/runner.ts +327 -82
- package/src/exec/sandbox-binds.ts +34 -5
- package/src/exec/sandbox-paths.ts +22 -2
- package/src/exec/sandbox-runtime.ts +627 -102
- package/src/exec/sandbox-violations.ts +36 -23
- package/src/graph/dependency-spec.ts +14 -14
- package/src/graph/index.ts +12 -2
- package/src/graph/priorities.ts +130 -0
- package/src/graph/scheduler.ts +182 -134
- package/src/graph/task-graph.ts +552 -188
- package/src/index.ts +16 -2
- package/src/orchestrator/admission.ts +41 -17
- package/src/orchestrator/colors.ts +8 -3
- package/src/orchestrator/deferred-outputs.ts +22 -7
- package/src/orchestrator/doctor.ts +122 -14
- package/src/orchestrator/events.ts +71 -2
- package/src/orchestrator/excluded-keys.ts +155 -0
- package/src/orchestrator/execute-task.ts +331 -37
- package/src/orchestrator/failure-recap.ts +121 -0
- package/src/orchestrator/fingerprint-watch.ts +89 -0
- package/src/orchestrator/framed-output.ts +81 -14
- package/src/orchestrator/history.ts +6 -7
- package/src/orchestrator/hit-restore.ts +82 -18
- package/src/orchestrator/index.ts +7 -1
- package/src/orchestrator/keyed-projects.ts +89 -0
- package/src/orchestrator/local-shortcircuit.ts +83 -23
- package/src/orchestrator/lockfile-claim.ts +152 -32
- package/src/orchestrator/logger.ts +116 -20
- package/src/orchestrator/metrics.ts +156 -60
- package/src/orchestrator/miss-save.ts +106 -43
- package/src/orchestrator/options.ts +30 -4
- package/src/orchestrator/persistent.ts +41 -21
- package/src/orchestrator/placement.ts +55 -25
- package/src/orchestrator/plan.ts +24 -11
- package/src/orchestrator/plugin-host.ts +17 -23
- package/src/orchestrator/plugin.ts +31 -8
- package/src/orchestrator/prepare.ts +99 -13
- package/src/orchestrator/projects.ts +19 -8
- package/src/orchestrator/remote-prefetch.ts +21 -1
- package/src/orchestrator/run-artifacts.ts +9 -0
- package/src/orchestrator/run-context.ts +19 -4
- package/src/orchestrator/run-lock.ts +349 -0
- package/src/orchestrator/run-records.ts +6 -23
- package/src/orchestrator/run-report.ts +4 -2
- package/src/orchestrator/run.ts +264 -57
- package/src/orchestrator/sandbox-request.ts +388 -62
- package/src/orchestrator/save-lane.ts +4 -7
- package/src/orchestrator/shell-verdict.ts +117 -0
- package/src/orchestrator/signals.ts +86 -30
- package/src/orchestrator/stable-keys.ts +202 -10
- package/src/orchestrator/status-line.ts +33 -1
- package/src/orchestrator/summary.ts +49 -2
- package/src/orchestrator/task-hash.ts +87 -7
- package/src/orchestrator/task-log-buffer.ts +7 -7
- package/src/orchestrator/telemetry-host.ts +16 -7
- package/src/orchestrator/telemetry.ts +69 -28
- package/src/orchestrator/upstream.ts +91 -45
- package/src/util/bun-version.ts +43 -0
- package/src/util/errors.ts +86 -0
- package/src/util/hash.ts +10 -1
- package/src/util/index.ts +32 -3
- package/src/util/paths.ts +147 -5
- package/src/util/procfs.ts +20 -0
- package/src/util/settle.ts +4 -2
- package/src/util/size.ts +33 -0
- package/src/util/task-id.ts +20 -0
- package/src/util/timing.ts +11 -1
- package/src/util/which.ts +48 -0
- package/src/workspace/affected.ts +272 -64
- package/src/workspace/config-cache.ts +262 -32
- package/src/workspace/config-eval.ts +49 -12
- package/src/workspace/config-imports.ts +126 -5
- package/src/workspace/config-schema.ts +237 -41
- package/src/workspace/filter.ts +33 -18
- package/src/workspace/fingerprint.ts +29 -11
- package/src/workspace/index.ts +7 -0
- package/src/workspace/json-data.ts +90 -0
- package/src/workspace/load-reads.ts +32 -0
- package/src/workspace/lockfile.ts +33 -11
- package/src/workspace/migrate-scripts.ts +60 -7
- package/src/workspace/migration.ts +40 -1
- package/src/workspace/package-graph.ts +154 -16
- package/src/workspace/project-loader.ts +227 -77
- 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,
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
150
|
-
|
|
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,
|
|
246
|
-
*
|
|
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
|
-
|
|
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 `
|
|
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
|
|
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
|
|
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 =
|
|
517
|
+
const pkg = parsePackageJson(text, pkgJsonPath)
|
|
368
518
|
return { dir, pkg, configPath }
|
|
369
519
|
}),
|
|
370
520
|
)
|