@vzn/vx 0.0.595 → 0.0.597
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 +1 -1
- package/launcher.cjs +7 -3
- package/package.json +5 -5
- package/src/cache/archive.ts +35 -6
- package/src/cache/cache.ts +170 -99
- package/src/cache/chained-cache.ts +25 -7
- package/src/cache/git-inputs.ts +23 -0
- package/src/cache/index.ts +3 -0
- package/src/cache/inputs.ts +99 -44
- package/src/cache/key-fold.ts +1 -1
- package/src/cache/layer.ts +9 -6
- package/src/cache/policy.ts +5 -0
- package/src/cache/zstd.ts +10 -4
- package/src/cli/cache.ts +12 -6
- package/src/cli/foreign-flags.ts +57 -3
- package/src/cli/info.ts +1 -1
- package/src/cli/init.ts +2 -2
- package/src/cli/last.ts +10 -4
- package/src/cli/lock.ts +5 -3
- package/src/cli/plan-format.ts +4 -4
- package/src/cli/run.ts +73 -30
- package/src/cli/select.ts +6 -3
- package/src/cli/show.ts +8 -2
- package/src/cli/upgrade.ts +1 -1
- package/src/cli/watch-filter.ts +63 -12
- package/src/cli/watch-fs.ts +11 -3
- package/src/cli/watch-judge.ts +15 -8
- package/src/cli/watch.ts +18 -11
- package/src/cli/workspace-config.ts +3 -1
- package/src/config.ts +42 -21
- package/src/exec/executor.ts +1 -1
- package/src/exec/kill-tree.ts +89 -18
- package/src/exec/proc-sample.ts +1 -1
- package/src/exec/runner.ts +74 -36
- package/src/exec/sandbox-paths.ts +12 -1
- package/src/exec/sandbox-runtime.ts +283 -88
- package/src/exec/sandbox-violations.ts +115 -15
- package/src/graph/scheduler.ts +30 -14
- package/src/graph/task-graph.ts +265 -44
- package/src/orchestrator/admission.ts +12 -3
- package/src/orchestrator/affected-tasks.ts +6 -2
- package/src/orchestrator/deferred-outputs.ts +3 -2
- package/src/orchestrator/doctor.ts +2 -2
- package/src/orchestrator/events.ts +10 -1
- package/src/orchestrator/excluded-keys.ts +2 -13
- package/src/orchestrator/execute-task.ts +85 -39
- package/src/orchestrator/failure-mode.ts +43 -28
- package/src/orchestrator/framed-output.ts +8 -8
- package/src/orchestrator/index.ts +0 -1
- package/src/orchestrator/keyed-projects.ts +6 -3
- package/src/orchestrator/logger.ts +21 -9
- package/src/orchestrator/metrics.ts +19 -8
- package/src/orchestrator/miss-save.ts +9 -13
- package/src/orchestrator/options.ts +7 -0
- package/src/orchestrator/plan.ts +7 -23
- package/src/orchestrator/plugin.ts +37 -16
- package/src/orchestrator/prepare.ts +13 -4
- package/src/orchestrator/run-artifacts.ts +4 -3
- package/src/orchestrator/run-lock.ts +25 -24
- package/src/orchestrator/run.ts +22 -5
- package/src/orchestrator/sandbox-request.ts +96 -18
- package/src/orchestrator/save-lane.ts +5 -5
- package/src/orchestrator/signals.ts +22 -4
- package/src/orchestrator/stable-keys.ts +3 -11
- package/src/orchestrator/summary.ts +7 -13
- package/src/orchestrator/task-hash.ts +13 -3
- package/src/orchestrator/telemetry.ts +16 -7
- package/src/orchestrator/upstream.ts +11 -0
- package/src/util/hangup.ts +42 -0
- package/src/util/index.ts +2 -0
- package/src/util/paths.ts +24 -4
- package/src/workspace/affected.ts +79 -42
- package/src/workspace/config-eval.ts +27 -6
- package/src/workspace/config-imports.ts +8 -53
- package/src/workspace/config-schema.ts +56 -9
- package/src/workspace/filter.ts +19 -0
- package/src/workspace/lockfile.ts +2 -1
- package/src/workspace/migrate-scripts.ts +5 -3
- package/src/workspace/migration.ts +1 -1
- package/src/workspace/project-loader.ts +19 -4
- package/src/workspace/repo-id.ts +42 -4
- package/src/workspace/workspace.ts +53 -24
|
@@ -84,7 +84,10 @@ export interface AffectedArgs {
|
|
|
84
84
|
* (item 954). The `--affected` sugar's graph walk has already staged every
|
|
85
85
|
* config, so there it costs nothing more.
|
|
86
86
|
*/
|
|
87
|
-
workspaceGlobOwners?: (
|
|
87
|
+
workspaceGlobOwners?: (
|
|
88
|
+
changedPaths: readonly string[],
|
|
89
|
+
nested: readonly string[],
|
|
90
|
+
) => Promise<Iterable<string>>
|
|
88
91
|
/**
|
|
89
92
|
* The fingerprint files a plugin claims (`VxPlugin.fingerprint`) and its
|
|
90
93
|
* answer for a change to one. Resolved lazily: loading the workspace
|
|
@@ -142,6 +145,12 @@ export interface AffectedChanges {
|
|
|
142
145
|
/** Project → its changed paths, relative to the project's directory. */
|
|
143
146
|
paths: ReadonlyMap<string, readonly string[]>
|
|
144
147
|
whole: ReadonlySet<string>
|
|
148
|
+
/**
|
|
149
|
+
* The changed paths that are nested repositories (a submodule, an
|
|
150
|
+
* embedded repository), workspace-relative: each stands for every file
|
|
151
|
+
* in it, so a `workspaceFiles` glob that reaches under one is reached.
|
|
152
|
+
*/
|
|
153
|
+
nested?: readonly string[]
|
|
145
154
|
}
|
|
146
155
|
|
|
147
156
|
export async function affectedChanges(
|
|
@@ -221,16 +230,10 @@ export async function affectedChanges(
|
|
|
221
230
|
// submodules (`diff.ignoreSubmodules`, `submodule.<name>.ignore`), and
|
|
222
231
|
// then an edit inside one, or a committed bump, was no change here while
|
|
223
232
|
// the task's key moved (item 951).
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
'--relative',
|
|
229
|
-
'--name-only',
|
|
230
|
-
'-z',
|
|
231
|
-
'--end-of-options',
|
|
232
|
-
base,
|
|
233
|
-
]),
|
|
233
|
+
//
|
|
234
|
+
// `--raw` for each path's mode: a gitlink (160000) is a nested
|
|
235
|
+
// repository, one path standing for every file in it (`nested` below).
|
|
236
|
+
gitDiffPaths(args.workspaceRoot, base),
|
|
234
237
|
// `git diff` never reports untracked-but-not-ignored files, but input
|
|
235
238
|
// enumeration does (`git ls-files --cached --others --exclude-standard`),
|
|
236
239
|
// so a brand-new source file changes a task's cache key. Union it in or
|
|
@@ -244,9 +247,13 @@ export async function affectedChanges(
|
|
|
244
247
|
// file: re-running `vx lock` must not mark every project affected.
|
|
245
248
|
// An untracked file under `node_modules` is an install, which the input
|
|
246
249
|
// enumeration drops too (`isInstalledPath`); a tracked one is a source.
|
|
247
|
-
const changed = [...diffed, ...untracked.filter((s) => !isInstalledPath(s))].filter(
|
|
250
|
+
const changed = [...diffed.paths, ...untracked.filter((s) => !isInstalledPath(s))].filter(
|
|
248
251
|
(s) => s !== LOCKFILE_NAME,
|
|
249
252
|
)
|
|
253
|
+
// The nested repositories git reports as one path: a gitlink, or an
|
|
254
|
+
// untracked embedded repository (`dir/`, the only path git prints so).
|
|
255
|
+
const nested = new Set(diffed.gitlinks)
|
|
256
|
+
for (const rel of untracked) if (rel.endsWith('/')) nested.add(rel)
|
|
250
257
|
|
|
251
258
|
// A lockfile or workspace-definition change re-keys EVERY task, because the
|
|
252
259
|
// workspace fingerprint folds those files into every cache key. Mapping
|
|
@@ -316,6 +323,7 @@ export async function affectedChanges(
|
|
|
316
323
|
const owned = projectsContaining(
|
|
317
324
|
args.workspaceRoot,
|
|
318
325
|
changed,
|
|
326
|
+
nested,
|
|
319
327
|
args.projects,
|
|
320
328
|
realDirs,
|
|
321
329
|
perTask
|
|
@@ -371,15 +379,21 @@ export async function affectedChanges(
|
|
|
371
379
|
projects: args.projects,
|
|
372
380
|
changed,
|
|
373
381
|
skip: perTask ? new Set() : owned,
|
|
374
|
-
realDirs,
|
|
375
382
|
})) {
|
|
376
383
|
owned.add(name)
|
|
377
384
|
whole.add(name)
|
|
378
385
|
}
|
|
379
386
|
|
|
380
|
-
const
|
|
387
|
+
const nestedDirs = [...nested].map((rel) => rel.replace(/\/$/, ''))
|
|
388
|
+
const done = (): AffectedChanges => ({
|
|
389
|
+
projects: owned,
|
|
390
|
+
changed,
|
|
391
|
+
paths,
|
|
392
|
+
whole,
|
|
393
|
+
...(nestedDirs.length > 0 ? { nested: nestedDirs } : {}),
|
|
394
|
+
})
|
|
381
395
|
if (changed.length === 0 || args.workspaceGlobOwners === undefined) return done()
|
|
382
|
-
for (const name of await args.workspaceGlobOwners(changed)) owned.add(name)
|
|
396
|
+
for (const name of await args.workspaceGlobOwners(changed, nestedDirs)) owned.add(name)
|
|
383
397
|
return done()
|
|
384
398
|
}
|
|
385
399
|
|
|
@@ -645,6 +659,37 @@ async function gitPaths(workspaceRoot: string, cmd: string[]): Promise<string[]>
|
|
|
645
659
|
return stdout.split('\0').filter((s) => s.length > 0)
|
|
646
660
|
}
|
|
647
661
|
|
|
662
|
+
/**
|
|
663
|
+
* `git diff --raw -z <base>`'s paths, and those that are a gitlink on
|
|
664
|
+
* either side: one the change removed took its files with it. Each record
|
|
665
|
+
* is `:<mode> <mode> <oid> <oid> <status>` then its path (one path:
|
|
666
|
+
* `--no-renames`).
|
|
667
|
+
*/
|
|
668
|
+
async function gitDiffPaths(
|
|
669
|
+
workspaceRoot: string,
|
|
670
|
+
base: string,
|
|
671
|
+
): Promise<{ paths: string[]; gitlinks: string[] }> {
|
|
672
|
+
const fields = await gitPaths(workspaceRoot, [
|
|
673
|
+
'diff',
|
|
674
|
+
'--no-renames',
|
|
675
|
+
'--ignore-submodules=none',
|
|
676
|
+
'--relative',
|
|
677
|
+
'--raw',
|
|
678
|
+
'-z',
|
|
679
|
+
'--end-of-options',
|
|
680
|
+
base,
|
|
681
|
+
])
|
|
682
|
+
const paths: string[] = []
|
|
683
|
+
const gitlinks: string[] = []
|
|
684
|
+
for (let i = 0; i + 1 < fields.length; i += 2) {
|
|
685
|
+
const rel = fields[i + 1]!
|
|
686
|
+
paths.push(rel)
|
|
687
|
+
const head = fields[i]!
|
|
688
|
+
if (head.startsWith('160000 ', 1) || head.startsWith('160000 ', 8)) gitlinks.push(rel)
|
|
689
|
+
}
|
|
690
|
+
return { paths, gitlinks }
|
|
691
|
+
}
|
|
692
|
+
|
|
648
693
|
/**
|
|
649
694
|
* Resolve the default base for `--affected` with no explicit value.
|
|
650
695
|
* Tries the remote's HEAD branch first (`origin/main`, `origin/master`,
|
|
@@ -826,17 +871,11 @@ async function verifyRef(workspaceRoot: string, ref: string): Promise<void> {
|
|
|
826
871
|
)
|
|
827
872
|
}
|
|
828
873
|
|
|
829
|
-
function isDirectory(abs: string): boolean {
|
|
830
|
-
try {
|
|
831
|
-
return statSync(abs).isDirectory()
|
|
832
|
-
} catch {
|
|
833
|
-
return false
|
|
834
|
-
}
|
|
835
|
-
}
|
|
836
|
-
|
|
837
874
|
function projectsContaining(
|
|
838
875
|
workspaceRoot: string,
|
|
839
876
|
changedRelPaths: readonly string[],
|
|
877
|
+
/** The changed paths that are nested repositories (`nested` in `affectedChanges`). */
|
|
878
|
+
nested: ReadonlySet<string>,
|
|
840
879
|
projects: readonly ProjectMeta[],
|
|
841
880
|
realDirs: ReadonlyMap<string, string>,
|
|
842
881
|
/** Told each owned path relative to its project, and each nested repository's projects. */
|
|
@@ -868,37 +907,35 @@ function projectsContaining(
|
|
|
868
907
|
}
|
|
869
908
|
const owned = new Set<string>()
|
|
870
909
|
for (const rel of changedRelPaths) {
|
|
910
|
+
const repo = nested.has(rel)
|
|
871
911
|
let dir = path.resolve(workspaceRoot, rel).normalize('NFC')
|
|
872
|
-
let hit = false
|
|
873
912
|
for (;;) {
|
|
874
913
|
const name = dirToName.get(dir)
|
|
875
914
|
if (name !== undefined) {
|
|
876
915
|
owned.add(name)
|
|
877
916
|
on?.path(name, relPosix(dir, path.resolve(workspaceRoot, rel).normalize('NFC')))
|
|
878
|
-
|
|
917
|
+
// Its files are the project's inputs, and none of them is this
|
|
918
|
+
// path: a task's globs cannot say whether they reach inside.
|
|
919
|
+
if (repo) on?.repo(name)
|
|
879
920
|
break
|
|
880
921
|
}
|
|
881
922
|
const parent = path.dirname(dir)
|
|
882
923
|
if (parent === dir) break // reached the filesystem root
|
|
883
924
|
dir = parent
|
|
884
925
|
}
|
|
885
|
-
if (
|
|
886
|
-
// A
|
|
887
|
-
//
|
|
888
|
-
//
|
|
889
|
-
//
|
|
890
|
-
//
|
|
891
|
-
//
|
|
892
|
-
//
|
|
893
|
-
|
|
894
|
-
const
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
if (!dir.startsWith(prefix)) continue
|
|
899
|
-
owned.add(name)
|
|
900
|
-
on?.repo(name)
|
|
901
|
-
}
|
|
926
|
+
if (!repo) continue
|
|
927
|
+
// A submodule whose checkout moved or is dirty (`vendor/sub`), an
|
|
928
|
+
// embedded repository left untracked (`vendor/nested/`): the workspace
|
|
929
|
+
// repository sees the nested one as that single path, so a change
|
|
930
|
+
// inside is a change to it, and every project under it is affected.
|
|
931
|
+
// Its own enumeration already keys those projects on their files
|
|
932
|
+
// (git-inputs.ts); without this, `--affected` after an edit inside
|
|
933
|
+
// selected none of them.
|
|
934
|
+
const prefix = path.resolve(workspaceRoot, rel).normalize('NFC') + path.sep
|
|
935
|
+
for (const [dir, name] of dirToName) {
|
|
936
|
+
if (!dir.startsWith(prefix)) continue
|
|
937
|
+
owned.add(name)
|
|
938
|
+
on?.repo(name)
|
|
902
939
|
}
|
|
903
940
|
}
|
|
904
941
|
return owned
|
|
@@ -185,12 +185,16 @@ self.onmessage = async (e) => {
|
|
|
185
185
|
: thrown
|
|
186
186
|
const report = changed()
|
|
187
187
|
umaskBack()
|
|
188
|
+
// A thrown string or plain object has no message, and String() of a
|
|
189
|
+
// null-prototype object throws here, inside the catch.
|
|
190
|
+
const isError = err !== null && typeof err === 'object' && typeof err.message === 'string'
|
|
188
191
|
postMessage({
|
|
189
192
|
id,
|
|
190
193
|
ok: false,
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
+
thrown: isError ? null : globalThis.Bun.inspect(err, { compact: true }),
|
|
195
|
+
name: isError ? (err.name ?? 'Error') : 'Error',
|
|
196
|
+
message: isError ? err.message : '',
|
|
197
|
+
stack: isError ? (err.stack ?? null) : null,
|
|
194
198
|
changed: report,
|
|
195
199
|
position:
|
|
196
200
|
err?.position && typeof err.position === 'object'
|
|
@@ -214,6 +218,8 @@ interface WorkerReply {
|
|
|
214
218
|
nonJson: NonJsonValue[]
|
|
215
219
|
name: string
|
|
216
220
|
message: string
|
|
221
|
+
/** What a config threw that is not an Error, as `thrownValueMessage` shows it; null for an Error. */
|
|
222
|
+
thrown?: string | null
|
|
217
223
|
stack: string | null
|
|
218
224
|
/** A transpile error's location — `BuildMessage.position`, trimmed to what the loader reads. */
|
|
219
225
|
position: { file?: string; line?: number; column?: number } | null
|
|
@@ -222,6 +228,16 @@ interface WorkerReply {
|
|
|
222
228
|
interface Pending {
|
|
223
229
|
resolve: (reply: WorkerReply) => void
|
|
224
230
|
reject: (err: Error) => void
|
|
231
|
+
configPath: string
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* A config threw something that is not an Error: `throw 'no'` printed
|
|
236
|
+
* `vx: no`, naming no file, and a null-prototype object crashed vx's own
|
|
237
|
+
* error printer with a stack.
|
|
238
|
+
*/
|
|
239
|
+
export function thrownValueMessage(kind: string, configPath: string, shown: string): string {
|
|
240
|
+
return `${kind} config ${configPath} threw ${shown}, which is not an Error`
|
|
225
241
|
}
|
|
226
242
|
|
|
227
243
|
/**
|
|
@@ -236,10 +252,11 @@ export function evalBudgetMs(): number {
|
|
|
236
252
|
// BOUND on a worker that may be wedged, with no "no limit" reading. Falls
|
|
237
253
|
// back rather than clamping, because honouring ~24.8 days would hang
|
|
238
254
|
// `vx watch` forever on a worker the OS killed — and unbounded makes it 1 ms,
|
|
239
|
-
// so EVERY config load times out instead. Both ends break the same feature
|
|
255
|
+
// so EVERY config load times out instead. Both ends break the same feature,
|
|
256
|
+
// and `0` is the second end: it fired on the next tick (D-157).
|
|
240
257
|
if (raw !== undefined && /^[0-9]+$/.test(raw)) {
|
|
241
258
|
const n = Number(raw)
|
|
242
|
-
if (n <= MAX_TIMEOUT_MS) return n
|
|
259
|
+
if (n > 0 && n <= MAX_TIMEOUT_MS) return n
|
|
243
260
|
}
|
|
244
261
|
return 30_000
|
|
245
262
|
}
|
|
@@ -287,6 +304,10 @@ function acquireWorker(): Worker {
|
|
|
287
304
|
p.resolve(msg)
|
|
288
305
|
return
|
|
289
306
|
}
|
|
307
|
+
if (typeof msg.thrown === 'string') {
|
|
308
|
+
p.reject(new UserError(thrownValueMessage('Project', p.configPath, msg.thrown)))
|
|
309
|
+
return
|
|
310
|
+
}
|
|
290
311
|
// Rebuild the error the config actually threw. Name, message, stack
|
|
291
312
|
// and a transpile error's position all survive the hop, so a broken
|
|
292
313
|
// config reports the same text it would from an in-process import.
|
|
@@ -365,7 +386,7 @@ export async function evaluateConfigFresh(configPath: string): Promise<unknown>
|
|
|
365
386
|
// evaluation costs, so it can only fire on a genuine wedge.
|
|
366
387
|
const budget = evalBudgetMs()
|
|
367
388
|
const reply = await new Promise<WorkerReply>((resolve, reject) => {
|
|
368
|
-
pending.set(id, { resolve, reject })
|
|
389
|
+
pending.set(id, { resolve, reject, configPath })
|
|
369
390
|
timer = setTimeout(() => {
|
|
370
391
|
rejectAll(new Error(`config worker did not answer within ${budget}ms`))
|
|
371
392
|
if (worker !== null) {
|
|
@@ -20,13 +20,13 @@
|
|
|
20
20
|
// the lockfile moves, which the workspace fingerprint already covers.
|
|
21
21
|
// A tsconfig `paths` / `baseUrl` alias is the exception: Bun loads it
|
|
22
22
|
// from disk (D-27).
|
|
23
|
-
// -
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
23
|
+
// - One reversed BFS from every config at once, each file read once
|
|
24
|
+
// however many configs reach it. It descends through every file in
|
|
25
|
+
// the workspace, another project's included: a site's `vx.config.ts`
|
|
26
|
+
// importing `../core/src/index.ts` evaluates what that file imports
|
|
27
|
+
// too, so an edit there moves the site's key. The walk stopped at the
|
|
28
|
+
// first file of another project, and containment selected core alone
|
|
29
|
+
// while the site's key moved (X-128).
|
|
30
30
|
|
|
31
31
|
import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'
|
|
32
32
|
import { realpath } from 'node:fs/promises'
|
|
@@ -393,32 +393,6 @@ export interface ConfigImportOwnersArgs {
|
|
|
393
393
|
changed: readonly string[]
|
|
394
394
|
/** Already-selected projects; their configs need no scan. */
|
|
395
395
|
skip: ReadonlySet<string>
|
|
396
|
-
/**
|
|
397
|
-
* Each project dir's realpath, when the caller already has them: the
|
|
398
|
-
* containment pass needs the same answers, and asking twice cost 5,000
|
|
399
|
-
* realpaths at 5,000 projects (D-24).
|
|
400
|
-
*/
|
|
401
|
-
realDirs?: ReadonlyMap<string, string>
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
/**
|
|
405
|
-
* Project dir → name, REALPATH'D — which is why this cannot reuse the index
|
|
406
|
-
* the containment pass builds. `Bun.resolveSync` hands back realpath'd
|
|
407
|
-
* targets, and on darwin a workspace under `os.tmpdir()` lives at
|
|
408
|
-
* `/var/folders/…` while its realpath is `/private/var/folders/…`; comparing
|
|
409
|
-
* the two matches nothing and fails exactly like "found no imports".
|
|
410
|
-
*/
|
|
411
|
-
async function realDirIndex(
|
|
412
|
-
projects: readonly ProjectMeta[],
|
|
413
|
-
known: ReadonlyMap<string, string> | undefined,
|
|
414
|
-
): Promise<Map<string, string>> {
|
|
415
|
-
const out = new Map<string, string>()
|
|
416
|
-
await Promise.all(
|
|
417
|
-
projects.map(async (p) => {
|
|
418
|
-
out.set(known?.get(p.dir) ?? (await realpath(p.dir).catch(() => p.dir)), p.name)
|
|
419
|
-
}),
|
|
420
|
-
)
|
|
421
|
-
return out
|
|
422
396
|
}
|
|
423
397
|
|
|
424
398
|
const TS_EXT = new Set(['.ts', '.mts', '.cts'])
|
|
@@ -426,18 +400,6 @@ const TS_EXT = new Set(['.ts', '.mts', '.cts'])
|
|
|
426
400
|
/** The extensions Bun tries for a specifier that names none. */
|
|
427
401
|
const RESOLVED_EXTENSIONS = ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.json']
|
|
428
402
|
|
|
429
|
-
/** The deepest project containing `file`, or undefined when none does. */
|
|
430
|
-
function ownerOf(file: string, dirToName: ReadonlyMap<string, string>): string | undefined {
|
|
431
|
-
let dir = path.dirname(file)
|
|
432
|
-
for (;;) {
|
|
433
|
-
const name = dirToName.get(dir)
|
|
434
|
-
if (name !== undefined) return name
|
|
435
|
-
const parent = path.dirname(dir)
|
|
436
|
-
if (parent === dir) return undefined
|
|
437
|
-
dir = parent
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
|
|
441
403
|
/** Projects whose config file transitively imports one of `changed`. */
|
|
442
404
|
export async function configImportOwners(a: ConfigImportOwnersArgs): Promise<Set<string>> {
|
|
443
405
|
const selected = new Set<string>()
|
|
@@ -453,10 +415,6 @@ export async function configImportOwners(a: ConfigImportOwnersArgs): Promise<Set
|
|
|
453
415
|
}),
|
|
454
416
|
)
|
|
455
417
|
if (roots.size === 0) return selected
|
|
456
|
-
const dirToName = await realDirIndex(a.projects, a.realDirs)
|
|
457
|
-
// A root project owns every file no member owns: the shared tooling the
|
|
458
|
-
// walk descends through when the root is no project (D-41).
|
|
459
|
-
const rootOwner = dirToName.get(workspaceRoot)
|
|
460
418
|
|
|
461
419
|
// target → the files that import it. Reversed up front so one BFS from the
|
|
462
420
|
// changed set answers every root at once, instead of a walk per root.
|
|
@@ -492,10 +450,7 @@ export async function configImportOwners(a: ConfigImportOwnersArgs): Promise<Set
|
|
|
492
450
|
const list = importedBy.get(target)
|
|
493
451
|
if (list) list.push(file)
|
|
494
452
|
else importedBy.set(target, [file])
|
|
495
|
-
|
|
496
|
-
// see the header.
|
|
497
|
-
const owner = ownerOf(target, dirToName)
|
|
498
|
-
if (owner === undefined || owner === rootOwner) frontier.push(target)
|
|
453
|
+
frontier.push(target)
|
|
499
454
|
}
|
|
500
455
|
}
|
|
501
456
|
}
|
|
@@ -76,6 +76,16 @@ function validateRules(rules: unknown, configPath: string): void {
|
|
|
76
76
|
|
|
77
77
|
const RETENTION_FIELDS = new Set(['olderThan', 'maxSize'])
|
|
78
78
|
|
|
79
|
+
/**
|
|
80
|
+
* A refused value as a message quotes it. A workspace file is evaluated
|
|
81
|
+
* in-process and no JSON rule precedes its schema, so a value here may be
|
|
82
|
+
* one `JSON.stringify` throws on (`30n`) or drops (a symbol): the refusal
|
|
83
|
+
* became a TypeError with a stack.
|
|
84
|
+
*/
|
|
85
|
+
function shown(v: unknown): string {
|
|
86
|
+
return typeof v === 'bigint' ? `${v}n` : (JSON.stringify(v) ?? String(v))
|
|
87
|
+
}
|
|
88
|
+
|
|
79
89
|
function validateRetention(retention: unknown, configPath: string): void {
|
|
80
90
|
const where = `${configPath}: \`cacheRetention\``
|
|
81
91
|
const field = (key: string) => `${configPath}: \`cacheRetention.${key}\``
|
|
@@ -93,12 +103,12 @@ function validateRetention(retention: unknown, configPath: string): void {
|
|
|
93
103
|
(typeof olderThan !== 'string' || parseDuration(olderThan) === null)
|
|
94
104
|
) {
|
|
95
105
|
throw new UserError(
|
|
96
|
-
`${field('olderThan')} must be a duration like '30d', '12h', '90m' or '45s' (got ${
|
|
106
|
+
`${field('olderThan')} must be a duration like '30d', '12h', '90m' or '45s' (got ${shown(olderThan)})`,
|
|
97
107
|
)
|
|
98
108
|
}
|
|
99
109
|
if (maxSize !== undefined && (typeof maxSize !== 'string' || parseSize(maxSize) === null)) {
|
|
100
110
|
throw new UserError(
|
|
101
|
-
`${field('maxSize')} must be a size like '10G', '500MB' or '64KB' (got ${
|
|
111
|
+
`${field('maxSize')} must be a size like '10G', '500MB' or '64KB' (got ${shown(maxSize)})`,
|
|
102
112
|
)
|
|
103
113
|
}
|
|
104
114
|
// The bounds `vx cache prune` refuses, for the same reason: each evicts
|
|
@@ -144,6 +154,11 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
|
|
|
144
154
|
) {
|
|
145
155
|
throw new UserError(`${configPath}: \`cacheDir\` is only whitespace — name a directory`)
|
|
146
156
|
}
|
|
157
|
+
// No path can carry one: the mkdir's argument error was reported as an
|
|
158
|
+
// unwritable workspace.
|
|
159
|
+
if (typeof config.cacheDir === 'string' && config.cacheDir.includes('\0')) {
|
|
160
|
+
throw new UserError(`${configPath}: \`cacheDir\` holds a NUL, which no path can carry`)
|
|
161
|
+
}
|
|
147
162
|
if (config.timeout !== undefined) {
|
|
148
163
|
if (
|
|
149
164
|
typeof config.timeout !== 'number' ||
|
|
@@ -156,8 +171,12 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
|
|
|
156
171
|
}
|
|
157
172
|
if (config.cacheRetention !== undefined) validateRetention(config.cacheRetention, configPath)
|
|
158
173
|
if (config.affectedBase !== undefined) {
|
|
159
|
-
// A leading '-' would reach git as an option, not a ref
|
|
160
|
-
|
|
174
|
+
// A leading '-' would reach git as an option, not a ref; a NUL fails the
|
|
175
|
+
// spawn with an argument error naming nothing in this file.
|
|
176
|
+
if (
|
|
177
|
+
typeof config.affectedBase !== 'string' ||
|
|
178
|
+
!/^[^-\s\0][^\s\0]*$/.test(config.affectedBase)
|
|
179
|
+
) {
|
|
161
180
|
throw new UserError(`${configPath}: \`affectedBase\` must be a git ref like 'origin/main'`)
|
|
162
181
|
}
|
|
163
182
|
}
|
|
@@ -170,7 +189,7 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
|
|
|
170
189
|
throw new UserError(`${configPath}: \`plugins\` must be an array of plugin objects`)
|
|
171
190
|
}
|
|
172
191
|
const verbOwners = new Map<string, string>()
|
|
173
|
-
const fileClaimants = new Map<string, string>()
|
|
192
|
+
const fileClaimants = new Map<string, { name: string; at: number }>()
|
|
174
193
|
for (const [i, p] of config.plugins.entries()) {
|
|
175
194
|
if (p === null || typeof p !== 'object') {
|
|
176
195
|
// Nx's nx.json lists plugins by module name (`'@nx/vite/plugin'`);
|
|
@@ -190,6 +209,13 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
|
|
|
190
209
|
// one whose `name` was overwritten after the stamp, is refused here —
|
|
191
210
|
// the one boundary every plugin crosses.
|
|
192
211
|
const pkg = (p as Record<symbol, unknown>)[PLUGIN_PACKAGE]
|
|
212
|
+
// An async factory called without `await` was told its plugin did not
|
|
213
|
+
// come from definePlugin — it had (D-159).
|
|
214
|
+
if (pkg === undefined && typeof (p as { then?: unknown }).then === 'function') {
|
|
215
|
+
throw new UserError(
|
|
216
|
+
`${configPath}: \`plugins[${i}]\` is a Promise — await the factory that returns the plugin`,
|
|
217
|
+
)
|
|
218
|
+
}
|
|
193
219
|
if (typeof pkg !== 'string' || pkg.length === 0) {
|
|
194
220
|
throw new UserError(
|
|
195
221
|
`${configPath}: \`plugins[${i}]\` must come from definePlugin(import.meta, { … }) — a plugin's name is its package name`,
|
|
@@ -308,16 +334,23 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
|
|
|
308
334
|
/[/\\]/.test(file)
|
|
309
335
|
) {
|
|
310
336
|
throw new UserError(
|
|
311
|
-
`${configPath}: plugin '${plug.name}' claims fingerprint file ${
|
|
337
|
+
`${configPath}: plugin '${plug.name}' claims fingerprint file ${shown(file)}, which is not a file name at the workspace root`,
|
|
312
338
|
)
|
|
313
339
|
}
|
|
314
340
|
const owner = fileClaimants.get(file)
|
|
341
|
+
// One package twice (`plugins: [bun(), bun()]`) read "plugins 'x'
|
|
342
|
+
// and 'x' both claim" (D-158).
|
|
343
|
+
if (owner !== undefined && owner.name === plug.name) {
|
|
344
|
+
throw new UserError(
|
|
345
|
+
`${configPath}: plugin '${plug.name}' claims fingerprint file '${file}' twice (plugins[${owner.at}] and plugins[${i}]) — a file has one claimant; declare the plugin once`,
|
|
346
|
+
)
|
|
347
|
+
}
|
|
315
348
|
if (owner !== undefined) {
|
|
316
349
|
throw new UserError(
|
|
317
|
-
`${configPath}: plugins '${owner}' and '${plug.name}' both claim fingerprint file '${file}' — a file has one claimant`,
|
|
350
|
+
`${configPath}: plugins '${owner.name}' and '${plug.name}' both claim fingerprint file '${file}' — a file has one claimant`,
|
|
318
351
|
)
|
|
319
352
|
}
|
|
320
|
-
fileClaimants.set(file, plug.name)
|
|
353
|
+
fileClaimants.set(file, { name: plug.name, at: i })
|
|
321
354
|
}
|
|
322
355
|
}
|
|
323
356
|
// A plugin must contribute at least one capability or lifecycle hook
|
|
@@ -463,6 +496,8 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
|
|
|
463
496
|
`(got "${wild}") — list explicit env var names instead`,
|
|
464
497
|
)
|
|
465
498
|
}
|
|
499
|
+
for (const n of passThrough as string[])
|
|
500
|
+
assertShellName(n, `${where}.exec.env.passThrough`)
|
|
466
501
|
}
|
|
467
502
|
const secret = (env as { secret?: unknown }).secret
|
|
468
503
|
if (
|
|
@@ -489,6 +524,7 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
|
|
|
489
524
|
`${where}.exec.env.define: ${JSON.stringify(k)} is not an env var name (non-empty, no '=' or NUL)`,
|
|
490
525
|
)
|
|
491
526
|
}
|
|
527
|
+
assertShellName(k, `${where}.exec.env.define`)
|
|
492
528
|
if (typeof val !== 'string' || val.includes('\0')) {
|
|
493
529
|
throw new UserError(`${where}.exec.env.define.${k} must be a string with no NUL`)
|
|
494
530
|
}
|
|
@@ -1350,8 +1386,19 @@ function specForm(spec: string): SpecForm {
|
|
|
1350
1386
|
* A name an environment can hold. An `=` splits at the first one, so
|
|
1351
1387
|
* `define: { 'A=B': 'x' }` gave the child `A` with the value `B=x`; `''`
|
|
1352
1388
|
* was dropped; a NUL failed the spawn with a hint about exit 127 (item
|
|
1353
|
-
* 999).
|
|
1389
|
+
* 999). And the task runs under `sh -c`, whose dash (Linux) drops a
|
|
1390
|
+
* variable whose name is no shell identifier: `my.var` reached a task
|
|
1391
|
+
* under macOS's bash and nothing on Linux. Refused at load, where the
|
|
1392
|
+
* config is named.
|
|
1354
1393
|
*/
|
|
1394
|
+
function assertShellName(name: string, field: string): void {
|
|
1395
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
|
|
1396
|
+
throw new UserError(
|
|
1397
|
+
`${field}: ${JSON.stringify(name)} is not a shell variable name ([A-Za-z_][A-Za-z0-9_]*); sh would drop it before the task runs`,
|
|
1398
|
+
)
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1355
1402
|
function isEnvName(name: unknown): name is string {
|
|
1356
1403
|
return typeof name === 'string' && name.length > 0 && !name.includes('=') && !name.includes('\0')
|
|
1357
1404
|
}
|
package/src/workspace/filter.ts
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
// {<dir>} same as ./<dir>
|
|
6
6
|
// // the workspace-root project only (Turbo's name for the root)
|
|
7
7
|
// tag:<pattern> the projects whose config `tags` hold a match (Nx's `tag:`)
|
|
8
|
+
// <dir>/<glob> a name with a `/` outside a scope that names no project:
|
|
9
|
+
// ./<dir>/<glob> (Nx's `--projects 'apps/*'`)
|
|
8
10
|
// <pattern>... pattern + its transitive workspace dependencies
|
|
9
11
|
// ...<pattern> pattern + its transitive workspace dependents
|
|
10
12
|
// <pattern>^... only the transitive deps of pattern (excluding the matched package)
|
|
@@ -58,6 +60,11 @@ export interface ParsedFilter {
|
|
|
58
60
|
exactDir?: true
|
|
59
61
|
/** `tag:<pattern>`: `matcher` is a glob over the projects' tags, not their names. */
|
|
60
62
|
tag?: true
|
|
63
|
+
/**
|
|
64
|
+
* `apps/*`: a name pattern holding a `/` outside a scope, read as
|
|
65
|
+
* `./apps/*` when it names no project, as Nx's `--projects` reads it.
|
|
66
|
+
*/
|
|
67
|
+
dirFallback?: ParsedFilter
|
|
61
68
|
}
|
|
62
69
|
|
|
63
70
|
export function parseFilter(raw: string, workspaceRoot: string): ParsedFilter {
|
|
@@ -187,6 +194,15 @@ export function parseFilter(raw: string, workspaceRoot: string): ParsedFilter {
|
|
|
187
194
|
}
|
|
188
195
|
}
|
|
189
196
|
|
|
197
|
+
// A package name holds a `/` only after its `@scope`, so `apps/*` is a
|
|
198
|
+
// directory for every project named after its package.json; Nx matches
|
|
199
|
+
// a `--projects` entry by name, then by root (find-matching-projects,
|
|
200
|
+
// 23.2.1), and `-p 'apps/*'` matched nothing here.
|
|
201
|
+
const dirFallback =
|
|
202
|
+
!isPath && s.includes('/') && !s.startsWith('@')
|
|
203
|
+
? parseFilter(`./${s}`, workspaceRoot)
|
|
204
|
+
: undefined
|
|
205
|
+
|
|
190
206
|
return {
|
|
191
207
|
raw,
|
|
192
208
|
negate,
|
|
@@ -196,6 +212,7 @@ export function parseFilter(raw: string, workspaceRoot: string): ParsedFilter {
|
|
|
196
212
|
onlyDependents,
|
|
197
213
|
isPath,
|
|
198
214
|
matcher,
|
|
215
|
+
...(dirFallback !== undefined ? { dirFallback } : {}),
|
|
199
216
|
...(pathGlob !== undefined ? { pathGlob, pathRoot: workspaceRoot } : {}),
|
|
200
217
|
...(pathGlobBase !== undefined ? { pathGlobBase } : {}),
|
|
201
218
|
...(gitSince !== undefined ? { gitSince } : {}),
|
|
@@ -278,6 +295,8 @@ function matchSelector(
|
|
|
278
295
|
for (const p of projects) {
|
|
279
296
|
if (re.test(p.name)) out.push(p.name)
|
|
280
297
|
}
|
|
298
|
+
if (out.length === 0 && filter.dirFallback !== undefined)
|
|
299
|
+
return matchSelector(filter.dirFallback, projects, tags)
|
|
281
300
|
if (out.length > 0 || filter.matcher.includes('/')) return out
|
|
282
301
|
// pnpm's rule: the scope may be left out (`--filter core` is
|
|
283
302
|
// `@babel/core`), an exact name only when one package carries it. Nx
|
|
@@ -152,7 +152,8 @@ export async function frozenProjectConfigs(
|
|
|
152
152
|
): Promise<ProjectConfig[]> {
|
|
153
153
|
const entries = metas.map((meta) => {
|
|
154
154
|
const rel = relPosix(root, meta.configPath)
|
|
155
|
-
|
|
155
|
+
// Own entries only: `constructor` read Object's, "locked" at undefined (D-152).
|
|
156
|
+
const entry = Object.hasOwn(lock.projects, meta.name) ? lock.projects[meta.name] : undefined
|
|
156
157
|
if (entry !== undefined && entry.configPath !== rel) {
|
|
157
158
|
// A renamed or moved config: "has no entry for a" named an entry the
|
|
158
159
|
// lock holds, under its old path (D-88).
|
|
@@ -429,7 +429,8 @@ function pmRunsMembers(script: string): boolean {
|
|
|
429
429
|
return false
|
|
430
430
|
}
|
|
431
431
|
|
|
432
|
-
|
|
432
|
+
// A bare target may escape a space or paren (`cd packages/my\ app`).
|
|
433
|
+
const CD = /(?:^|[\s;&|(])cd\s+("[^"]*"|'[^']*'|(?:\\.|[^\s;&|()\\])+)/g
|
|
433
434
|
|
|
434
435
|
/**
|
|
435
436
|
* A `cd` into a member's directory, or one holding members, runs that
|
|
@@ -439,7 +440,8 @@ const CD = /(?:^|[\s;&|(])cd\s+("[^"]*"|'[^']*'|[^\s;&|()]+)/g
|
|
|
439
440
|
*/
|
|
440
441
|
function cdsToMembers(script: string, rootDir: string, memberDirs: readonly string[]): boolean {
|
|
441
442
|
for (const m of script.matchAll(CD)) {
|
|
442
|
-
const
|
|
443
|
+
const quoted = /^(["'])(.*)\1$/.exec(m[1]!)
|
|
444
|
+
const target = quoted ? quoted[2]! : m[1]!.replace(/\\(.)/g, '$1')
|
|
443
445
|
if (/[$`~*?]/.test(target) || target === '-') return true
|
|
444
446
|
const dir = path.resolve(rootDir, target)
|
|
445
447
|
const inside = (a: string, b: string): boolean => a === b || a.startsWith(b + path.sep)
|
|
@@ -845,7 +847,7 @@ export function migrateScripts(
|
|
|
845
847
|
const names = runnable.filter((n) => taskNameProblem(n) === null)
|
|
846
848
|
if (meta !== rootMeta && !('build' in scripts)) {
|
|
847
849
|
const hook = LIFECYCLE_BUILD_HOOKS.find(
|
|
848
|
-
(h) => typeof scripts[h] === 'string' && BUILDER.test(scripts[h]
|
|
850
|
+
(h) => typeof scripts[h] === 'string' && BUILDER.test(scripts[h]),
|
|
849
851
|
)
|
|
850
852
|
if (hook !== undefined) lifecycleBuilds.push([meta.name, hook, scripts[hook] as string])
|
|
851
853
|
}
|
|
@@ -406,7 +406,7 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
|
|
|
406
406
|
: []
|
|
407
407
|
if (empty && unreached.length > 0) {
|
|
408
408
|
report.push(
|
|
409
|
-
`${verb}: ${unreachedHint(unreached)}`,
|
|
409
|
+
`${verb}: ${unreachedHint(unreached, root)}`,
|
|
410
410
|
hasWorkspaceFile
|
|
411
411
|
? `${workspaceName} already exists.`
|
|
412
412
|
: dry
|