@vzn/vx 0.0.596 → 0.0.598
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/launcher.cjs +7 -3
- package/package.json +5 -5
- package/src/bin.ts +9 -5
- package/src/cache/archive.ts +35 -6
- package/src/cache/cache.ts +60 -31
- package/src/cache/git-inputs.ts +23 -0
- package/src/cache/index.ts +2 -0
- package/src/cache/inputs.ts +87 -32
- package/src/cache/key-fold.ts +1 -1
- package/src/cache/layer.ts +2 -1
- package/src/cache/layered-cache.ts +14 -9
- package/src/cache/output-index.ts +12 -7
- package/src/cache/run-history.ts +16 -8
- package/src/cli/cache.ts +2 -2
- package/src/cli/foreign-flags.ts +56 -2
- package/src/cli/info.ts +1 -1
- package/src/cli/init.ts +2 -2
- package/src/cli/last.ts +6 -3
- package/src/cli/lock.ts +5 -3
- package/src/cli/plan-format.ts +26 -20
- package/src/cli/run.ts +42 -15
- package/src/cli/select.ts +19 -5
- package/src/cli/show.ts +21 -27
- package/src/cli/upgrade.ts +1 -1
- package/src/cli/watch-cycle.ts +43 -0
- package/src/cli/watch-fs.ts +11 -3
- package/src/cli/watch.ts +50 -11
- package/src/cli/workspace-config.ts +3 -1
- package/src/config.ts +21 -0
- package/src/exec/executor.ts +1 -1
- package/src/exec/kill-tree.ts +121 -20
- package/src/exec/runner.ts +224 -48
- package/src/exec/sandbox-paths.ts +11 -0
- package/src/exec/sandbox-runtime.ts +187 -54
- package/src/exec/sandbox-violations.ts +59 -6
- package/src/graph/priorities.ts +1 -2
- package/src/graph/scheduler.ts +2 -2
- package/src/graph/task-graph.ts +145 -33
- package/src/index.ts +3 -1
- package/src/orchestrator/affected-tasks.ts +6 -2
- package/src/orchestrator/deferred-outputs.ts +3 -2
- package/src/orchestrator/doctor.ts +5 -3
- package/src/orchestrator/excluded-keys.ts +2 -13
- package/src/orchestrator/execute-task.ts +70 -28
- package/src/orchestrator/failure-mode.ts +38 -29
- package/src/orchestrator/framed-output.ts +5 -5
- package/src/orchestrator/hit-restore.ts +1 -1
- package/src/orchestrator/index.ts +0 -1
- package/src/orchestrator/keyed-projects.ts +6 -3
- package/src/orchestrator/lockfile-claim.ts +14 -7
- package/src/orchestrator/logger.ts +17 -7
- package/src/orchestrator/miss-save.ts +7 -11
- package/src/orchestrator/plan.ts +4 -20
- package/src/orchestrator/plugin-host.ts +7 -2
- package/src/orchestrator/plugin.ts +5 -2
- package/src/orchestrator/prepare.ts +4 -2
- package/src/orchestrator/projects.ts +1 -1
- package/src/orchestrator/run-artifacts.ts +2 -2
- package/src/orchestrator/run-context.ts +1 -1
- package/src/orchestrator/run-lock.ts +35 -25
- package/src/orchestrator/run.ts +2 -1
- package/src/orchestrator/sandbox-request.ts +31 -9
- package/src/orchestrator/signals.ts +22 -4
- package/src/orchestrator/stable-keys.ts +3 -11
- package/src/orchestrator/summary.ts +5 -9
- package/src/orchestrator/task-hash.ts +13 -3
- 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 +1 -1
- package/src/workspace/affected.ts +79 -42
- package/src/workspace/config-cache.ts +15 -1
- package/src/workspace/config-eval.ts +47 -7
- package/src/workspace/config-imports.ts +8 -55
- package/src/workspace/config-schema.ts +30 -6
- package/src/workspace/lockfile.ts +2 -1
- package/src/workspace/migrate-scripts.ts +1 -1
- package/src/workspace/project-loader.ts +33 -6
- package/src/workspace/workspace.ts +36 -14
|
@@ -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
|
|
@@ -606,8 +606,12 @@ export function configEvalKeyFromIdentities(a: {
|
|
|
606
606
|
* `node_modules`, the config itself excluded. `vx watch` watches these: a
|
|
607
607
|
* shared preset outside the project (`../../shared/preset.mjs`, the way
|
|
608
608
|
* configs compose) changed what a run evaluates and no watcher saw it
|
|
609
|
-
* (item 949). A file that cannot be read or resolved ends its branch
|
|
609
|
+
* (item 949). A file that cannot be read or resolved ends its branch; one
|
|
610
|
+
* that cannot be resolved is still listed, by the name it would have.
|
|
610
611
|
*/
|
|
612
|
+
// The names an extensionless relative import may resolve to, as Bun tries them.
|
|
613
|
+
const IMPORT_EXTENSIONS = ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.json']
|
|
614
|
+
|
|
611
615
|
export async function configImports(configPath: string): Promise<string[]> {
|
|
612
616
|
const seen = new Set<string>([configPath])
|
|
613
617
|
const out: string[] = []
|
|
@@ -635,6 +639,16 @@ export async function configImports(configPath: string): Promise<string[]> {
|
|
|
635
639
|
dir ??= realDirOf(file)
|
|
636
640
|
resolved = realPath(Bun.resolveSync(spec, dir))
|
|
637
641
|
} catch {
|
|
642
|
+
// Missing for now, still imported: the config that names it fails
|
|
643
|
+
// to load until it is back, and a list without it left `vx watch`
|
|
644
|
+
// deaf to its return. Named as written, and not walked.
|
|
645
|
+
if (dir === undefined) continue
|
|
646
|
+
for (const ext of path.extname(spec) === '' ? IMPORT_EXTENSIONS : ['']) {
|
|
647
|
+
const missing = path.resolve(dir, spec + ext)
|
|
648
|
+
if (seen.has(missing)) continue
|
|
649
|
+
seen.add(missing)
|
|
650
|
+
out.push(missing)
|
|
651
|
+
}
|
|
638
652
|
continue
|
|
639
653
|
}
|
|
640
654
|
if (seen.has(resolved) || resolved.split(path.sep).includes('node_modules')) continue
|
|
@@ -103,6 +103,23 @@ const nonJsonPaths = ${nonJsonPaths.toString()}
|
|
|
103
103
|
throw new Error('process.exit(' + (code ?? '') + ') in a config: ' + ${JSON.stringify(CONFIG_EXIT)})
|
|
104
104
|
}
|
|
105
105
|
}
|
|
106
|
+
// Bun's resolver reads \\ as a separator, so a config under a\\b is served
|
|
107
|
+
// from the path as written (project-loader.ts's LITERAL_QUERY), read now.
|
|
108
|
+
let literal = false
|
|
109
|
+
const serveLiteral = () => {
|
|
110
|
+
if (literal) return
|
|
111
|
+
literal = true
|
|
112
|
+
globalThis.Bun.plugin({
|
|
113
|
+
name: 'vx-config-literal',
|
|
114
|
+
setup(build) {
|
|
115
|
+
build.onResolve({ filter: /\\?vx-literal$/ }, (args) => ({ path: args.path, namespace: 'file' }))
|
|
116
|
+
build.onLoad({ filter: /\\?vx-literal$/ }, async (args) => {
|
|
117
|
+
const file = args.path.slice(0, -'?vx-literal'.length)
|
|
118
|
+
return { contents: await globalThis.Bun.file(file).text(), loader: /\\.[cm]?ts$/.test(file) ? 'ts' : 'js' }
|
|
119
|
+
})
|
|
120
|
+
},
|
|
121
|
+
})
|
|
122
|
+
}
|
|
106
123
|
self.onmessage = async (e) => {
|
|
107
124
|
const { id, path, env } = e.data
|
|
108
125
|
// A Worker starts with the process's STARTUP environment, not the
|
|
@@ -155,7 +172,9 @@ self.onmessage = async (e) => {
|
|
|
155
172
|
if (umaskIn !== null && globalThis.process.umask() !== umaskIn) globalThis.process.umask(umaskIn)
|
|
156
173
|
}
|
|
157
174
|
try {
|
|
158
|
-
const
|
|
175
|
+
const literalPath = path.includes('\\\\')
|
|
176
|
+
if (literalPath) serveLiteral()
|
|
177
|
+
const ns = await import(literalPath ? path + '?vx-literal' : path)
|
|
159
178
|
// Awaited as the in-process load's async return flattens it: a Promise
|
|
160
179
|
// default loaded on a run and was refused as "an instance of Promise"
|
|
161
180
|
// on every later evaluation in the process (D-5).
|
|
@@ -185,12 +204,16 @@ self.onmessage = async (e) => {
|
|
|
185
204
|
: thrown
|
|
186
205
|
const report = changed()
|
|
187
206
|
umaskBack()
|
|
207
|
+
// A thrown string or plain object has no message, and String() of a
|
|
208
|
+
// null-prototype object throws here, inside the catch.
|
|
209
|
+
const isError = err !== null && typeof err === 'object' && typeof err.message === 'string'
|
|
188
210
|
postMessage({
|
|
189
211
|
id,
|
|
190
212
|
ok: false,
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
213
|
+
thrown: isError ? null : globalThis.Bun.inspect(err, { compact: true }),
|
|
214
|
+
name: isError ? (err.name ?? 'Error') : 'Error',
|
|
215
|
+
message: isError ? err.message : '',
|
|
216
|
+
stack: isError ? (err.stack ?? null) : null,
|
|
194
217
|
changed: report,
|
|
195
218
|
position:
|
|
196
219
|
err?.position && typeof err.position === 'object'
|
|
@@ -214,6 +237,8 @@ interface WorkerReply {
|
|
|
214
237
|
nonJson: NonJsonValue[]
|
|
215
238
|
name: string
|
|
216
239
|
message: string
|
|
240
|
+
/** What a config threw that is not an Error, as `thrownValueMessage` shows it; null for an Error. */
|
|
241
|
+
thrown?: string | null
|
|
217
242
|
stack: string | null
|
|
218
243
|
/** A transpile error's location — `BuildMessage.position`, trimmed to what the loader reads. */
|
|
219
244
|
position: { file?: string; line?: number; column?: number } | null
|
|
@@ -222,6 +247,16 @@ interface WorkerReply {
|
|
|
222
247
|
interface Pending {
|
|
223
248
|
resolve: (reply: WorkerReply) => void
|
|
224
249
|
reject: (err: Error) => void
|
|
250
|
+
configPath: string
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* A config threw something that is not an Error: `throw 'no'` printed
|
|
255
|
+
* `vx: no`, naming no file, and a null-prototype object crashed vx's own
|
|
256
|
+
* error printer with a stack.
|
|
257
|
+
*/
|
|
258
|
+
export function thrownValueMessage(kind: string, configPath: string, shown: string): string {
|
|
259
|
+
return `${kind} config ${configPath} threw ${shown}, which is not an Error`
|
|
225
260
|
}
|
|
226
261
|
|
|
227
262
|
/**
|
|
@@ -236,10 +271,11 @@ export function evalBudgetMs(): number {
|
|
|
236
271
|
// BOUND on a worker that may be wedged, with no "no limit" reading. Falls
|
|
237
272
|
// back rather than clamping, because honouring ~24.8 days would hang
|
|
238
273
|
// `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
|
|
274
|
+
// so EVERY config load times out instead. Both ends break the same feature,
|
|
275
|
+
// and `0` is the second end: it fired on the next tick (D-157).
|
|
240
276
|
if (raw !== undefined && /^[0-9]+$/.test(raw)) {
|
|
241
277
|
const n = Number(raw)
|
|
242
|
-
if (n <= MAX_TIMEOUT_MS) return n
|
|
278
|
+
if (n > 0 && n <= MAX_TIMEOUT_MS) return n
|
|
243
279
|
}
|
|
244
280
|
return 30_000
|
|
245
281
|
}
|
|
@@ -287,6 +323,10 @@ function acquireWorker(): Worker {
|
|
|
287
323
|
p.resolve(msg)
|
|
288
324
|
return
|
|
289
325
|
}
|
|
326
|
+
if (typeof msg.thrown === 'string') {
|
|
327
|
+
p.reject(new UserError(thrownValueMessage('Project', p.configPath, msg.thrown)))
|
|
328
|
+
return
|
|
329
|
+
}
|
|
290
330
|
// Rebuild the error the config actually threw. Name, message, stack
|
|
291
331
|
// and a transpile error's position all survive the hop, so a broken
|
|
292
332
|
// config reports the same text it would from an in-process import.
|
|
@@ -365,7 +405,7 @@ export async function evaluateConfigFresh(configPath: string): Promise<unknown>
|
|
|
365
405
|
// evaluation costs, so it can only fire on a genuine wedge.
|
|
366
406
|
const budget = evalBudgetMs()
|
|
367
407
|
const reply = await new Promise<WorkerReply>((resolve, reject) => {
|
|
368
|
-
pending.set(id, { resolve, reject })
|
|
408
|
+
pending.set(id, { resolve, reject, configPath })
|
|
369
409
|
timer = setTimeout(() => {
|
|
370
410
|
rejectAll(new Error(`config worker did not answer within ${budget}ms`))
|
|
371
411
|
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.
|
|
@@ -486,18 +444,13 @@ export async function configImportOwners(a: ConfigImportOwnersArgs): Promise<Set
|
|
|
486
444
|
if (entry === null) continue
|
|
487
445
|
const { file, source } = entry
|
|
488
446
|
const loader = TS_EXT.has(path.extname(file)) ? 'ts' : 'js'
|
|
489
|
-
const from = ownerOf(file, dirToName)
|
|
490
447
|
for (const target of scanLocalImports(source, path.dirname(file), loader, tsconfigs)) {
|
|
491
448
|
if (!target.startsWith(workspaceRoot + path.sep)) continue
|
|
492
449
|
if (target.split(path.sep).includes('node_modules')) continue
|
|
493
450
|
const list = importedBy.get(target)
|
|
494
451
|
if (list) list.push(file)
|
|
495
452
|
else importedBy.set(target, [file])
|
|
496
|
-
|
|
497
|
-
// the importer's own project's (a config split into `./tasks.mjs`
|
|
498
|
-
// that imports shared tooling): see the header.
|
|
499
|
-
const owner = ownerOf(target, dirToName)
|
|
500
|
-
if (owner === undefined || owner === rootOwner || owner === from) frontier.push(target)
|
|
453
|
+
frontier.push(target)
|
|
501
454
|
}
|
|
502
455
|
}
|
|
503
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
|
|
@@ -179,7 +189,7 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
|
|
|
179
189
|
throw new UserError(`${configPath}: \`plugins\` must be an array of plugin objects`)
|
|
180
190
|
}
|
|
181
191
|
const verbOwners = new Map<string, string>()
|
|
182
|
-
const fileClaimants = new Map<string, string>()
|
|
192
|
+
const fileClaimants = new Map<string, { name: string; at: number }>()
|
|
183
193
|
for (const [i, p] of config.plugins.entries()) {
|
|
184
194
|
if (p === null || typeof p !== 'object') {
|
|
185
195
|
// Nx's nx.json lists plugins by module name (`'@nx/vite/plugin'`);
|
|
@@ -199,6 +209,13 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
|
|
|
199
209
|
// one whose `name` was overwritten after the stamp, is refused here —
|
|
200
210
|
// the one boundary every plugin crosses.
|
|
201
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
|
+
}
|
|
202
219
|
if (typeof pkg !== 'string' || pkg.length === 0) {
|
|
203
220
|
throw new UserError(
|
|
204
221
|
`${configPath}: \`plugins[${i}]\` must come from definePlugin(import.meta, { … }) — a plugin's name is its package name`,
|
|
@@ -317,16 +334,23 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
|
|
|
317
334
|
/[/\\]/.test(file)
|
|
318
335
|
) {
|
|
319
336
|
throw new UserError(
|
|
320
|
-
`${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`,
|
|
321
338
|
)
|
|
322
339
|
}
|
|
323
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
|
+
}
|
|
324
348
|
if (owner !== undefined) {
|
|
325
349
|
throw new UserError(
|
|
326
|
-
`${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`,
|
|
327
351
|
)
|
|
328
352
|
}
|
|
329
|
-
fileClaimants.set(file, plug.name)
|
|
353
|
+
fileClaimants.set(file, { name: plug.name, at: i })
|
|
330
354
|
}
|
|
331
355
|
}
|
|
332
356
|
// A plugin must contribute at least one capability or lifecycle hook
|
|
@@ -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).
|
|
@@ -847,7 +847,7 @@ export function migrateScripts(
|
|
|
847
847
|
const names = runnable.filter((n) => taskNameProblem(n) === null)
|
|
848
848
|
if (meta !== rootMeta && !('build' in scripts)) {
|
|
849
849
|
const hook = LIFECYCLE_BUILD_HOOKS.find(
|
|
850
|
-
(h) => typeof scripts[h] === 'string' && BUILDER.test(scripts[h]
|
|
850
|
+
(h) => typeof scripts[h] === 'string' && BUILDER.test(scripts[h]),
|
|
851
851
|
)
|
|
852
852
|
if (hook !== undefined) lifecycleBuilds.push([meta.name, hook, scripts[hook] as string])
|
|
853
853
|
}
|