@vzn/vx 0.0.15 → 0.0.18
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 +91 -130
- package/index.ts +10 -0
- package/launcher.mjs +10 -11
- package/package.json +19 -5
- package/plugins/local-cache/index.ts +2 -0
- package/plugins/local-executor/index.ts +2 -0
- package/plugins/schedule-history/index.ts +2 -0
- package/src/bin.ts +2 -2
- package/src/cache/archive.ts +557 -0
- package/src/cache/cache.ts +929 -250
- package/src/cache/cas-backend.ts +20 -2
- package/src/cache/chained-cache.ts +202 -0
- package/src/cache/digest.ts +0 -15
- package/src/cache/index.ts +13 -3
- package/src/cache/inputs.ts +445 -41
- package/src/cache/layered-cache.ts +129 -30
- package/src/cache/tar-stream.ts +358 -0
- package/src/cli/cache.ts +36 -10
- package/src/cli/help.ts +167 -128
- package/src/cli/index.ts +74 -43
- package/src/cli/info.ts +30 -0
- package/src/cli/last.ts +140 -0
- package/src/cli/migrate-emit.ts +21 -0
- package/src/cli/migrate-nx.ts +23 -0
- package/src/cli/migrate-scripts.ts +127 -0
- package/src/cli/migrate-turbo.ts +43 -6
- package/src/cli/migrate.ts +119 -33
- package/src/cli/plan-format.ts +58 -3
- package/src/cli/plugin-commands.ts +96 -0
- package/src/cli/prune.ts +291 -0
- package/src/cli/run.ts +272 -80
- package/src/cli/upgrade.ts +7 -6
- package/src/cli/watch.ts +259 -61
- package/src/cli/why.ts +193 -0
- package/src/config.ts +40 -23
- package/src/exec/env.ts +7 -1
- package/src/exec/executor.ts +239 -0
- package/src/exec/index.ts +19 -1
- package/src/exec/runner.ts +116 -29
- package/src/exec/sandbox-runtime.ts +194 -24
- package/src/graph/index.ts +3 -1
- package/src/graph/scheduler.ts +92 -18
- package/src/graph/task-graph.ts +220 -6
- package/src/index.ts +82 -84
- package/src/orchestrator/deferred-outputs.ts +144 -0
- package/src/orchestrator/download-policy.ts +171 -0
- package/src/orchestrator/events.ts +30 -0
- package/src/orchestrator/execute-task.ts +328 -94
- package/src/orchestrator/failure-mode.ts +61 -0
- package/src/orchestrator/framed-output.ts +87 -35
- package/src/orchestrator/history.ts +78 -31
- package/src/orchestrator/index.ts +43 -90
- package/src/orchestrator/local-shortcircuit.ts +22 -0
- package/src/orchestrator/logger.ts +196 -14
- package/src/orchestrator/metrics.ts +57 -1376
- package/src/orchestrator/missing-plugin.ts +9 -0
- package/src/orchestrator/options.ts +22 -4
- package/src/orchestrator/plan.ts +144 -1
- package/src/orchestrator/plugin-host.ts +199 -119
- package/src/orchestrator/plugin.ts +144 -57
- package/src/orchestrator/prepare.ts +220 -69
- package/src/orchestrator/remote-prefetch.ts +27 -8
- package/src/orchestrator/resources.ts +20 -34
- package/src/orchestrator/run-artifacts.ts +41 -16
- package/src/orchestrator/run-context.ts +167 -29
- package/src/orchestrator/run-report.ts +73 -46
- package/src/orchestrator/run.ts +590 -122
- package/src/orchestrator/stable-keys.ts +74 -33
- package/src/orchestrator/status-line.ts +47 -8
- package/src/orchestrator/summary.ts +22 -1
- package/src/orchestrator/tally.ts +78 -35
- package/src/orchestrator/task-hash.ts +105 -11
- package/src/orchestrator/task-log-buffer.ts +291 -0
- package/src/orchestrator/telemetry-host.ts +24 -6
- package/src/orchestrator/telemetry.ts +181 -21
- package/src/orchestrator/upstream.ts +72 -31
- package/src/orchestrator/verify.ts +12 -2
- package/src/plugins/local-cache/index.ts +11 -0
- package/src/plugins/local-executor/index.ts +45 -0
- package/src/plugins/schedule-history/index.ts +109 -0
- package/src/util/edit-distance.ts +21 -0
- package/src/util/errors.ts +15 -1
- package/src/util/index.ts +7 -2
- package/src/util/num.ts +37 -0
- package/src/util/paths.ts +43 -0
- package/src/util/settle.ts +48 -0
- package/src/util/size.ts +5 -1
- package/src/util/tail.ts +66 -0
- package/src/util/timing.ts +63 -0
- package/src/workspace/affected.ts +142 -15
- package/src/workspace/config-cache.ts +304 -0
- package/src/workspace/config-eval.ts +210 -0
- package/src/workspace/config-imports.ts +161 -0
- package/src/workspace/filter.ts +22 -4
- package/src/workspace/fingerprint.ts +16 -1
- package/src/workspace/index.ts +15 -3
- package/src/workspace/lockfile.ts +13 -2
- package/src/workspace/nested-dirs.ts +21 -7
- package/src/workspace/package-graph.ts +23 -20
- package/src/workspace/project-loader.ts +503 -59
- package/src/workspace/workspace.ts +213 -72
- package/src/cache/tar.ts +0 -343
- package/src/cli/backend.ts +0 -23
- package/src/cli/mcp-rpc.ts +0 -292
- package/src/cli/mcp.ts +0 -92
- package/src/orchestrator/devframe-surface.ts +0 -74
- package/src/orchestrator/predict.ts +0 -106
- package/src/orchestrator/protocol.ts +0 -156
- package/src/orchestrator/run-state.ts +0 -145
- package/src/orchestrator/wire-render.ts +0 -92
- package/src/orchestrator/wire.ts +0 -212
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { readdir } from 'node:fs/promises'
|
|
1
|
+
import { readdir, stat } from 'node:fs/promises'
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
import type { ProjectConfig, WorkspaceConfig } from '../config.js'
|
|
4
|
-
import { UserError } from '../util/index.js'
|
|
4
|
+
import { relPosix, UserError } from '../util/index.js'
|
|
5
5
|
|
|
6
6
|
export interface PackageJson {
|
|
7
7
|
name: string
|
|
@@ -40,29 +40,117 @@ export interface ProjectEntry {
|
|
|
40
40
|
const CONFIG_FILENAMES = ['vx.config.ts', 'vx.config.mts', 'vx.config.js', 'vx.config.mjs']
|
|
41
41
|
|
|
42
42
|
/**
|
|
43
|
-
* Walk up from `start` to find the workspace root. A directory is a
|
|
44
|
-
*
|
|
45
|
-
* - `pnpm-workspace.yaml`, OR
|
|
46
|
-
* - a `package.json` (with or without a `workspaces` field).
|
|
43
|
+
* Walk up from `start` to find the workspace root. A directory is a root
|
|
44
|
+
* CANDIDATE when it contains `pnpm-workspace.yaml` or a `package.json`.
|
|
47
45
|
*
|
|
48
|
-
* The
|
|
49
|
-
*
|
|
50
|
-
*
|
|
46
|
+
* The nearest candidate that CLAIMS `start` wins — one of the directories
|
|
47
|
+
* between it and `start` matches one of its package globs. Every workspace
|
|
48
|
+
* member has its own `package.json`, so stopping at the first candidate would
|
|
49
|
+
* make a run from inside a package treat that package as the whole workspace:
|
|
50
|
+
* `^task` edges vanish, upstream hashes drop out of the cache key (stale
|
|
51
|
+
* hits), and a second cache dir appears under the member. Claiming is decided
|
|
52
|
+
* with the same globs `loadWorkspace` applies, so "the root that claims me"
|
|
53
|
+
* and "the root that lists me as a project" cannot diverge.
|
|
54
|
+
*
|
|
55
|
+
* When no candidate claims `start` — a standalone package, or a subdirectory
|
|
56
|
+
* of a single-project repo — the nearest candidate wins (the root itself IS
|
|
57
|
+
* the project). Throws a `UserError` when there is no candidate before `/`.
|
|
51
58
|
*/
|
|
52
59
|
export async function findWorkspaceRoot(start: string): Promise<string> {
|
|
53
60
|
let dir = path.resolve(start)
|
|
61
|
+
const below: string[] = []
|
|
62
|
+
let nearest: string | null = null
|
|
54
63
|
while (true) {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
64
|
+
let globs: string[] | null
|
|
65
|
+
try {
|
|
66
|
+
globs = await readPackageGlobs(dir)
|
|
67
|
+
} catch {
|
|
68
|
+
// An unparseable manifest is still a root SIGNAL (the pre-existing
|
|
69
|
+
// behaviour probed only for existence); it just can't claim members.
|
|
70
|
+
// `loadWorkspace` surfaces the parse error if this dir is chosen.
|
|
71
|
+
globs = []
|
|
72
|
+
}
|
|
73
|
+
if (globs !== null) {
|
|
74
|
+
nearest ??= dir
|
|
75
|
+
if (claimsMember(dir, below, globs)) return dir
|
|
63
76
|
}
|
|
77
|
+
const parent = path.dirname(dir)
|
|
78
|
+
if (parent === dir) break
|
|
79
|
+
below.push(dir)
|
|
64
80
|
dir = parent
|
|
65
81
|
}
|
|
82
|
+
if (nearest !== null) return nearest
|
|
83
|
+
throw new UserError(
|
|
84
|
+
`Could not find a workspace root in any parent of ${start} ` +
|
|
85
|
+
`(looked for pnpm-workspace.yaml or package.json)`,
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** True when one of `below` (dirs under `root`, toward `start`) is a member. */
|
|
90
|
+
function claimsMember(root: string, below: readonly string[], globs: readonly string[]): boolean {
|
|
91
|
+
if (below.length === 0 || globs.length === 0) return false
|
|
92
|
+
const rels = below.map((d) => relPosix(root, d))
|
|
93
|
+
for (const pattern of globs) {
|
|
94
|
+
const normalized = pattern.replace(/\/+$/, '')
|
|
95
|
+
// `.` means the root itself is the project — never a directory below it.
|
|
96
|
+
if (normalized === '' || normalized === '.') continue
|
|
97
|
+
const glob = new Bun.Glob(normalized)
|
|
98
|
+
if (rels.some((rel) => glob.match(rel))) return true
|
|
99
|
+
}
|
|
100
|
+
return false
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Package globs declared by `dir`, or `null` when `dir` is not a root
|
|
105
|
+
* candidate. A bare `package.json` (no `workspaces`) is single-project mode:
|
|
106
|
+
* the root itself is the only project, hence `['.']`.
|
|
107
|
+
*/
|
|
108
|
+
async function readPackageGlobs(dir: string): Promise<string[] | null> {
|
|
109
|
+
const yamlPath = path.join(dir, 'pnpm-workspace.yaml')
|
|
110
|
+
if (await Bun.file(yamlPath).exists()) {
|
|
111
|
+
const parsed = (parseManifest(await Bun.file(yamlPath).text(), yamlPath, Bun.YAML.parse) ??
|
|
112
|
+
{}) as { packages?: unknown }
|
|
113
|
+
return assertGlobList(parsed.packages ?? [], yamlPath, 'packages')
|
|
114
|
+
}
|
|
115
|
+
const pkgPath = path.join(dir, 'package.json')
|
|
116
|
+
if (!(await Bun.file(pkgPath).exists())) return null
|
|
117
|
+
const pkg = parseManifest(await Bun.file(pkgPath).text(), pkgPath, JSON.parse) as PackageJson
|
|
118
|
+
const ws = pkg.workspaces as unknown
|
|
119
|
+
if (ws === undefined || ws === null) return ['.']
|
|
120
|
+
if (ws && typeof ws === 'object' && !Array.isArray(ws) && 'packages' in ws) {
|
|
121
|
+
return assertGlobList(
|
|
122
|
+
(ws as { packages?: unknown }).packages ?? [],
|
|
123
|
+
pkgPath,
|
|
124
|
+
'workspaces.packages',
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
return assertGlobList(ws, pkgPath, 'workspaces')
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Parse a workspace manifest, naming the FILE on failure. The raw parser
|
|
132
|
+
* errors (`Failed to parse JSON`) carry no path, so in a 1000-package
|
|
133
|
+
* monorepo they say nothing about which manifest is broken.
|
|
134
|
+
*/
|
|
135
|
+
function parseManifest(text: string, file: string, parse: (t: string) => unknown): unknown {
|
|
136
|
+
try {
|
|
137
|
+
return parse(text)
|
|
138
|
+
} catch (err) {
|
|
139
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
140
|
+
throw new UserError(`failed to parse ${file}: ${msg}`)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* A glob list is user input from a manifest: `packages: "packages/*"`
|
|
146
|
+
* (a bare string instead of a list) is an easy YAML slip, and without
|
|
147
|
+
* this it surfaces as `workspace.packageGlobs.map is not a function`.
|
|
148
|
+
*/
|
|
149
|
+
function assertGlobList(value: unknown, file: string, field: string): string[] {
|
|
150
|
+
if (!Array.isArray(value) || value.some((p) => typeof p !== 'string')) {
|
|
151
|
+
throw new UserError(`${file}: \`${field}\` must be an array of glob strings`)
|
|
152
|
+
}
|
|
153
|
+
return value as string[]
|
|
66
154
|
}
|
|
67
155
|
|
|
68
156
|
/**
|
|
@@ -86,83 +174,133 @@ export function resolveCacheDir(root: string, config: WorkspaceConfig | null): s
|
|
|
86
174
|
* itself is treated as a single-project workspace.
|
|
87
175
|
*/
|
|
88
176
|
export async function loadWorkspace(root: string): Promise<Workspace> {
|
|
89
|
-
const
|
|
90
|
-
if (
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
}
|
|
94
|
-
return { root, packageGlobs: parsed.packages ?? [] }
|
|
177
|
+
const packageGlobs = await readPackageGlobs(root)
|
|
178
|
+
if (packageGlobs === null) {
|
|
179
|
+
// Should be unreachable: findWorkspaceRoot only returns dirs that
|
|
180
|
+
// pass at least one of the two existence checks.
|
|
181
|
+
throw new UserError(`workspace root ${root} has neither pnpm-workspace.yaml nor package.json`)
|
|
95
182
|
}
|
|
183
|
+
return { root, packageGlobs }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// `<dir>/*` — the shape of nearly every workspace glob. Anything with another
|
|
187
|
+
// glob character (`**`, `{a,b}`, `?`, `[`) or a negation takes the general
|
|
188
|
+
// path.
|
|
189
|
+
const SIMPLE_STAR_RE = /^[^*?{}[\]!]+\/\*$/
|
|
96
190
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
191
|
+
/**
|
|
192
|
+
* The directories a workspace glob names. For the `<dir>/*` shape this is
|
|
193
|
+
* one readdir of `<dir>` — the same answer `Bun.Glob` gives, at a third of
|
|
194
|
+
* the cost (measured 2026-09-02: 25 ms → ~2 ms for 1000 members). A
|
|
195
|
+
* symlinked member is followed when it points at a directory, as the glob
|
|
196
|
+
* did. Dot-directories are skipped (`dot: false`), so are `node_modules`.
|
|
197
|
+
*/
|
|
198
|
+
async function memberDirs(root: string, pattern: string): Promise<string[]> {
|
|
199
|
+
// Normalize: `"."` -> the root itself; `"foo/"` -> `"foo"`.
|
|
200
|
+
const normalized = pattern === '.' ? '' : pattern.replace(/\/$/, '')
|
|
201
|
+
if (SIMPLE_STAR_RE.test(normalized)) {
|
|
202
|
+
const base = path.resolve(root, normalized.slice(0, -2))
|
|
203
|
+
let entries: import('node:fs').Dirent[]
|
|
204
|
+
try {
|
|
205
|
+
entries = await readdir(base, { withFileTypes: true })
|
|
206
|
+
} catch {
|
|
207
|
+
return []
|
|
208
|
+
}
|
|
209
|
+
const dirs: string[] = []
|
|
210
|
+
for (const e of entries) {
|
|
211
|
+
if (e.name.startsWith('.') || e.name === 'node_modules') continue
|
|
212
|
+
if (e.isDirectory()) dirs.push(path.join(base, e.name))
|
|
213
|
+
else if (e.isSymbolicLink()) {
|
|
214
|
+
try {
|
|
215
|
+
if ((await stat(path.join(base, e.name))).isDirectory())
|
|
216
|
+
dirs.push(path.join(base, e.name))
|
|
217
|
+
} catch {
|
|
218
|
+
// dangling link: not a member
|
|
219
|
+
}
|
|
220
|
+
}
|
|
104
221
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
222
|
+
return dirs
|
|
223
|
+
}
|
|
224
|
+
const globPattern = normalized === '' ? 'package.json' : `${normalized}/package.json`
|
|
225
|
+
const glob = new Bun.Glob(globPattern)
|
|
226
|
+
const dirs: string[] = []
|
|
227
|
+
for await (const rel of glob.scan({ cwd: root, onlyFiles: true, dot: false })) {
|
|
228
|
+
// Skip nested node_modules — workspace package globs shouldn't
|
|
229
|
+
// ever reach into them, but a pathological pattern like `**`
|
|
230
|
+
// would. Avoid splitting the path on the hot loop.
|
|
231
|
+
if (rel.includes(`${path.sep}node_modules${path.sep}`)) continue
|
|
232
|
+
if (rel.startsWith(`node_modules${path.sep}`)) continue
|
|
233
|
+
dirs.push(path.dirname(path.resolve(root, rel)))
|
|
108
234
|
}
|
|
235
|
+
return dirs
|
|
236
|
+
}
|
|
109
237
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
238
|
+
/**
|
|
239
|
+
* The project's config file, by `CONFIG_FILENAMES` precedence, or null.
|
|
240
|
+
* Stats the candidates in order rather than listing the directory: across
|
|
241
|
+
* 1000 projects a readdir each cost 9.6 ms against 6.3 ms of stats even
|
|
242
|
+
* with the config at the LAST name (measured 2026-09-03), and a `.ts`
|
|
243
|
+
* config — the first name — pays one stat.
|
|
244
|
+
*/
|
|
245
|
+
async function findConfigFile(dir: string): Promise<string | null> {
|
|
246
|
+
for (const name of CONFIG_FILENAMES) {
|
|
247
|
+
const candidate = path.join(dir, name)
|
|
248
|
+
try {
|
|
249
|
+
if ((await stat(candidate)).isFile()) return candidate
|
|
250
|
+
} catch {
|
|
251
|
+
// not this name
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return null
|
|
113
255
|
}
|
|
114
256
|
|
|
115
257
|
export async function listProjects(workspace: Workspace): Promise<ProjectMeta[]> {
|
|
116
258
|
// Run all package globs concurrently. Disk-bound walks parallelize
|
|
117
259
|
// well; serializing them just stretches the discovery phase by N×.
|
|
118
260
|
const perPattern = await Promise.all(
|
|
119
|
-
workspace.packageGlobs.map(
|
|
120
|
-
// Normalize: `"."` -> the root itself; `"foo/"` -> `"foo"`.
|
|
121
|
-
const normalized = pattern === '.' ? '' : pattern.replace(/\/$/, '')
|
|
122
|
-
const globPattern = normalized === '' ? 'package.json' : `${normalized}/package.json`
|
|
123
|
-
const glob = new Bun.Glob(globPattern)
|
|
124
|
-
const hits: string[] = []
|
|
125
|
-
for await (const rel of glob.scan({
|
|
126
|
-
cwd: workspace.root,
|
|
127
|
-
onlyFiles: true,
|
|
128
|
-
dot: false,
|
|
129
|
-
})) {
|
|
130
|
-
// Skip nested node_modules — workspace package globs shouldn't
|
|
131
|
-
// ever reach into them, but a pathological pattern like `**`
|
|
132
|
-
// would. Avoid splitting the path on the hot loop.
|
|
133
|
-
if (rel.includes(`${path.sep}node_modules${path.sep}`)) continue
|
|
134
|
-
if (rel.startsWith(`node_modules${path.sep}`)) continue
|
|
135
|
-
hits.push(path.resolve(workspace.root, rel))
|
|
136
|
-
}
|
|
137
|
-
return hits
|
|
138
|
-
}),
|
|
261
|
+
workspace.packageGlobs.map((pattern) => memberDirs(workspace.root, pattern)),
|
|
139
262
|
)
|
|
140
263
|
const matches = new Set<string>()
|
|
141
264
|
for (const arr of perPattern) for (const m of arr) matches.add(m)
|
|
142
265
|
|
|
143
|
-
// Per-project
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
266
|
+
// Per-project discovery: the manifest read answers "is there a
|
|
267
|
+
// package.json" (an ENOENT is the "not a member" answer, at the cost of
|
|
268
|
+
// one failed open) and `findConfigFile` answers "which config file", both
|
|
269
|
+
// in flight together. All async and all in flight at once — the thread
|
|
270
|
+
// pool runs them in parallel, and a sync read here measured 2× slower on
|
|
271
|
+
// a 1000-member workspace.
|
|
148
272
|
const loaded = await Promise.all(
|
|
149
|
-
[...matches].map(async (
|
|
150
|
-
const
|
|
151
|
-
const [
|
|
152
|
-
|
|
153
|
-
|
|
273
|
+
[...matches].map(async (dir) => {
|
|
274
|
+
const pkgJsonPath = path.join(dir, 'package.json')
|
|
275
|
+
const [configPath, text] = await Promise.all([
|
|
276
|
+
findConfigFile(dir),
|
|
277
|
+
Bun.file(pkgJsonPath)
|
|
278
|
+
.text()
|
|
279
|
+
.catch(() => null),
|
|
154
280
|
])
|
|
155
|
-
|
|
156
|
-
const
|
|
157
|
-
const configPath = configName !== undefined ? path.join(dir, configName) : null
|
|
281
|
+
if (text === null) return null
|
|
282
|
+
const pkg = parseManifest(text, pkgJsonPath, JSON.parse) as PackageJson
|
|
158
283
|
return { dir, pkg, configPath }
|
|
159
284
|
}),
|
|
160
285
|
)
|
|
161
286
|
|
|
162
287
|
const projects: ProjectMeta[] = []
|
|
163
288
|
const seenName = new Map<string, string>()
|
|
164
|
-
for (const
|
|
165
|
-
if (
|
|
289
|
+
for (const entry of loaded) {
|
|
290
|
+
if (entry === null) continue
|
|
291
|
+
const { dir, pkg, configPath } = entry
|
|
292
|
+
if (!pkg.name) {
|
|
293
|
+
// A nameless manifest can't be addressed, filtered, or made affected —
|
|
294
|
+
// and vx identifies projects by name, so it simply vanishes. Silent is
|
|
295
|
+
// fine for a dir that declares no tasks; a dir with a vx config was
|
|
296
|
+
// meant to run.
|
|
297
|
+
if (configPath !== null) {
|
|
298
|
+
process.stderr.write(
|
|
299
|
+
`vx: ${relPosix(workspace.root, dir)} has a vx config but its package.json has no "name" — skipped\n`,
|
|
300
|
+
)
|
|
301
|
+
}
|
|
302
|
+
continue
|
|
303
|
+
}
|
|
166
304
|
const previous = seenName.get(pkg.name)
|
|
167
305
|
if (previous) {
|
|
168
306
|
throw new UserError(
|
|
@@ -172,5 +310,8 @@ export async function listProjects(workspace: Workspace): Promise<ProjectMeta[]>
|
|
|
172
310
|
seenName.set(pkg.name, dir)
|
|
173
311
|
projects.push({ name: pkg.name, dir, packageJson: pkg, configPath })
|
|
174
312
|
}
|
|
175
|
-
|
|
313
|
+
// Code-unit order, not `localeCompare`: ICU collation cost 28 ms of a
|
|
314
|
+
// 300 ms warm run at 1000 projects, and a stable deterministic order is
|
|
315
|
+
// all any consumer needs.
|
|
316
|
+
return projects.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
|
|
176
317
|
}
|