@vzn/vx 0.0.636 → 0.0.638
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 +39 -11
- package/docs/caching.md +246 -213
- package/docs/cli.md +791 -705
- package/docs/execution.md +68 -76
- package/docs/features.md +47 -42
- package/docs/patterns.md +26 -25
- package/docs/schema.md +392 -205
- 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 +4 -4
- package/schemas/show.json +5 -3
- package/schemas/why.json +15 -1
- package/skills/vx/SKILL.md +28 -13
- package/src/bin.ts +30 -9
- package/src/cache/archive.ts +11 -0
- package/src/cache/cache.ts +124 -53
- package/src/cache/chained-cache.ts +4 -0
- package/src/cache/git-inputs.ts +277 -63
- package/src/cache/index.ts +15 -1
- package/src/cache/inputs.ts +102 -36
- package/src/cache/key-fold.ts +8 -7
- package/src/cache/layer.ts +15 -18
- package/src/cache/layered-cache.ts +20 -102
- package/src/cache/plugin-cache.ts +248 -0
- package/src/cache/policy.ts +32 -27
- package/src/cache/run-history.ts +104 -14
- package/src/cache/run-store.ts +77 -0
- package/src/cache/schema.ts +58 -31
- package/src/cache/scoped-cache.ts +237 -0
- package/src/cli/cache.ts +173 -34
- package/src/cli/completions.ts +11 -7
- package/src/cli/core-alias.ts +1 -1
- package/src/cli/foreign-flags.ts +13 -12
- package/src/cli/help.ts +110 -70
- package/src/cli/index.ts +67 -24
- package/src/cli/info.ts +43 -27
- package/src/cli/init.ts +115 -407
- package/src/cli/last.ts +157 -9
- package/src/cli/lock.ts +356 -122
- package/src/cli/plan-format.ts +7 -5
- package/src/cli/plugin-commands.ts +81 -14
- package/src/cli/plugin-templates.ts +11 -11
- package/src/cli/run.ts +116 -160
- package/src/cli/select.ts +50 -47
- package/src/cli/show.ts +83 -59
- package/src/cli/task-verb.ts +6 -13
- package/src/cli/upgrade.ts +415 -149
- package/src/cli/watch-cycle.ts +9 -2
- package/src/cli/watch-filter.ts +39 -61
- package/src/cli/watch-fs.ts +29 -16
- package/src/cli/watch-judge.ts +18 -4
- package/src/cli/watch-set.ts +16 -42
- package/src/cli/watch.ts +227 -230
- package/src/cli/why.ts +38 -18
- package/src/cli/workspace-config.ts +33 -70
- package/src/config.ts +169 -70
- 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 +27 -7
- package/src/graph/task-graph.ts +76 -24
- package/src/handoff.ts +169 -0
- package/src/index.ts +35 -58
- package/src/orchestrator/affected-tasks.ts +15 -8
- 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 +147 -69
- package/src/orchestrator/failure-mode.ts +19 -10
- package/src/orchestrator/fingerprint-watch.ts +18 -15
- package/src/orchestrator/forecast.ts +10 -2
- package/src/orchestrator/framed-output.ts +9 -5
- package/src/orchestrator/graph-stage.ts +5 -5
- package/src/orchestrator/history.ts +126 -30
- package/src/orchestrator/hit-restore.ts +15 -3
- package/src/orchestrator/index.ts +20 -5
- package/src/orchestrator/local-shortcircuit.ts +2 -1
- package/src/orchestrator/lockfile-claim.ts +79 -20
- package/src/orchestrator/logger.ts +80 -41
- package/src/orchestrator/metrics.ts +78 -9
- package/src/orchestrator/miss-save.ts +3 -0
- package/src/orchestrator/options.ts +42 -29
- package/src/orchestrator/output-host.ts +72 -0
- package/src/orchestrator/plan.ts +6 -8
- package/src/orchestrator/plugin-host.ts +100 -66
- package/src/orchestrator/plugin.ts +173 -67
- package/src/orchestrator/prepare.ts +163 -78
- package/src/orchestrator/projects.ts +105 -99
- package/src/orchestrator/remote-prefetch.ts +11 -6
- 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-records.ts +1 -1
- package/src/orchestrator/run-report.ts +9 -9
- package/src/orchestrator/run-settings.ts +48 -0
- package/src/orchestrator/run.ts +244 -119
- 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 +49 -7
- package/src/orchestrator/summary.ts +115 -106
- package/src/orchestrator/task-hash.ts +12 -83
- package/src/orchestrator/telemetry.ts +4 -4
- package/src/settings.ts +428 -0
- package/src/{orchestrator → util}/colors.ts +5 -0
- package/src/util/config-frame.ts +31 -2
- package/src/util/errors.ts +33 -1
- package/src/util/essential-env.ts +31 -0
- package/src/util/hide-env.ts +107 -0
- package/src/util/index.ts +16 -3
- package/src/util/paths.ts +10 -0
- package/src/util/refusal-notice.ts +111 -0
- package/src/util/report.ts +31 -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/ui.ts +186 -0
- package/src/util/ulid.ts +1 -1
- package/src/util/verbs.ts +0 -15
- package/src/workspace/affected.ts +84 -232
- package/src/workspace/config-eval.ts +15 -20
- package/src/workspace/config-imports.ts +78 -3
- package/src/workspace/config-schema.ts +209 -268
- package/src/workspace/dependency-rules.ts +65 -0
- package/src/workspace/filter.ts +2 -2
- package/src/workspace/fingerprint.ts +11 -121
- package/src/workspace/index.ts +20 -27
- package/src/workspace/load-reads.ts +4 -4
- package/src/workspace/lockfile.ts +96 -140
- package/src/workspace/migration.ts +307 -215
- 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 +363 -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 {
|
|
@@ -145,6 +143,8 @@ export interface AffectedChanges {
|
|
|
145
143
|
/** Project → its changed paths, relative to the project's directory. */
|
|
146
144
|
paths: ReadonlyMap<string, readonly string[]>
|
|
147
145
|
whole: ReadonlySet<string>
|
|
146
|
+
/** A whole project → the workspace-wide file that made it so (a workspace config, a claimed root file). */
|
|
147
|
+
wholeBy?: ReadonlyMap<string, string>
|
|
148
148
|
/**
|
|
149
149
|
* The changed paths that are nested repositories (a submodule, an
|
|
150
150
|
* embedded repository), workspace-relative: each stands for every file
|
|
@@ -246,7 +246,7 @@ export async function affectedChanges(
|
|
|
246
246
|
gitPaths(args.workspaceRoot, ['ls-files', '--others', '--exclude-standard', '-z']))(),
|
|
247
247
|
])
|
|
248
248
|
|
|
249
|
-
// vx
|
|
249
|
+
// .vx/projects.lock.json (workspace-root metadata) is excluded like a gitignored
|
|
250
250
|
// file: re-running `vx lock` must not mark every project affected.
|
|
251
251
|
// An untracked file under `node_modules` is an install, which the input
|
|
252
252
|
// enumeration drops too (`isInstalledPath`); a tracked one is a source.
|
|
@@ -258,62 +258,61 @@ export async function affectedChanges(
|
|
|
258
258
|
const nested = new Set(diffed.gitlinks)
|
|
259
259
|
for (const rel of untracked) if (rel.endsWith('/')) nested.add(rel)
|
|
260
260
|
|
|
261
|
-
// A
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
//
|
|
261
|
+
// A root file a plugin CLAIMS (`VxPlugin.fingerprint`: a lockfile, an
|
|
262
|
+
// install setting) re-keys tasks through what its claimant folds, and it
|
|
263
|
+
// sits at the root, owned by no project or by the root alone: mapping
|
|
264
|
+
// paths to directories selected ZERO projects for a lockfile bump that
|
|
265
|
+
// re-keyed them all, and `vx run test --affected` after a `pnpm update`
|
|
266
|
+
// exited 0 having run nothing — the failure `docs/cli.md` warns against:
|
|
267
|
+
// "input hashing sees it, so `--affected` must too." So selection asks the
|
|
268
|
+
// claimant the question its key answers, with the bytes at the base ref
|
|
269
|
+
// and in the working tree; the answer is unioned with the path-owned
|
|
270
|
+
// projects below, and only "cannot tell" widens. Core keys no root file
|
|
271
|
+
// itself, so an unclaimed one is a path like any other.
|
|
269
272
|
//
|
|
270
273
|
// 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
|
-
const everything = (): AffectedChanges => {
|
|
274
|
+
const everything = (file: string): AffectedChanges => {
|
|
277
275
|
const all = new Set(args.projects.map((p) => p.name))
|
|
278
|
-
|
|
276
|
+
const wholeBy = new Map([...all].map((n) => [n, file]))
|
|
277
|
+
return { projects: all, changed, paths: new Map(), whole: all, wholeBy }
|
|
279
278
|
}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
|
|
279
|
+
// The workspace config's plugins shape every project's resolved config,
|
|
280
|
+
// so its edit can re-key any task and selection cannot tell which (item
|
|
281
|
+
// 953). No key folds the file; selection is not hashed, so widening here
|
|
282
|
+
// changes no key.
|
|
283
|
+
const wsConfig = WORKSPACE_CONFIG_FILENAMES.find((n) => changed.includes(n))
|
|
284
|
+
if (wsConfig !== undefined) return everything(wsConfig)
|
|
285
|
+
// Loading the workspace file costs an evaluation; only a changed ROOT
|
|
286
|
+
// name can be claimed, so a diff inside the projects never loads it.
|
|
287
|
+
let claims: FingerprintClaims | undefined
|
|
288
|
+
const loadClaims = async (): Promise<FingerprintClaims | undefined> =>
|
|
289
|
+
(claims ??= await args.fingerprintClaims?.())
|
|
290
|
+
// A patch bun.lock names folds into the key of the plugin that claims
|
|
291
|
+
// bun.lock (`bun()`; the lockfile records no hash of it), so its edit
|
|
292
|
+
// widens as a lockfile edit does. A patch the lockfile no longer names
|
|
293
|
+
// moved bun.lock itself.
|
|
285
294
|
if (changed.length > 0) {
|
|
286
295
|
const lock = await bytesOrNull(path.join(args.workspaceRoot, 'bun.lock'))
|
|
287
296
|
if (lock !== null) {
|
|
288
297
|
const patches = new Set(bunPatchFiles(lock))
|
|
289
|
-
|
|
298
|
+
const patch = changed.find((p) => patches.has(p))
|
|
299
|
+
if (patch !== undefined && (await loadClaims())?.files.has('bun.lock')) {
|
|
300
|
+
return everything(patch)
|
|
301
|
+
}
|
|
290
302
|
}
|
|
291
303
|
}
|
|
292
|
-
const claimedOwned = new
|
|
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
|
-
}
|
|
304
|
+
const claimedOwned = new Map<string, string>()
|
|
305
|
+
const rootNamesChanged = changed.filter((p) => !p.includes('/'))
|
|
306
|
+
const claimed = rootNamesChanged.length > 0 ? await loadClaims() : undefined
|
|
307
|
+
for (const file of rootNamesChanged) {
|
|
308
|
+
if (claimed === undefined || !claimed.files.has(file)) continue
|
|
309
|
+
const answer = await claimed.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(file)
|
|
315
|
+
for (const name of answer) if (!claimedOwned.has(name)) claimedOwned.set(name, file)
|
|
317
316
|
}
|
|
318
317
|
|
|
319
318
|
const realDirs = new Map(
|
|
@@ -322,7 +321,7 @@ export async function affectedChanges(
|
|
|
322
321
|
),
|
|
323
322
|
)
|
|
324
323
|
const paths = new Map<string, string[]>()
|
|
325
|
-
const whole = new Set<string>(claimedOwned)
|
|
324
|
+
const whole = new Set<string>(claimedOwned.keys())
|
|
326
325
|
const owned = projectsContaining(
|
|
327
326
|
args.workspaceRoot,
|
|
328
327
|
changed,
|
|
@@ -340,29 +339,13 @@ export async function affectedChanges(
|
|
|
340
339
|
}
|
|
341
340
|
: undefined,
|
|
342
341
|
)
|
|
343
|
-
for (const name of claimedOwned) owned.add(name)
|
|
344
|
-
|
|
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
|
-
}
|
|
342
|
+
for (const name of claimedOwned.keys()) owned.add(name)
|
|
343
|
+
|
|
344
|
+
// A config that makes an existing directory a project took files from
|
|
345
|
+
// the project above it.
|
|
346
|
+
const configs = changed.filter((rel) => CONFIG_NAMES.has(path.posix.basename(rel)))
|
|
347
|
+
if (configs.length > 0) {
|
|
348
|
+
const atBase = await gitBlobsAt(args.workspaceRoot, base, configs)
|
|
366
349
|
for (const name of parentsOfNewNested(args.workspaceRoot, atBase, args.projects)) {
|
|
367
350
|
owned.add(name)
|
|
368
351
|
whole.add(name)
|
|
@@ -393,6 +376,7 @@ export async function affectedChanges(
|
|
|
393
376
|
changed,
|
|
394
377
|
paths,
|
|
395
378
|
whole,
|
|
379
|
+
...(claimedOwned.size > 0 ? { wholeBy: claimedOwned } : {}),
|
|
396
380
|
...(nestedDirs.length > 0 ? { nested: nestedDirs } : {}),
|
|
397
381
|
})
|
|
398
382
|
if (changed.length === 0 || args.workspaceGlobOwners === undefined) return done()
|
|
@@ -400,102 +384,16 @@ export async function affectedChanges(
|
|
|
400
384
|
return done()
|
|
401
385
|
}
|
|
402
386
|
|
|
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
|
-
}
|
|
387
|
+
const CONFIG_NAMES: ReadonlySet<string> = new Set(PROJECT_CONFIG_FILENAMES)
|
|
490
388
|
|
|
491
389
|
/**
|
|
492
390
|
* 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
|
-
*
|
|
391
|
+
* stop at every project below it, so a config that makes an existing
|
|
392
|
+
* directory a project re-keys the project above it — its `**` lost the
|
|
393
|
+
* directory's files — while containment maps the change to the new
|
|
394
|
+
* project alone, and `--affected` left the re-keyed parent out. "New" is
|
|
395
|
+
* judged at the base: none of the changed configs in its directory was
|
|
396
|
+
* there. `atBase` holds each changed config there.
|
|
499
397
|
*/
|
|
500
398
|
function parentsOfNewNested(
|
|
501
399
|
workspaceRoot: string,
|
|
@@ -507,7 +405,10 @@ function parentsOfNewNested(
|
|
|
507
405
|
// dir discovered by readdir keeps the spelling it was created with.
|
|
508
406
|
const dirToName = new Map<string, string>()
|
|
509
407
|
for (const p of projects) dirToName.set(p.dir.normalize('NFC'), p.name)
|
|
510
|
-
|
|
408
|
+
const existed = new Set<string>()
|
|
409
|
+
for (const [rel, bytes] of atBase) if (bytes !== null) existed.add(path.posix.dirname(rel))
|
|
410
|
+
for (const rel of atBase.keys()) {
|
|
411
|
+
if (existed.has(path.posix.dirname(rel))) continue
|
|
511
412
|
const dir = path.resolve(workspaceRoot, path.posix.dirname(rel))
|
|
512
413
|
if (!dirToName.has(dir)) continue
|
|
513
414
|
let parent: string | undefined
|
|
@@ -515,17 +416,7 @@ function parentsOfNewNested(
|
|
|
515
416
|
parent = dirToName.get(d)
|
|
516
417
|
if (path.dirname(d) === d) break
|
|
517
418
|
}
|
|
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)
|
|
419
|
+
if (parent !== undefined) out.add(parent)
|
|
529
420
|
}
|
|
530
421
|
return out
|
|
531
422
|
}
|
|
@@ -581,45 +472,6 @@ async function gitBlobsAt(
|
|
|
581
472
|
return blobs
|
|
582
473
|
}
|
|
583
474
|
|
|
584
|
-
/**
|
|
585
|
-
* Whether `changed` holds the workspace config or a file it imports by
|
|
586
|
-
* relative specifier. Its plugins' `config` and `project` stages shape
|
|
587
|
-
* every project's resolved config, so such an edit can re-key any task,
|
|
588
|
-
* and selection cannot tell which: an edit that changed every key
|
|
589
|
-
* selected nothing (item 953). The fingerprint leaves the file out (its
|
|
590
|
-
* placement plugins must not split a cache between machines); a stage it
|
|
591
|
-
* installs re-keys through the resolved configs instead. Selection is not
|
|
592
|
-
* hashed, so widening here changes no key.
|
|
593
|
-
*/
|
|
594
|
-
async function workspaceConfigChanged(
|
|
595
|
-
workspaceRoot: string,
|
|
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
|
|
614
|
-
}
|
|
615
|
-
|
|
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
475
|
/**
|
|
624
476
|
* A root file's bytes at `ref`, or null when the ref has no such file.
|
|
625
477
|
* `./` 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
|
+
}
|