@vzn/vx 0.0.635 → 0.0.637
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 +47 -19
- package/docs/caching.md +222 -210
- package/docs/cli.md +699 -664
- package/docs/execution.md +67 -75
- package/docs/features.md +44 -40
- package/docs/patterns.md +31 -32
- package/docs/schema.md +418 -202
- package/docs/security.md +76 -39
- package/launcher.sh +43 -0
- package/package.json +5 -5
- package/schemas/cache.json +41 -19
- package/schemas/info.json +23 -11
- package/schemas/last.json +48 -2
- package/schemas/lock.json +1 -1
- package/schemas/plan.json +2 -2
- package/schemas/show.json +13 -4
- package/schemas/why.json +15 -1
- package/skills/vx/SKILL.md +16 -12
- package/src/bin.ts +17 -2
- package/src/cache/archive.ts +11 -0
- package/src/cache/cache.ts +105 -53
- package/src/cache/git-inputs.ts +277 -63
- package/src/cache/index.ts +7 -1
- package/src/cache/inputs.ts +125 -35
- package/src/cache/key-fold.ts +8 -7
- package/src/cache/layer.ts +8 -17
- package/src/cache/layered-cache.ts +16 -102
- package/src/cache/policy.ts +25 -29
- package/src/cache/run-history.ts +36 -14
- package/src/cache/schema.ts +21 -23
- package/src/cache/scoped-cache.ts +224 -0
- package/src/cli/cache.ts +150 -28
- package/src/cli/completions.ts +2 -2
- package/src/cli/core-alias.ts +1 -1
- package/src/cli/foreign-flags.ts +13 -12
- package/src/cli/help.ts +94 -61
- package/src/cli/index.ts +42 -13
- package/src/cli/info.ts +38 -23
- package/src/cli/init.ts +115 -406
- package/src/cli/last.ts +145 -4
- package/src/cli/lock.ts +333 -111
- package/src/cli/plan-format.ts +2 -2
- package/src/cli/plugin-commands.ts +5 -9
- package/src/cli/plugin-templates.ts +10 -10
- package/src/cli/run.ts +123 -144
- package/src/cli/select.ts +33 -39
- package/src/cli/show.ts +17 -19
- package/src/cli/task-verb.ts +2 -9
- package/src/cli/upgrade.ts +402 -145
- package/src/cli/watch-cycle.ts +9 -2
- package/src/cli/watch-filter.ts +33 -60
- package/src/cli/watch-fs.ts +29 -16
- package/src/cli/watch-judge.ts +12 -3
- package/src/cli/watch-set.ts +16 -42
- package/src/cli/watch.ts +188 -215
- package/src/cli/why.ts +28 -17
- package/src/cli/workspace-config.ts +31 -68
- package/src/config.ts +191 -71
- package/src/exec/env.ts +1 -31
- package/src/exec/executor.ts +1 -5
- package/src/exec/kill-tree.ts +2 -1
- package/src/exec/sandbox-runtime.ts +12 -1
- package/src/exec/sandbox-violations.ts +9 -1
- package/src/graph/dependency-spec.ts +1 -1
- package/src/graph/index.ts +1 -0
- package/src/graph/priorities.ts +1 -1
- package/src/graph/scheduler.ts +19 -7
- package/src/graph/task-graph.ts +44 -20
- package/src/handoff.ts +169 -0
- package/src/index.ts +25 -57
- package/src/orchestrator/affected-tasks.ts +7 -5
- package/src/orchestrator/command-windows.ts +44 -0
- package/src/orchestrator/deferred-outputs.ts +12 -10
- package/src/orchestrator/doctor.ts +98 -110
- package/src/orchestrator/download-policy.ts +3 -3
- package/src/orchestrator/events.ts +19 -3
- package/src/orchestrator/excluded-keys.ts +0 -2
- package/src/orchestrator/execute-task.ts +192 -70
- package/src/orchestrator/fingerprint-watch.ts +18 -15
- package/src/orchestrator/forecast.ts +10 -2
- package/src/orchestrator/framed-output.ts +8 -3
- package/src/orchestrator/graph-stage.ts +5 -5
- package/src/orchestrator/history.ts +70 -30
- package/src/orchestrator/hit-restore.ts +15 -3
- package/src/orchestrator/index.ts +18 -4
- package/src/orchestrator/local-shortcircuit.ts +2 -1
- package/src/orchestrator/lockfile-claim.ts +79 -20
- package/src/orchestrator/logger.ts +71 -39
- package/src/orchestrator/metrics.ts +71 -6
- package/src/orchestrator/miss-save.ts +3 -0
- package/src/orchestrator/options.ts +43 -28
- package/src/orchestrator/output-host.ts +72 -0
- package/src/orchestrator/plan.ts +6 -8
- package/src/orchestrator/plugin-host.ts +87 -63
- package/src/orchestrator/plugin.ts +94 -60
- package/src/orchestrator/prepare.ts +114 -73
- package/src/orchestrator/projects.ts +102 -99
- package/src/orchestrator/remote-prefetch.ts +2 -1
- package/src/orchestrator/run-context.ts +25 -6
- package/src/orchestrator/run-failures.ts +17 -8
- package/src/orchestrator/run-lock.ts +1 -1
- package/src/orchestrator/run-report.ts +9 -9
- package/src/orchestrator/run-settings.ts +48 -0
- package/src/orchestrator/run.ts +127 -88
- package/src/orchestrator/sandbox-fix.ts +39 -20
- package/src/orchestrator/sandbox-request.ts +84 -13
- package/src/orchestrator/signals.ts +1 -1
- package/src/orchestrator/stable-keys.ts +13 -11
- package/src/orchestrator/status-line.ts +48 -6
- package/src/orchestrator/summary.ts +46 -62
- package/src/orchestrator/task-hash.ts +12 -83
- package/src/orchestrator/telemetry.ts +3 -3
- package/src/settings.ts +417 -0
- package/src/util/errors.ts +1 -1
- package/src/util/essential-env.ts +31 -0
- package/src/util/hide-env.ts +107 -0
- package/src/util/index.ts +8 -2
- package/src/util/paths.ts +10 -0
- package/src/util/settle.ts +49 -30
- package/src/util/size.ts +23 -4
- package/src/util/timing.ts +3 -2
- package/src/util/ulid.ts +1 -1
- package/src/util/verbs.ts +0 -15
- package/src/workspace/affected.ts +77 -222
- package/src/workspace/config-eval.ts +15 -20
- package/src/workspace/config-imports.ts +78 -3
- package/src/workspace/config-schema.ts +251 -241
- package/src/workspace/dependency-rules.ts +57 -0
- package/src/workspace/filter.ts +2 -2
- package/src/workspace/fingerprint.ts +11 -121
- package/src/workspace/index.ts +22 -27
- package/src/workspace/load-reads.ts +4 -4
- package/src/workspace/lockfile.ts +96 -140
- package/src/workspace/migration.ts +242 -146
- package/src/workspace/package-graph.ts +30 -216
- package/src/workspace/project-loader.ts +80 -285
- package/src/workspace/repo-id.ts +2 -1
- package/src/workspace/workspace.ts +362 -831
- package/src/cache/config-evals.ts +0 -130
- package/src/workspace/config-cache.ts +0 -661
- package/src/workspace/migrate-scripts.ts +0 -1121
|
@@ -6,25 +6,23 @@
|
|
|
6
6
|
// index + unstaged — so it captures everything you touched and nothing
|
|
7
7
|
// the base branch moved on with. Matches Turbo's `[<since>]` semantics.
|
|
8
8
|
|
|
9
|
-
import { realpathSync, statSync } from 'node:fs'
|
|
10
9
|
import { realpath } from 'node:fs/promises'
|
|
11
10
|
import path from 'node:path'
|
|
12
11
|
import {
|
|
13
12
|
executablePath,
|
|
14
|
-
|
|
13
|
+
gitEnv,
|
|
15
14
|
gitSpawnRefusal,
|
|
16
15
|
isExecutableMissing,
|
|
17
16
|
isInstalledPath,
|
|
18
17
|
notAWorkTree,
|
|
19
18
|
relPosix,
|
|
19
|
+
UserError,
|
|
20
20
|
} from '../util/index.js'
|
|
21
21
|
import { LOCKFILE_NAME } from './lockfile.js'
|
|
22
22
|
import { configImportOwners, realpathOr } from './config-imports.js'
|
|
23
|
-
import { configImports } from './config-cache.js'
|
|
24
23
|
import { WORKSPACE_CONFIG_FILENAMES } from './project-loader.js'
|
|
25
|
-
import { bunPatchFiles
|
|
26
|
-
import {
|
|
27
|
-
import type { PackageJson, ProjectMeta } from './workspace.js'
|
|
24
|
+
import { bunPatchFiles } from './fingerprint.js'
|
|
25
|
+
import { PROJECT_CONFIG_FILENAMES, type ProjectMeta } from './workspace.js'
|
|
28
26
|
|
|
29
27
|
/**
|
|
30
28
|
* Every git call here goes through these two: a git that is not on PATH
|
|
@@ -38,7 +36,13 @@ function spawnGitSync(
|
|
|
38
36
|
stderr: 'pipe' | 'ignore' = 'pipe',
|
|
39
37
|
): ReturnType<typeof Bun.spawnSync> {
|
|
40
38
|
try {
|
|
41
|
-
return Bun.spawnSync({
|
|
39
|
+
return Bun.spawnSync({
|
|
40
|
+
cmd: [executablePath('git'), ...args],
|
|
41
|
+
env: gitEnv(),
|
|
42
|
+
cwd,
|
|
43
|
+
stdout: 'pipe',
|
|
44
|
+
stderr,
|
|
45
|
+
})
|
|
42
46
|
} catch (err) {
|
|
43
47
|
if (isExecutableMissing(err)) throw gitSpawnRefusal(cwd)
|
|
44
48
|
throw err
|
|
@@ -53,6 +57,7 @@ function spawnGit(
|
|
|
53
57
|
try {
|
|
54
58
|
return Bun.spawn({
|
|
55
59
|
cmd: [executablePath('git'), ...args],
|
|
60
|
+
env: gitEnv(),
|
|
56
61
|
cwd,
|
|
57
62
|
stdin: stdin ?? 'ignore',
|
|
58
63
|
stdout: 'pipe',
|
|
@@ -91,17 +96,10 @@ export interface AffectedArgs {
|
|
|
91
96
|
/**
|
|
92
97
|
* The fingerprint files a plugin claims (`VxPlugin.fingerprint`) and its
|
|
93
98
|
* answer for a change to one. Resolved lazily: loading the workspace
|
|
94
|
-
* file costs an evaluation, and a diff that touches no
|
|
95
|
-
*
|
|
99
|
+
* file costs an evaluation, and a diff that touches no root file — the
|
|
100
|
+
* common one — never needs it.
|
|
96
101
|
*/
|
|
97
102
|
fingerprintClaims?: () => Promise<FingerprintClaims>
|
|
98
|
-
/**
|
|
99
|
-
* The cross-project `dependsOn` edges, project → the projects its tasks
|
|
100
|
-
* name. Asked only when a package's name moved or it was removed: a
|
|
101
|
-
* config naming `dependsOn: ['lib#build']` breaks on it, which the
|
|
102
|
-
* package graph cannot see (item 1085).
|
|
103
|
-
*/
|
|
104
|
-
taskEdges?: () => Promise<ReadonlyMap<string, readonly string[]>>
|
|
105
103
|
/**
|
|
106
104
|
* The untracked files, workspace-relative, from a walk the caller shares
|
|
107
105
|
* with the run (`GitEnumeration.untracked`); null or absent spawns `git
|
|
@@ -135,7 +133,7 @@ export async function affectedProjects(args: AffectedArgs): Promise<Set<string>>
|
|
|
135
133
|
* projects, each one's changed paths relative to it, and the projects a
|
|
136
134
|
* change reaches as a whole — every task in one is affected whatever its
|
|
137
135
|
* inputs say, because the reason was no path of its own (a lockfile claim,
|
|
138
|
-
* a
|
|
136
|
+
* a config import, a nested repository, a
|
|
139
137
|
* workspace-wide file).
|
|
140
138
|
*/
|
|
141
139
|
export interface AffectedChanges {
|
|
@@ -246,7 +244,7 @@ export async function affectedChanges(
|
|
|
246
244
|
gitPaths(args.workspaceRoot, ['ls-files', '--others', '--exclude-standard', '-z']))(),
|
|
247
245
|
])
|
|
248
246
|
|
|
249
|
-
// vx
|
|
247
|
+
// .vx/projects.lock.json (workspace-root metadata) is excluded like a gitignored
|
|
250
248
|
// file: re-running `vx lock` must not mark every project affected.
|
|
251
249
|
// An untracked file under `node_modules` is an install, which the input
|
|
252
250
|
// enumeration drops too (`isInstalledPath`); a tracked one is a source.
|
|
@@ -258,62 +256,54 @@ export async function affectedChanges(
|
|
|
258
256
|
const nested = new Set(diffed.gitlinks)
|
|
259
257
|
for (const rel of untracked) if (rel.endsWith('/')) nested.add(rel)
|
|
260
258
|
|
|
261
|
-
// A
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
//
|
|
259
|
+
// A root file a plugin CLAIMS (`VxPlugin.fingerprint`: a lockfile, an
|
|
260
|
+
// install setting) re-keys tasks through what its claimant folds, and it
|
|
261
|
+
// sits at the root, owned by no project or by the root alone: mapping
|
|
262
|
+
// paths to directories selected ZERO projects for a lockfile bump that
|
|
263
|
+
// re-keyed them all, and `vx run test --affected` after a `pnpm update`
|
|
264
|
+
// exited 0 having run nothing — the failure `docs/cli.md` warns against:
|
|
265
|
+
// "input hashing sees it, so `--affected` must too." So selection asks the
|
|
266
|
+
// claimant the question its key answers, with the bytes at the base ref
|
|
267
|
+
// and in the working tree; the answer is unioned with the path-owned
|
|
268
|
+
// projects below, and only "cannot tell" widens. Core keys no root file
|
|
269
|
+
// itself, so an unclaimed one is a path like any other.
|
|
269
270
|
//
|
|
270
271
|
// Selection is not hashed, so widening it here changes no cache key.
|
|
271
|
-
//
|
|
272
|
-
// A file a plugin CLAIMS is the exception: the key folds what the plugin
|
|
273
|
-
// says per project, so selection asks the plugin the same question, with
|
|
274
|
-
// the bytes at the base ref and in the working tree. Its answer is
|
|
275
|
-
// unioned with the path-owned projects below; only "cannot tell" widens.
|
|
276
272
|
const everything = (): AffectedChanges => {
|
|
277
273
|
const all = new Set(args.projects.map((p) => p.name))
|
|
278
274
|
return { projects: all, changed, paths: new Map(), whole: all }
|
|
279
275
|
}
|
|
280
|
-
if (
|
|
281
|
-
|
|
282
|
-
//
|
|
283
|
-
|
|
284
|
-
|
|
276
|
+
if (workspaceConfigChanged(changed)) return everything()
|
|
277
|
+
// Loading the workspace file costs an evaluation; only a changed ROOT
|
|
278
|
+
// name can be claimed, so a diff inside the projects never loads it.
|
|
279
|
+
let claims: FingerprintClaims | undefined
|
|
280
|
+
const loadClaims = async (): Promise<FingerprintClaims | undefined> =>
|
|
281
|
+
(claims ??= await args.fingerprintClaims?.())
|
|
282
|
+
// A patch bun.lock names folds into the key of the plugin that claims
|
|
283
|
+
// bun.lock (`bun()`; the lockfile records no hash of it), so its edit
|
|
284
|
+
// widens as a lockfile edit does. A patch the lockfile no longer names
|
|
285
|
+
// moved bun.lock itself.
|
|
285
286
|
if (changed.length > 0) {
|
|
286
287
|
const lock = await bytesOrNull(path.join(args.workspaceRoot, 'bun.lock'))
|
|
287
288
|
if (lock !== null) {
|
|
288
289
|
const patches = new Set(bunPatchFiles(lock))
|
|
289
|
-
if (changed.some((p) => patches.has(p))
|
|
290
|
+
if (changed.some((p) => patches.has(p)) && (await loadClaims())?.files.has('bun.lock')) {
|
|
291
|
+
return everything()
|
|
292
|
+
}
|
|
290
293
|
}
|
|
291
294
|
}
|
|
292
295
|
const claimedOwned = new Set<string>()
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
for (const file of rootNamesChanged) if (claims.files.has(file)) fingerprintChanged.push(file)
|
|
305
|
-
}
|
|
306
|
-
if (fingerprintChanged.length > 0) {
|
|
307
|
-
for (const file of fingerprintChanged) {
|
|
308
|
-
if (claims === undefined || !claims.files.has(file)) return everything()
|
|
309
|
-
const answer = await claims.affected({
|
|
310
|
-
file,
|
|
311
|
-
before: await gitBytesAt(args.workspaceRoot, base, file),
|
|
312
|
-
after: await bytesOrNull(path.join(args.workspaceRoot, file)),
|
|
313
|
-
})
|
|
314
|
-
if (answer === undefined) return everything()
|
|
315
|
-
for (const name of answer) claimedOwned.add(name)
|
|
316
|
-
}
|
|
296
|
+
const rootNamesChanged = changed.filter((p) => !p.includes('/'))
|
|
297
|
+
const claimed = rootNamesChanged.length > 0 ? await loadClaims() : undefined
|
|
298
|
+
for (const file of rootNamesChanged) {
|
|
299
|
+
if (claimed === undefined || !claimed.files.has(file)) continue
|
|
300
|
+
const answer = await claimed.affected({
|
|
301
|
+
file,
|
|
302
|
+
before: await gitBytesAt(args.workspaceRoot, base, file),
|
|
303
|
+
after: await bytesOrNull(path.join(args.workspaceRoot, file)),
|
|
304
|
+
})
|
|
305
|
+
if (answer === undefined) return everything()
|
|
306
|
+
for (const name of answer) claimedOwned.add(name)
|
|
317
307
|
}
|
|
318
308
|
|
|
319
309
|
const realDirs = new Map(
|
|
@@ -342,27 +332,11 @@ export async function affectedChanges(
|
|
|
342
332
|
)
|
|
343
333
|
for (const name of claimedOwned) owned.add(name)
|
|
344
334
|
|
|
345
|
-
// A
|
|
346
|
-
//
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
// change to the package alone and the dependents walk sees only today's
|
|
351
|
-
// graph, so `--affected` left `app#build` out (D-3).
|
|
352
|
-
const manifests = changed.filter((rel) => path.posix.basename(rel) === 'package.json')
|
|
353
|
-
if (manifests.length > 0) {
|
|
354
|
-
const atBase = await gitBlobsAt(args.workspaceRoot, base, manifests)
|
|
355
|
-
const dependents = await dependentsAtBase(
|
|
356
|
-
args.workspaceRoot,
|
|
357
|
-
atBase,
|
|
358
|
-
args.projects,
|
|
359
|
-
args.taskEdges,
|
|
360
|
-
)
|
|
361
|
-
if (dependents === undefined) return everything()
|
|
362
|
-
for (const name of dependents) {
|
|
363
|
-
owned.add(name)
|
|
364
|
-
whole.add(name)
|
|
365
|
-
}
|
|
335
|
+
// A config that makes an existing directory a project took files from
|
|
336
|
+
// the project above it.
|
|
337
|
+
const configs = changed.filter((rel) => CONFIG_NAMES.has(path.posix.basename(rel)))
|
|
338
|
+
if (configs.length > 0) {
|
|
339
|
+
const atBase = await gitBlobsAt(args.workspaceRoot, base, configs)
|
|
366
340
|
for (const name of parentsOfNewNested(args.workspaceRoot, atBase, args.projects)) {
|
|
367
341
|
owned.add(name)
|
|
368
342
|
whole.add(name)
|
|
@@ -400,102 +374,16 @@ export async function affectedChanges(
|
|
|
400
374
|
return done()
|
|
401
375
|
}
|
|
402
376
|
|
|
403
|
-
|
|
404
|
-
* The projects whose workspace dependencies at the base differ from
|
|
405
|
-
* today's: the package graph is built again over the changed manifests as
|
|
406
|
-
* the base had them, and every project whose `directDeps` (the edges its
|
|
407
|
-
* key folds) moved is selected. `undefined` (every project) when the root
|
|
408
|
-
* manifest's `workspaces` moved: which packages left is a discovery at the
|
|
409
|
-
* base, and selection cannot tell cheaply. `atBase` holds each changed
|
|
410
|
-
* manifest as the base had it, read in one `git cat-file --batch`.
|
|
411
|
-
*/
|
|
412
|
-
async function dependentsAtBase(
|
|
413
|
-
workspaceRoot: string,
|
|
414
|
-
atBase: ReadonlyMap<string, Uint8Array | null>,
|
|
415
|
-
projects: readonly ProjectMeta[],
|
|
416
|
-
taskEdges: AffectedArgs['taskEdges'],
|
|
417
|
-
): Promise<Set<string> | undefined> {
|
|
418
|
-
const byDir = new Map<string, ProjectMeta>()
|
|
419
|
-
for (const p of projects) byDir.set(p.dir, p)
|
|
420
|
-
const nameNow = new Map(projects.map((p) => [p.dir, p.name]))
|
|
421
|
-
// Base names no project holds today: a removed or renamed package.
|
|
422
|
-
const gone = new Set<string>()
|
|
423
|
-
const changedDirs = new Set<string>()
|
|
424
|
-
for (const rel of atBase.keys()) {
|
|
425
|
-
if (rel === 'package.json') {
|
|
426
|
-
const workspacesOf = (bytes: Uint8Array | null): string => {
|
|
427
|
-
if (bytes === null) return ''
|
|
428
|
-
try {
|
|
429
|
-
return JSON.stringify(
|
|
430
|
-
(JSON.parse(new TextDecoder().decode(bytes)) as PackageJson).workspaces ?? null,
|
|
431
|
-
)
|
|
432
|
-
} catch {
|
|
433
|
-
return 'unreadable'
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
|
-
const before = workspacesOf(atBase.get(rel) ?? null)
|
|
437
|
-
const after = workspacesOf(await bytesOrNull(path.join(workspaceRoot, rel)))
|
|
438
|
-
if (before !== after) return undefined
|
|
439
|
-
}
|
|
440
|
-
const dir = path.resolve(workspaceRoot, path.posix.dirname(rel))
|
|
441
|
-
changedDirs.add(dir)
|
|
442
|
-
byDir.delete(dir)
|
|
443
|
-
const bytes = atBase.get(rel)
|
|
444
|
-
if (bytes === undefined || bytes === null) continue
|
|
445
|
-
let pkg: PackageJson
|
|
446
|
-
try {
|
|
447
|
-
pkg = JSON.parse(new TextDecoder().decode(bytes)) as PackageJson
|
|
448
|
-
} catch {
|
|
449
|
-
// A manifest that did not parse at the base named no package.
|
|
450
|
-
continue
|
|
451
|
-
}
|
|
452
|
-
if (
|
|
453
|
-
pkg === null ||
|
|
454
|
-
typeof pkg !== 'object' ||
|
|
455
|
-
typeof pkg.name !== 'string' ||
|
|
456
|
-
pkg.name === ''
|
|
457
|
-
) {
|
|
458
|
-
continue
|
|
459
|
-
}
|
|
460
|
-
// Today's catalogs, or both graphs would differ on every `catalog:`
|
|
461
|
-
// entry. A catalog edit is a `pnpm-workspace.yaml` or root manifest
|
|
462
|
-
// edit, which the fingerprint widening and the root's own change answer.
|
|
463
|
-
const catalogs = projects[0]?.catalogs
|
|
464
|
-
byDir.set(dir, {
|
|
465
|
-
name: pkg.name,
|
|
466
|
-
dir,
|
|
467
|
-
packageJson: pkg,
|
|
468
|
-
configPath: null,
|
|
469
|
-
...(catalogs === undefined ? {} : { catalogs }),
|
|
470
|
-
})
|
|
471
|
-
if (nameNow.get(dir) !== pkg.name) gone.add(pkg.name)
|
|
472
|
-
}
|
|
473
|
-
const now = buildPackageGraph([...projects])
|
|
474
|
-
const then = buildPackageGraph([...byDir.values()])
|
|
475
|
-
const out = new Set<string>()
|
|
476
|
-
for (const p of projects) {
|
|
477
|
-
// A project whose own manifest changed is owned by containment.
|
|
478
|
-
if (changedDirs.has(p.dir)) continue
|
|
479
|
-
const a = now.directDeps(p.name)
|
|
480
|
-
const b = then.directDeps(p.name)
|
|
481
|
-
if (a.length !== b.length || a.some((d, i) => d !== b[i])) out.add(p.name)
|
|
482
|
-
}
|
|
483
|
-
if (gone.size > 0 && taskEdges !== undefined) {
|
|
484
|
-
for (const [project, targets] of await taskEdges()) {
|
|
485
|
-
if (targets.some((t) => gone.has(t))) out.add(project)
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
return out
|
|
489
|
-
}
|
|
377
|
+
const CONFIG_NAMES: ReadonlySet<string> = new Set(PROJECT_CONFIG_FILENAMES)
|
|
490
378
|
|
|
491
379
|
/**
|
|
492
380
|
* The projects a NEW nested project took files from. A project's inputs
|
|
493
|
-
* stop at every project below it, so a
|
|
494
|
-
*
|
|
495
|
-
*
|
|
496
|
-
*
|
|
497
|
-
*
|
|
498
|
-
*
|
|
381
|
+
* stop at every project below it, so a config that makes an existing
|
|
382
|
+
* directory a project re-keys the project above it — its `**` lost the
|
|
383
|
+
* directory's files — while containment maps the change to the new
|
|
384
|
+
* project alone, and `--affected` left the re-keyed parent out. "New" is
|
|
385
|
+
* judged at the base: none of the changed configs in its directory was
|
|
386
|
+
* there. `atBase` holds each changed config there.
|
|
499
387
|
*/
|
|
500
388
|
function parentsOfNewNested(
|
|
501
389
|
workspaceRoot: string,
|
|
@@ -507,7 +395,10 @@ function parentsOfNewNested(
|
|
|
507
395
|
// dir discovered by readdir keeps the spelling it was created with.
|
|
508
396
|
const dirToName = new Map<string, string>()
|
|
509
397
|
for (const p of projects) dirToName.set(p.dir.normalize('NFC'), p.name)
|
|
510
|
-
|
|
398
|
+
const existed = new Set<string>()
|
|
399
|
+
for (const [rel, bytes] of atBase) if (bytes !== null) existed.add(path.posix.dirname(rel))
|
|
400
|
+
for (const rel of atBase.keys()) {
|
|
401
|
+
if (existed.has(path.posix.dirname(rel))) continue
|
|
511
402
|
const dir = path.resolve(workspaceRoot, path.posix.dirname(rel))
|
|
512
403
|
if (!dirToName.has(dir)) continue
|
|
513
404
|
let parent: string | undefined
|
|
@@ -515,17 +406,7 @@ function parentsOfNewNested(
|
|
|
515
406
|
parent = dirToName.get(d)
|
|
516
407
|
if (path.dirname(d) === d) break
|
|
517
408
|
}
|
|
518
|
-
if (parent
|
|
519
|
-
let named = false
|
|
520
|
-
if (bytes !== null) {
|
|
521
|
-
try {
|
|
522
|
-
const name = (JSON.parse(new TextDecoder().decode(bytes)) as PackageJson).name
|
|
523
|
-
named = typeof name === 'string' && name !== ''
|
|
524
|
-
} catch {
|
|
525
|
-
// A manifest that did not parse at the base named no project.
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
if (!named) out.add(parent)
|
|
409
|
+
if (parent !== undefined) out.add(parent)
|
|
529
410
|
}
|
|
530
411
|
return out
|
|
531
412
|
}
|
|
@@ -582,44 +463,18 @@ async function gitBlobsAt(
|
|
|
582
463
|
}
|
|
583
464
|
|
|
584
465
|
/**
|
|
585
|
-
* Whether `changed` holds the workspace config
|
|
586
|
-
*
|
|
587
|
-
*
|
|
588
|
-
*
|
|
589
|
-
*
|
|
590
|
-
*
|
|
591
|
-
*
|
|
592
|
-
* hashed, so widening here changes no key.
|
|
466
|
+
* Whether `changed` holds the workspace config. Its plugins' `config` and
|
|
467
|
+
* `project` stages shape every project's resolved config, so such an edit
|
|
468
|
+
* can re-key any task, and selection cannot tell which: an edit that
|
|
469
|
+
* changed every key selected nothing (item 953). No key folds the file
|
|
470
|
+
* (its placement plugins must not split a cache between machines); a
|
|
471
|
+
* stage it installs re-keys through the resolved configs instead.
|
|
472
|
+
* Selection is not hashed, so widening here changes no key.
|
|
593
473
|
*/
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
changed: readonly string[],
|
|
597
|
-
): Promise<boolean> {
|
|
598
|
-
if (changed.length === 0) return false
|
|
599
|
-
const set = new Set(changed)
|
|
600
|
-
if (WORKSPACE_CONFIG_FILENAMES.some((n) => set.has(n))) return true
|
|
601
|
-
const config = WORKSPACE_CONFIG_FILENAMES.map((n) => path.join(workspaceRoot, n)).find((f) => {
|
|
602
|
-
try {
|
|
603
|
-
return statSync(f).isFile()
|
|
604
|
-
} catch {
|
|
605
|
-
return false
|
|
606
|
-
}
|
|
607
|
-
})
|
|
608
|
-
if (config === undefined) return false
|
|
609
|
-
const root = realpathSync(workspaceRoot)
|
|
610
|
-
for (const file of await configImports(config)) {
|
|
611
|
-
if (set.has(relPosix(root, file))) return true
|
|
612
|
-
}
|
|
613
|
-
return false
|
|
474
|
+
function workspaceConfigChanged(changed: readonly string[]): boolean {
|
|
475
|
+
return WORKSPACE_CONFIG_FILENAMES.some((n) => changed.includes(n))
|
|
614
476
|
}
|
|
615
477
|
|
|
616
|
-
/**
|
|
617
|
-
* The fingerprint files as workspace-root-relative paths. Read from the same
|
|
618
|
-
* constant the fingerprint itself walks, so a new lockfile format cannot be
|
|
619
|
-
* taught to one surface and not the other.
|
|
620
|
-
*/
|
|
621
|
-
const FINGERPRINT_SET: ReadonlySet<string> = new Set(WORKSPACE_FINGERPRINT_FILES)
|
|
622
|
-
|
|
623
478
|
/**
|
|
624
479
|
* A root file's bytes at `ref`, or null when the ref has no such file.
|
|
625
480
|
* `./` anchors the path to the cwd (the workspace root) rather than the
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// `import './preset.js'` inside a config resolves to the same key no
|
|
7
7
|
// matter what query the entry carries — a busted entry re-evaluates
|
|
8
8
|
// against a STALE preset. Shared presets are the documented composition
|
|
9
|
-
// mechanism (`vx
|
|
9
|
+
// mechanism (`bunx @vzn/vx-migrate` generates one), so through a whole `vx watch`
|
|
10
10
|
// session a preset edit was invisible; and because the resolved config
|
|
11
11
|
// feeds the cache key, vx answered `up-to-date` for a command that had
|
|
12
12
|
// changed on disk.
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
// with the message the first load gives (item 701).
|
|
37
37
|
|
|
38
38
|
import path from 'node:path'
|
|
39
|
-
import { MAX_TIMEOUT_MS, UserError } from '../util/index.js'
|
|
39
|
+
import { MAX_TIMEOUT_MS, parseDuration, UserError } from '../util/index.js'
|
|
40
40
|
import { nonJsonMessage, nonJsonPaths, type NonJsonValue } from './json-data.js'
|
|
41
41
|
|
|
42
42
|
/** Why a config's `process.exit` throws, on both load paths (D-65). */
|
|
@@ -76,10 +76,10 @@ export const WATCHED_BUILTIN_NAMES = [
|
|
|
76
76
|
|
|
77
77
|
const WORKER_SRC = `
|
|
78
78
|
const nonJsonPaths = ${nonJsonPaths.toString()}
|
|
79
|
-
// The parent's stdout is a verb's JSON
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
79
|
+
// The parent's stdout is a verb's JSON, and a config printing on a repeat
|
|
80
|
+
// load wrote into it: the parent's own redirect covers the parent thread
|
|
81
|
+
// only (D-64). This worker only evaluates configs, so every route to fd 1
|
|
82
|
+
// is stderr's, Bun's own included (item 1069).
|
|
83
83
|
// Synchronous, before any message: an awaited import of the Console left
|
|
84
84
|
// the first evaluation racing its own redirect (D-73).
|
|
85
85
|
{
|
|
@@ -262,22 +262,17 @@ export function thrownValueMessage(kind: string, configPath: string, shown: stri
|
|
|
262
262
|
/**
|
|
263
263
|
* How long a single config evaluation may take, in the worker or in process
|
|
264
264
|
* (D-66); in process a synchronous loop holds the thread the deadline needs. Real evaluations are ~10 ms; this exists only so a hung config or a
|
|
265
|
-
* killed worker cannot stall a run or a long-lived process indefinitely.
|
|
266
|
-
*
|
|
265
|
+
* killed worker cannot stall a run or a long-lived process indefinitely.
|
|
266
|
+
* `config.workerTimeout` (`VX_CONFIG_WORKER_TIMEOUT`, read before any
|
|
267
|
+
* workspace loads), per call so a test can drive the deadline.
|
|
267
268
|
*/
|
|
268
269
|
export function evalBudgetMs(): number {
|
|
269
|
-
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
// and `0` is the second end: it fired on the next tick (D-157).
|
|
276
|
-
if (raw !== undefined && /^[0-9]+$/.test(raw)) {
|
|
277
|
-
const n = Number(raw)
|
|
278
|
-
if (n > 0 && n <= MAX_TIMEOUT_MS) return n
|
|
279
|
-
}
|
|
280
|
-
return 30_000
|
|
270
|
+
// A bound on a worker that may be wedged, with no "no limit" reading: 0
|
|
271
|
+
// fired on the next tick (D-157) and a timer past the platform's range at
|
|
272
|
+
// 1 ms, so both fall back. The CLI refuses either before a run
|
|
273
|
+
// (checkSettingsEnv); an embedder's falls back.
|
|
274
|
+
const n = parseDuration(process.env['VX_CONFIG_WORKER_TIMEOUT'] ?? '')
|
|
275
|
+
return n !== null && n > 0 && n <= MAX_TIMEOUT_MS ? n : 30_000
|
|
281
276
|
}
|
|
282
277
|
|
|
283
278
|
const pending = new Map<number, Pending>()
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
// affordable at all:
|
|
18
18
|
//
|
|
19
19
|
// - RELATIVE specifiers only. A bare specifier is a package; it moves when
|
|
20
|
-
// the lockfile moves, which
|
|
20
|
+
// the lockfile moves, which a lockfile plugin's key covers.
|
|
21
21
|
// A tsconfig `paths` / `baseUrl` alias is the exception: Bun loads it
|
|
22
22
|
// from disk (D-27).
|
|
23
23
|
// - One reversed BFS from every config at once, each file read once
|
|
@@ -27,12 +27,15 @@
|
|
|
27
27
|
// too, so an edit there moves the site's key. The walk stopped at the
|
|
28
28
|
// first file of another project, and containment selected core alone
|
|
29
29
|
// while the site's key moved (X-128).
|
|
30
|
+
//
|
|
31
|
+
// `configImports` walks one config's relative closure forward, for the
|
|
32
|
+
// files `vx watch` watches.
|
|
30
33
|
|
|
31
|
-
import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'
|
|
34
|
+
import { existsSync, lstatSync, readFileSync, realpathSync, statSync } from 'node:fs'
|
|
32
35
|
import { realpath } from 'node:fs/promises'
|
|
33
36
|
import { builtinModules } from 'node:module'
|
|
34
37
|
import path from 'node:path'
|
|
35
|
-
import { isOutOfFds } from '../util/index.js'
|
|
38
|
+
import { isOutOfFds, realPath } from '../util/index.js'
|
|
36
39
|
import type { ProjectMeta } from './workspace.js'
|
|
37
40
|
|
|
38
41
|
const BUILTINS = new Set(builtinModules)
|
|
@@ -467,3 +470,75 @@ export async function configImportOwners(a: ConfigImportOwnersArgs): Promise<Set
|
|
|
467
470
|
}
|
|
468
471
|
return selected
|
|
469
472
|
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* The canonical directory Bun resolves `file`'s imports from: its REAL
|
|
476
|
+
* path's, so a config linked in from elsewhere imports its neighbours
|
|
477
|
+
* there, not beside the link (item 950).
|
|
478
|
+
*/
|
|
479
|
+
function realDirOf(file: string): string {
|
|
480
|
+
if (lstatSync(file).isSymbolicLink()) return path.dirname(realPath(file))
|
|
481
|
+
return realPath(path.dirname(file))
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Every file a config imports by relative specifier, transitively, outside
|
|
486
|
+
* `node_modules`, the config itself excluded. `vx watch` watches these: a
|
|
487
|
+
* shared preset outside the project (`../../shared/preset.mjs`, the way
|
|
488
|
+
* configs compose) changed what a run evaluates and no watcher saw it
|
|
489
|
+
* (item 949). A file that cannot be read ends its branch; an import that
|
|
490
|
+
* cannot be resolved is still listed, by the names it would have.
|
|
491
|
+
*/
|
|
492
|
+
export async function configImports(configPath: string): Promise<string[]> {
|
|
493
|
+
const seen = new Set<string>([configPath])
|
|
494
|
+
const out: string[] = []
|
|
495
|
+
const queue = [configPath]
|
|
496
|
+
while (queue.length > 0) {
|
|
497
|
+
const file = queue.shift()!
|
|
498
|
+
let source: string
|
|
499
|
+
try {
|
|
500
|
+
source = await Bun.file(file).text()
|
|
501
|
+
} catch {
|
|
502
|
+
continue
|
|
503
|
+
}
|
|
504
|
+
let specs: string[]
|
|
505
|
+
try {
|
|
506
|
+
specs = scanner(TS_EXT.has(path.extname(file)) ? 'ts' : 'js')
|
|
507
|
+
.scanImports(source)
|
|
508
|
+
.map((i) => i.path)
|
|
509
|
+
} catch {
|
|
510
|
+
// A source the parser refuses (mid-edit) keeps the imports a plain
|
|
511
|
+
// match sees: watching too few files is what this list exists to
|
|
512
|
+
// prevent.
|
|
513
|
+
specs = [...source.matchAll(/\bfrom\s*['"]([^'"]+)['"]|\bimport\s*['"]([^'"]+)['"]/g)].map(
|
|
514
|
+
(m) => (m[1] ?? m[2])!,
|
|
515
|
+
)
|
|
516
|
+
}
|
|
517
|
+
let dir: string | undefined
|
|
518
|
+
for (const spec of specs) {
|
|
519
|
+
if (!spec.startsWith('./') && !spec.startsWith('../')) continue
|
|
520
|
+
let resolved: string
|
|
521
|
+
try {
|
|
522
|
+
dir ??= realDirOf(file)
|
|
523
|
+
resolved = realPath(Bun.resolveSync(spec, dir))
|
|
524
|
+
} catch {
|
|
525
|
+
// Missing for now, still imported: the config that names it fails
|
|
526
|
+
// to load until it is back, and a list without it left `vx watch`
|
|
527
|
+
// deaf to its return. Named as written, and not walked.
|
|
528
|
+
if (dir === undefined) continue
|
|
529
|
+
for (const ext of path.extname(spec) === '' ? RESOLVED_EXTENSIONS : ['']) {
|
|
530
|
+
const missing = path.resolve(dir, spec + ext)
|
|
531
|
+
if (seen.has(missing)) continue
|
|
532
|
+
seen.add(missing)
|
|
533
|
+
out.push(missing)
|
|
534
|
+
}
|
|
535
|
+
continue
|
|
536
|
+
}
|
|
537
|
+
if (seen.has(resolved) || resolved.split(path.sep).includes('node_modules')) continue
|
|
538
|
+
seen.add(resolved)
|
|
539
|
+
out.push(resolved)
|
|
540
|
+
queue.push(resolved)
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
return out
|
|
544
|
+
}
|