@vzn/vx 0.0.21 → 0.0.23
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 +21 -18
- package/package.json +6 -6
- package/src/bin.ts +52 -3
- package/src/cache/archive.ts +170 -21
- package/src/cache/cache.ts +667 -288
- package/src/cache/chained-cache.ts +14 -2
- package/src/cache/config-evals.ts +29 -6
- package/src/cache/file-hashes.ts +63 -35
- package/src/cache/git-inputs.ts +398 -69
- package/src/cache/index.ts +8 -1
- package/src/cache/inputs.ts +403 -102
- package/src/cache/key-fold.ts +232 -0
- package/src/cache/layer.ts +81 -11
- package/src/cache/layered-cache.ts +199 -32
- package/src/cache/output-index.ts +109 -10
- package/src/cache/run-history.ts +8 -3
- package/src/cache/zstd.ts +53 -30
- package/src/cli/cache.ts +51 -21
- package/src/cli/format.ts +4 -15
- package/src/cli/help.ts +13 -3
- package/src/cli/index.ts +35 -9
- package/src/cli/info.ts +49 -7
- package/src/cli/init.ts +17 -5
- package/src/cli/last.ts +96 -21
- package/src/cli/lock.ts +34 -17
- package/src/cli/plan-format.ts +4 -4
- package/src/cli/plugin-commands.ts +20 -5
- package/src/cli/run.ts +55 -12
- package/src/cli/select.ts +23 -13
- package/src/cli/show.ts +9 -4
- package/src/cli/upgrade.ts +134 -14
- package/src/cli/watch.ts +622 -102
- package/src/cli/why.ts +8 -16
- package/src/cli/workspace-config.ts +21 -9
- package/src/config.ts +46 -9
- package/src/exec/executor.ts +51 -5
- package/src/exec/index.ts +10 -0
- package/src/exec/kill-tree.ts +281 -0
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/runner.ts +327 -82
- package/src/exec/sandbox-binds.ts +34 -5
- package/src/exec/sandbox-paths.ts +22 -2
- package/src/exec/sandbox-runtime.ts +627 -102
- package/src/exec/sandbox-violations.ts +36 -23
- package/src/graph/dependency-spec.ts +14 -14
- package/src/graph/index.ts +12 -2
- package/src/graph/priorities.ts +130 -0
- package/src/graph/scheduler.ts +182 -134
- package/src/graph/task-graph.ts +552 -188
- package/src/index.ts +16 -2
- package/src/orchestrator/admission.ts +41 -17
- package/src/orchestrator/colors.ts +8 -3
- package/src/orchestrator/deferred-outputs.ts +22 -7
- package/src/orchestrator/doctor.ts +122 -14
- package/src/orchestrator/events.ts +71 -2
- package/src/orchestrator/excluded-keys.ts +155 -0
- package/src/orchestrator/execute-task.ts +331 -37
- package/src/orchestrator/failure-recap.ts +121 -0
- package/src/orchestrator/fingerprint-watch.ts +89 -0
- package/src/orchestrator/framed-output.ts +81 -14
- package/src/orchestrator/history.ts +6 -7
- package/src/orchestrator/hit-restore.ts +82 -18
- package/src/orchestrator/index.ts +7 -1
- package/src/orchestrator/keyed-projects.ts +89 -0
- package/src/orchestrator/local-shortcircuit.ts +83 -23
- package/src/orchestrator/lockfile-claim.ts +152 -32
- package/src/orchestrator/logger.ts +116 -20
- package/src/orchestrator/metrics.ts +156 -60
- package/src/orchestrator/miss-save.ts +106 -43
- package/src/orchestrator/options.ts +30 -4
- package/src/orchestrator/persistent.ts +41 -21
- package/src/orchestrator/placement.ts +55 -25
- package/src/orchestrator/plan.ts +24 -11
- package/src/orchestrator/plugin-host.ts +17 -23
- package/src/orchestrator/plugin.ts +31 -8
- package/src/orchestrator/prepare.ts +99 -13
- package/src/orchestrator/projects.ts +19 -8
- package/src/orchestrator/remote-prefetch.ts +21 -1
- package/src/orchestrator/run-artifacts.ts +9 -0
- package/src/orchestrator/run-context.ts +19 -4
- package/src/orchestrator/run-lock.ts +349 -0
- package/src/orchestrator/run-records.ts +6 -23
- package/src/orchestrator/run-report.ts +4 -2
- package/src/orchestrator/run.ts +264 -57
- package/src/orchestrator/sandbox-request.ts +388 -62
- package/src/orchestrator/save-lane.ts +4 -7
- package/src/orchestrator/shell-verdict.ts +117 -0
- package/src/orchestrator/signals.ts +86 -30
- package/src/orchestrator/stable-keys.ts +202 -10
- package/src/orchestrator/status-line.ts +33 -1
- package/src/orchestrator/summary.ts +49 -2
- package/src/orchestrator/task-hash.ts +87 -7
- package/src/orchestrator/task-log-buffer.ts +7 -7
- package/src/orchestrator/telemetry-host.ts +16 -7
- package/src/orchestrator/telemetry.ts +69 -28
- package/src/orchestrator/upstream.ts +91 -45
- package/src/util/bun-version.ts +43 -0
- package/src/util/errors.ts +86 -0
- package/src/util/hash.ts +10 -1
- package/src/util/index.ts +32 -3
- package/src/util/paths.ts +147 -5
- package/src/util/procfs.ts +20 -0
- package/src/util/settle.ts +4 -2
- package/src/util/size.ts +33 -0
- package/src/util/task-id.ts +20 -0
- package/src/util/timing.ts +11 -1
- package/src/util/which.ts +48 -0
- package/src/workspace/affected.ts +272 -64
- package/src/workspace/config-cache.ts +262 -32
- package/src/workspace/config-eval.ts +49 -12
- package/src/workspace/config-imports.ts +126 -5
- package/src/workspace/config-schema.ts +237 -41
- package/src/workspace/filter.ts +33 -18
- package/src/workspace/fingerprint.ts +29 -11
- package/src/workspace/index.ts +7 -0
- package/src/workspace/json-data.ts +90 -0
- package/src/workspace/load-reads.ts +32 -0
- package/src/workspace/lockfile.ts +33 -11
- package/src/workspace/migrate-scripts.ts +60 -7
- package/src/workspace/migration.ts +40 -1
- package/src/workspace/package-graph.ts +154 -16
- package/src/workspace/project-loader.ts +227 -77
- package/src/workspace/workspace.ts +173 -23
package/src/workspace/filter.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// <pattern>... pattern + its transitive workspace dependencies
|
|
7
7
|
// ...<pattern> pattern + its transitive workspace dependents
|
|
8
8
|
// <pattern>^... only the transitive deps of pattern (excluding the matched package)
|
|
9
|
+
// ...^<pattern> only the transitive dependents of pattern (excluding the matched package)
|
|
9
10
|
// !<pattern> exclude packages matching pattern from the selection
|
|
10
11
|
// [<since>] projects affected since the given git ref
|
|
11
12
|
// (Turbo-style; resolved upstream of applyFilters via
|
|
@@ -16,6 +17,7 @@
|
|
|
16
17
|
// given, the base set is "all projects" and excluded packages are removed.
|
|
17
18
|
|
|
18
19
|
import path from 'node:path'
|
|
20
|
+
import { BUN_GLOB_WILDCARDS } from '../util/index.js'
|
|
19
21
|
import type { PackageGraph } from './package-graph.js'
|
|
20
22
|
import type { ProjectMeta } from './workspace.js'
|
|
21
23
|
|
|
@@ -25,6 +27,8 @@ export interface ParsedFilter {
|
|
|
25
27
|
withDeps: boolean
|
|
26
28
|
withDependents: boolean
|
|
27
29
|
onlyDeps: boolean
|
|
30
|
+
/** `...^<pattern>`: the dependents alone, the matched package left out. */
|
|
31
|
+
onlyDependents: boolean
|
|
28
32
|
isPath: boolean
|
|
29
33
|
/** Glob pattern (for name match) or absolute path (for path match). */
|
|
30
34
|
matcher: string
|
|
@@ -47,6 +51,10 @@ export function parseFilter(raw: string, workspaceRoot: string): ParsedFilter {
|
|
|
47
51
|
|
|
48
52
|
const withDependents = s.startsWith('...')
|
|
49
53
|
if (withDependents) s = s.slice(3)
|
|
54
|
+
// `...^a`: cli.md listed it, and the `^` stayed in the name glob, so it
|
|
55
|
+
// matched no package and the run refused (item 890).
|
|
56
|
+
const onlyDependents = withDependents && s.startsWith('^')
|
|
57
|
+
if (onlyDependents) s = s.slice(1)
|
|
50
58
|
|
|
51
59
|
let onlyDeps = false
|
|
52
60
|
let withDeps = false
|
|
@@ -67,6 +75,7 @@ export function parseFilter(raw: string, workspaceRoot: string): ParsedFilter {
|
|
|
67
75
|
withDeps,
|
|
68
76
|
withDependents,
|
|
69
77
|
onlyDeps,
|
|
78
|
+
onlyDependents,
|
|
70
79
|
isPath: false,
|
|
71
80
|
matcher: '',
|
|
72
81
|
gitSince: s.slice(1, -1),
|
|
@@ -88,7 +97,8 @@ export function parseFilter(raw: string, workspaceRoot: string): ParsedFilter {
|
|
|
88
97
|
// `./packages/*` — pnpm's and Turbo's spelling for "every package under
|
|
89
98
|
// packages": a glob over the root-relative project dir. Resolved
|
|
90
99
|
// against the workspace root like the literal form.
|
|
91
|
-
|
|
100
|
+
// A member glob, not a task glob: `Bun.Glob`'s alphabet, the class included.
|
|
101
|
+
if (BUN_GLOB_WILDCARDS.test(pathForm)) {
|
|
92
102
|
const rel = path.relative(workspaceRoot, matcher).split(path.sep).join('/')
|
|
93
103
|
pathGlob = new Bun.Glob(rel.replace(/\/+$/, ''))
|
|
94
104
|
}
|
|
@@ -100,6 +110,7 @@ export function parseFilter(raw: string, workspaceRoot: string): ParsedFilter {
|
|
|
100
110
|
withDeps,
|
|
101
111
|
withDependents,
|
|
102
112
|
onlyDeps,
|
|
113
|
+
onlyDependents,
|
|
103
114
|
isPath,
|
|
104
115
|
matcher,
|
|
105
116
|
...(pathGlob !== undefined ? { pathGlob, pathRoot: workspaceRoot } : {}),
|
|
@@ -119,6 +130,15 @@ function matchProjects(
|
|
|
119
130
|
}
|
|
120
131
|
const out: string[] = []
|
|
121
132
|
if (filter.isPath) {
|
|
133
|
+
const prefix = filter.matcher + path.sep
|
|
134
|
+
for (const p of projects) {
|
|
135
|
+
if (p.dir === filter.matcher || p.dir.startsWith(prefix)) out.push(p.name)
|
|
136
|
+
}
|
|
137
|
+
// A path that names a project directory literally means that directory,
|
|
138
|
+
// as git reads a pathspec, even when it holds glob characters
|
|
139
|
+
// (`./packages/[abc]`); only a path that selects nothing literally is
|
|
140
|
+
// read as a glob.
|
|
141
|
+
if (out.length > 0) return out
|
|
122
142
|
if (filter.pathGlob !== undefined) {
|
|
123
143
|
// The glob is matched against the project's own dir, as pnpm and
|
|
124
144
|
// Turbo do: `./packages/*` is the packages directly under `packages`,
|
|
@@ -130,20 +150,12 @@ function matchProjects(
|
|
|
130
150
|
.join('/')
|
|
131
151
|
if (filter.pathGlob.match(rel)) out.push(p.name)
|
|
132
152
|
}
|
|
133
|
-
return out
|
|
134
|
-
}
|
|
135
|
-
const prefix = filter.matcher + path.sep
|
|
136
|
-
for (const p of projects) {
|
|
137
|
-
if (p.dir === filter.matcher || p.dir.startsWith(prefix)) out.push(p.name)
|
|
138
|
-
}
|
|
139
|
-
return out
|
|
140
|
-
}
|
|
141
|
-
if (!filter.matcher.includes('*')) {
|
|
142
|
-
for (const p of projects) {
|
|
143
|
-
if (p.name === filter.matcher) out.push(p.name)
|
|
144
153
|
}
|
|
145
154
|
return out
|
|
146
155
|
}
|
|
156
|
+
// A `*`-free name compiles to an exact anchored match, so a bare name
|
|
157
|
+
// needs no branch of its own: the one this had was deleted with the
|
|
158
|
+
// whole core suite green (item 649).
|
|
147
159
|
const re = compileNameGlob(filter.matcher)
|
|
148
160
|
for (const p of projects) {
|
|
149
161
|
if (re.test(p.name)) out.push(p.name)
|
|
@@ -186,12 +198,17 @@ export function applyFilters(opts: ApplyFiltersOptions): Set<string> {
|
|
|
186
198
|
const hasInclude = opts.filters.some((f) => !f.negate)
|
|
187
199
|
const selected = new Set<string>(hasInclude ? [] : allNames)
|
|
188
200
|
|
|
201
|
+
// Every include before any exclude, as pnpm does: applied in argv order,
|
|
202
|
+
// `--filter '!b' --filter 'a...'` let the later include add back what the
|
|
203
|
+
// exclude had removed, and b ran (item 979). The expansions are still
|
|
204
|
+
// taken in argv order, so `onNoMatch` names the filters as typed.
|
|
205
|
+
const excludes: Set<string>[] = []
|
|
189
206
|
for (const f of opts.filters) {
|
|
190
207
|
const matched = matchProjects(f, opts.projects, opts.affectedByFilter)
|
|
191
208
|
if (matched.length === 0) opts.onNoMatch?.(f)
|
|
192
209
|
const expanded = new Set<string>()
|
|
193
210
|
for (const name of matched) {
|
|
194
|
-
if (!f.onlyDeps) expanded.add(name)
|
|
211
|
+
if (!f.onlyDeps && !f.onlyDependents) expanded.add(name)
|
|
195
212
|
if (f.withDeps || f.onlyDeps) {
|
|
196
213
|
for (const d of opts.graph.transitiveDeps(name)) expanded.add(d)
|
|
197
214
|
}
|
|
@@ -199,12 +216,10 @@ export function applyFilters(opts: ApplyFiltersOptions): Set<string> {
|
|
|
199
216
|
for (const d of opts.graph.transitiveDependents(name)) expanded.add(d)
|
|
200
217
|
}
|
|
201
218
|
}
|
|
202
|
-
if (f.negate)
|
|
203
|
-
|
|
204
|
-
} else {
|
|
205
|
-
for (const name of expanded) selected.add(name)
|
|
206
|
-
}
|
|
219
|
+
if (f.negate) excludes.push(expanded)
|
|
220
|
+
else for (const name of expanded) selected.add(name)
|
|
207
221
|
}
|
|
222
|
+
for (const expanded of excludes) for (const name of expanded) selected.delete(name)
|
|
208
223
|
|
|
209
224
|
return selected
|
|
210
225
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import path from 'node:path'
|
|
2
2
|
import { xxh3 } from '../util/index.js'
|
|
3
|
+
import { type LoadReads, readOnce } from './load-reads.js'
|
|
3
4
|
|
|
4
5
|
// Every package-manager lockfile we know about, plus the workspace
|
|
5
6
|
// definition files. Whichever ones exist get folded into the
|
|
@@ -23,6 +24,12 @@ export const WORKSPACE_FINGERPRINT_FILES = [
|
|
|
23
24
|
// Workspace definition (pnpm); package.json is hashed per-project
|
|
24
25
|
// via projectPackageJsonHash, not here.
|
|
25
26
|
'pnpm-workspace.yaml',
|
|
27
|
+
// Yarn 4 catalogs: yarn.lock records a `catalog:` dependency as that
|
|
28
|
+
// literal, so a catalog flip to a range the file already resolves
|
|
29
|
+
// leaves it byte-identical while the workspace's install moves
|
|
30
|
+
// (turborepo#12635). pnpm's catalogs live in pnpm-workspace.yaml, and
|
|
31
|
+
// its lockfile records each importer's resolved version besides.
|
|
32
|
+
'.yarnrc.yml',
|
|
26
33
|
]
|
|
27
34
|
|
|
28
35
|
// DELIBERATELY ABSENT: `vx.workspace.{ts,mts,js,mjs}`. Everything it can
|
|
@@ -43,8 +50,11 @@ export const WORKSPACE_FINGERPRINT_FILES = [
|
|
|
43
50
|
* `npm install`, `bun install`, …) or a workspace-shape change
|
|
44
51
|
* invalidates every cached entry. Coarse but correct.
|
|
45
52
|
*/
|
|
46
|
-
export async function computeWorkspaceFingerprint(
|
|
47
|
-
|
|
53
|
+
export async function computeWorkspaceFingerprint(
|
|
54
|
+
workspaceRoot: string,
|
|
55
|
+
reads?: LoadReads,
|
|
56
|
+
): Promise<string> {
|
|
57
|
+
return (await computeWorkspaceFingerprints(workspaceRoot, NONE, reads)).all
|
|
48
58
|
}
|
|
49
59
|
|
|
50
60
|
const NONE: ReadonlySet<string> = new Set()
|
|
@@ -57,32 +67,40 @@ export interface WorkspaceFingerprints {
|
|
|
57
67
|
* — what every task key folds. Identical to `all` with nothing claimed.
|
|
58
68
|
*/
|
|
59
69
|
readonly unclaimed: string
|
|
70
|
+
/**
|
|
71
|
+
* The bytes folded, by file name — what a mid-run check compares against
|
|
72
|
+
* when a task may have rewritten one (orchestrator/fingerprint-watch.ts).
|
|
73
|
+
* Already in memory for the fold; kept, not copied.
|
|
74
|
+
*/
|
|
75
|
+
readonly files: ReadonlyMap<string, Uint8Array>
|
|
60
76
|
}
|
|
61
77
|
|
|
62
78
|
/**
|
|
63
|
-
* Both digests from one read of each file
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
79
|
+
* Both digests from one read of each file — none at all for the
|
|
80
|
+
* `pnpm-workspace.yaml` discovery already read into the load's `reads`. A
|
|
81
|
+
* claimed file leaves the key digest entirely (its name too): the claimant
|
|
82
|
+
* folds what it means per project, and a fold of "present" would still
|
|
83
|
+
* re-key the workspace when the file appeared.
|
|
67
84
|
*/
|
|
68
85
|
export async function computeWorkspaceFingerprints(
|
|
69
86
|
workspaceRoot: string,
|
|
70
87
|
claimed: ReadonlySet<string>,
|
|
88
|
+
reads?: LoadReads,
|
|
71
89
|
): Promise<WorkspaceFingerprints> {
|
|
72
90
|
let all = 0n
|
|
73
91
|
let unclaimed = 0n
|
|
92
|
+
const files = new Map<string, Uint8Array>()
|
|
74
93
|
for (const f of WORKSPACE_FINGERPRINT_FILES) {
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const bytes = await file.bytes()
|
|
94
|
+
const bytes = await readOnce(reads, path.join(workspaceRoot, f))
|
|
95
|
+
if (bytes === null) continue
|
|
96
|
+
files.set(f, bytes)
|
|
79
97
|
all = xxh3(`${f}\0`, all)
|
|
80
98
|
all = xxh3(bytes, all)
|
|
81
99
|
if (claimed.has(f)) continue
|
|
82
100
|
unclaimed = xxh3(`${f}\0`, unclaimed)
|
|
83
101
|
unclaimed = xxh3(bytes, unclaimed)
|
|
84
102
|
}
|
|
85
|
-
return { all: hex(all), unclaimed: hex(unclaimed) }
|
|
103
|
+
return { all: hex(all), unclaimed: hex(unclaimed), files }
|
|
86
104
|
}
|
|
87
105
|
|
|
88
106
|
function hex(h: bigint): string {
|
package/src/workspace/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ export {
|
|
|
5
5
|
affectedProjects,
|
|
6
6
|
defaultAffectedBase,
|
|
7
7
|
type FingerprintClaims,
|
|
8
|
+
refIsHead,
|
|
8
9
|
workspaceGlobsMatch,
|
|
9
10
|
} from './affected.js'
|
|
10
11
|
export { applyFilters, parseFilter } from './filter.js'
|
|
@@ -39,20 +40,26 @@ export {
|
|
|
39
40
|
blobOidOf,
|
|
40
41
|
configEvalKey,
|
|
41
42
|
configEvalKeyFromClosure,
|
|
43
|
+
configImports,
|
|
42
44
|
type ConfigEvalStore,
|
|
43
45
|
} from './config-cache.js'
|
|
44
46
|
export {
|
|
45
47
|
findWorkspaceRoot,
|
|
46
48
|
listProjects,
|
|
47
49
|
loadWorkspace,
|
|
50
|
+
unreachedHint,
|
|
51
|
+
unreachedPackages,
|
|
48
52
|
memberBaseDirs,
|
|
53
|
+
PROJECT_CONFIG_FILENAMES,
|
|
49
54
|
resolveCacheDir,
|
|
50
55
|
type ProjectEntry,
|
|
51
56
|
type ProjectMeta,
|
|
52
57
|
} from './workspace.js'
|
|
58
|
+
export type { LoadReads } from './load-reads.js'
|
|
53
59
|
// The migration seam (see migration.ts) and core's own mapper, the scripts one.
|
|
54
60
|
export {
|
|
55
61
|
applyMigration,
|
|
62
|
+
foldScriptHooks,
|
|
56
63
|
PERSISTENT_TASK_NAMES,
|
|
57
64
|
PERSISTENT_TODO,
|
|
58
65
|
quoteTsLiteral,
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// A project config is JSON data (item 701).
|
|
2
|
+
//
|
|
3
|
+
// The cache key folds `JSON.stringify` of a task's config, `vx lock`
|
|
4
|
+
// stores the same JSON, and a repeat load (`vx watch`) crosses back from
|
|
5
|
+
// its worker as JSON. A value JSON cannot carry faithfully was therefore
|
|
6
|
+
// refused by one path and dropped by another — `description: () => 'x'`
|
|
7
|
+
// failed `vx run`'s first load ("must be a string") and passed the
|
|
8
|
+
// worker's, which never saw it — or, where the validator does not type
|
|
9
|
+
// the value, it was silently outside the key on both. So every path
|
|
10
|
+
// refuses it, before schema validation, by this one rule: the first load
|
|
11
|
+
// on the live object (`validateProjectConfig`), the worker before its
|
|
12
|
+
// `JSON.stringify` (config-eval.ts), and the site's playground in its own
|
|
13
|
+
// Worker. The two workers embed `nonJsonPaths` by its source text, so
|
|
14
|
+
// the function must stay self-contained: no import, no module-scope name.
|
|
15
|
+
//
|
|
16
|
+
// An `undefined` PROPERTY is allowed: JSON drops it, the validator reads
|
|
17
|
+
// it as absent, and configs write it with conditional spreads. Inside an
|
|
18
|
+
// array it is refused, because JSON writes it as `null`.
|
|
19
|
+
|
|
20
|
+
/** A value JSON cannot carry, at the path the validator would name. */
|
|
21
|
+
export interface NonJsonValue {
|
|
22
|
+
path: string
|
|
23
|
+
/** What it is, as the message reads: `a function`, `NaN`, `an instance of Map`. */
|
|
24
|
+
is: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Every value in `value` that `JSON.stringify` would drop, rewrite or throw on. */
|
|
28
|
+
export function nonJsonPaths(value: unknown): NonJsonValue[] {
|
|
29
|
+
const out: NonJsonValue[] = []
|
|
30
|
+
// The key or index at each level, rendered only for a finding: the walk
|
|
31
|
+
// runs on every first load, and a path string built per node cost three
|
|
32
|
+
// times the schema check itself.
|
|
33
|
+
const keys: Array<string | number> = []
|
|
34
|
+
// The objects on the path to the one being walked: a cycle throws in
|
|
35
|
+
// `JSON.stringify` and would recurse here forever. A DAG is data.
|
|
36
|
+
const ancestors: object[] = []
|
|
37
|
+
const found = (is: string): void => {
|
|
38
|
+
let path = ''
|
|
39
|
+
for (const k of keys) path += typeof k === 'number' ? `[${k}]` : path === '' ? k : `.${k}`
|
|
40
|
+
out.push({ path: path === '' ? 'the default export' : path, is })
|
|
41
|
+
}
|
|
42
|
+
const walk = (v: unknown, inArray: boolean): void => {
|
|
43
|
+
if (typeof v === 'function') found('a function')
|
|
44
|
+
else if (typeof v === 'symbol') found('a symbol')
|
|
45
|
+
else if (typeof v === 'bigint') found('a bigint')
|
|
46
|
+
else if (typeof v === 'number' && !Number.isFinite(v)) found(String(v))
|
|
47
|
+
else if (v === undefined && inArray) found('undefined in an array, which JSON writes as null')
|
|
48
|
+
else if (typeof v === 'object' && v !== null) {
|
|
49
|
+
if (ancestors.includes(v)) {
|
|
50
|
+
found('a cyclic reference')
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
if (Array.isArray(v)) {
|
|
54
|
+
ancestors.push(v)
|
|
55
|
+
// By index, not `forEach`: a hole is `undefined` to JSON too.
|
|
56
|
+
for (let i = 0; i < v.length; i++) {
|
|
57
|
+
keys.push(i)
|
|
58
|
+
walk(v[i], true)
|
|
59
|
+
keys.pop()
|
|
60
|
+
}
|
|
61
|
+
ancestors.pop()
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
const proto: unknown = Object.getPrototypeOf(v)
|
|
65
|
+
if (proto !== Object.prototype && proto !== null) {
|
|
66
|
+
const name = (proto as { constructor?: { name?: unknown } }).constructor?.name
|
|
67
|
+
found(
|
|
68
|
+
typeof name === 'string' && name !== ''
|
|
69
|
+
? `an instance of ${name}`
|
|
70
|
+
: 'an object whose prototype is not Object.prototype',
|
|
71
|
+
)
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
ancestors.push(v)
|
|
75
|
+
for (const key of Object.keys(v)) {
|
|
76
|
+
keys.push(key)
|
|
77
|
+
walk((v as Record<string, unknown>)[key], false)
|
|
78
|
+
keys.pop()
|
|
79
|
+
}
|
|
80
|
+
ancestors.pop()
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
walk(value, false)
|
|
84
|
+
return out
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The refusal every path gives, the CLI's and the playground's. */
|
|
88
|
+
export function nonJsonMessage(configPath: string, found: NonJsonValue): string {
|
|
89
|
+
return `${configPath}: ${found.path} is ${found.is} — a config must be JSON data, because the cache key folds its JSON`
|
|
90
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// A load reads each root file once. Finding the root reads its manifest,
|
|
2
|
+
// listing the package globs read it again and the workspace fingerprint a
|
|
3
|
+
// third time. Every such read goes through a `LoadReads` the load owns, so
|
|
4
|
+
// a file costs one probe and one read per load however many stages ask for
|
|
5
|
+
// it. The map lives exactly as long as the load: a `vx watch` cycle is a
|
|
6
|
+
// new load and reads fresh bytes.
|
|
7
|
+
|
|
8
|
+
/** The files one load has read, by absolute path: the bytes, or null when no file is there. */
|
|
9
|
+
export type LoadReads = Map<string, Promise<Uint8Array | null>>
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `file`'s bytes, read at most once per `reads`; null when there is no file
|
|
13
|
+
* there (`Bun.file(<dir>).exists()` is false, so a directory is none).
|
|
14
|
+
*
|
|
15
|
+
* The probe stays ahead of the read: most names asked about are absent (six
|
|
16
|
+
* of the seven fingerprint files, three of the four workspace config names),
|
|
17
|
+
* and a failed read costs 90–200 µs building its error where `exists()`
|
|
18
|
+
* answers in 15–40 µs (measured 2026-09-24) — so one stat per present file
|
|
19
|
+
* is cheaper than letting the open answer both questions.
|
|
20
|
+
*/
|
|
21
|
+
export function readOnce(reads: LoadReads | undefined, file: string): Promise<Uint8Array | null> {
|
|
22
|
+
const held = reads?.get(file)
|
|
23
|
+
if (held !== undefined) return held
|
|
24
|
+
const pending = readIfFile(file)
|
|
25
|
+
reads?.set(file, pending)
|
|
26
|
+
return pending
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function readIfFile(file: string): Promise<Uint8Array | null> {
|
|
30
|
+
const f = Bun.file(file)
|
|
31
|
+
return (await f.exists()) ? await f.bytes() : null
|
|
32
|
+
}
|
|
@@ -2,15 +2,18 @@
|
|
|
2
2
|
//
|
|
3
3
|
// `vx lock` evaluates every project's vx.config.* in the CURRENT
|
|
4
4
|
// environment and freezes the resolved objects (plus a content hash of
|
|
5
|
-
// each config file) into `vx-lock.json` at the workspace root.
|
|
6
|
-
//
|
|
7
|
-
// frozen-env semantics. See
|
|
5
|
+
// each config file) into `vx-lock.json` at the workspace root. A run
|
|
6
|
+
// under `--frozen` loads configs from it instead of evaluating —
|
|
7
|
+
// frozen-env semantics; a plain run always evaluates live. See
|
|
8
|
+
// docs/design/config-lock-2026-06.md.
|
|
8
9
|
//
|
|
9
10
|
// Verification asymmetry (deliberate):
|
|
10
|
-
// - runs TRUST the lock:
|
|
11
|
-
// - `vx lock --check` AUDITS it:
|
|
12
|
-
// which catches eval-time env drift
|
|
11
|
+
// - `--frozen` runs TRUST the lock: no staleness check, eval-free.
|
|
12
|
+
// - `vx lock --check` AUDITS it: the file hashes, then full
|
|
13
|
+
// re-evaluation + deep equality, which catches eval-time env drift
|
|
14
|
+
// that file hashes cannot see.
|
|
13
15
|
|
|
16
|
+
import { rename, rm } from 'node:fs/promises'
|
|
14
17
|
import path from 'node:path'
|
|
15
18
|
import type { ProjectConfig } from '../config.js'
|
|
16
19
|
import { relPosix, UserError } from '../util/index.js'
|
|
@@ -37,16 +40,16 @@ export function lockfilePath(root: string): string {
|
|
|
37
40
|
return path.join(root, LOCKFILE_NAME)
|
|
38
41
|
}
|
|
39
42
|
|
|
43
|
+
/** What a `--frozen` verb says when there is no lock to read. */
|
|
44
|
+
export const FROZEN_WITHOUT_LOCK =
|
|
45
|
+
"--frozen requires vx-lock.json at the workspace root — run 'vx lock' and commit it"
|
|
46
|
+
|
|
40
47
|
/**
|
|
41
48
|
* Read and shape-validate `vx-lock.json`. Returns `null` when no lock
|
|
42
49
|
* exists (the common case — the feature is opt-in). The lock is a
|
|
43
50
|
* user-editable file, so this is a system boundary: malformed content
|
|
44
51
|
* throws a `UserError` instead of crashing deeper in the run.
|
|
45
52
|
*/
|
|
46
|
-
/** What a `--frozen` verb says when there is no lock to read. */
|
|
47
|
-
export const FROZEN_WITHOUT_LOCK =
|
|
48
|
-
"--frozen requires vx-lock.json at the workspace root — run 'vx lock' and commit it"
|
|
49
|
-
|
|
50
53
|
export async function readLockfile(root: string): Promise<Lockfile | null> {
|
|
51
54
|
const file = Bun.file(lockfilePath(root))
|
|
52
55
|
if (!(await file.exists())) return null
|
|
@@ -96,7 +99,26 @@ export async function readLockfile(root: string): Promise<Lockfile | null> {
|
|
|
96
99
|
}
|
|
97
100
|
|
|
98
101
|
export async function writeLockfile(root: string, lock: Lockfile): Promise<void> {
|
|
99
|
-
|
|
102
|
+
// Written beside its name and renamed over it: `Bun.write` truncates in
|
|
103
|
+
// place, so a `--frozen` run, or `vx watch --frozen` woken by the write
|
|
104
|
+
// itself (item 971), could read an empty or half-written lock and fail
|
|
105
|
+
// on "not valid JSON" (item 972).
|
|
106
|
+
const file = lockfilePath(root)
|
|
107
|
+
const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`
|
|
108
|
+
try {
|
|
109
|
+
await Bun.write(tmp, `${JSON.stringify(lock, null, 2)}\n`)
|
|
110
|
+
await rename(tmp, file)
|
|
111
|
+
} catch (err) {
|
|
112
|
+
// Best effort: where the write was refused the temp cannot be named
|
|
113
|
+
// either (ENOTDIR), and that must not replace the refusal itself.
|
|
114
|
+
await rm(tmp, { force: true }).catch(() => {})
|
|
115
|
+
// The refusal names the file the user knows: a read-only checkout read
|
|
116
|
+
// `open '…/vx-lock.json.tmp-…'` where it had read the lock's own name.
|
|
117
|
+
const e = err as NodeJS.ErrnoException
|
|
118
|
+
if (typeof e.message === 'string') e.message = e.message.split(tmp).join(file)
|
|
119
|
+
if (e.path === tmp) e.path = file
|
|
120
|
+
throw err
|
|
121
|
+
}
|
|
100
122
|
}
|
|
101
123
|
|
|
102
124
|
/**
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
|
|
27
27
|
import type { ProjectMeta } from './workspace.js'
|
|
28
28
|
import {
|
|
29
|
+
foldScriptHooks,
|
|
29
30
|
type GeneratedProject,
|
|
30
31
|
type GeneratedTask,
|
|
31
32
|
type MigrationPlan,
|
|
@@ -46,10 +47,38 @@ const LIFECYCLE = /^(pre|post)(install|publish|pack|version)$|^(prepare|prepubli
|
|
|
46
47
|
*/
|
|
47
48
|
export function delegatedScript(command: string): string | null {
|
|
48
49
|
const m =
|
|
49
|
-
/^(?:(?:npm run|pnpm
|
|
50
|
+
/^(?:(?:npm run|(pnpm|yarn|bun)(?: (run))?) ([^\s&|;<>()$`'"\\]+)|npm (test|start))$/.exec(
|
|
50
51
|
command.trim(),
|
|
51
52
|
)
|
|
52
|
-
|
|
53
|
+
if (m === null) return null
|
|
54
|
+
if (m[4] !== undefined) return m[4]
|
|
55
|
+
const [, manager, run, name] = m
|
|
56
|
+
// Bare, the manager's own command wins over a script of that name:
|
|
57
|
+
// `bun test` is Bun's test runner and `bun build` its bundler, never the
|
|
58
|
+
// `test` / `build` script, and a group over the script ran the wrong
|
|
59
|
+
// thing (item 908). `pnpm test` and `yarn test` do run the script.
|
|
60
|
+
if (manager !== undefined && run === undefined && OWN_COMMANDS[manager]!.has(name!)) return null
|
|
61
|
+
return name!
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** What `<manager> <name>`, with no `run`, runs as the manager's own command. */
|
|
65
|
+
const OWN_COMMANDS: Readonly<Record<string, ReadonlySet<string>>> = {
|
|
66
|
+
// `bun --help`, Bun 1.4.2, with the short aliases.
|
|
67
|
+
bun: new Set(
|
|
68
|
+
'run test x repl exec install i add a remove rm update audit outdated link unlink publish patch pm info why build init create c upgrade feedback lint'.split(
|
|
69
|
+
' ',
|
|
70
|
+
),
|
|
71
|
+
),
|
|
72
|
+
pnpm: new Set(
|
|
73
|
+
'add install i update up remove rm link unlink import rebuild prune fetch patch audit list ls outdated why exec dlx create init publish pack store env setup config root bin licenses deploy doctor'.split(
|
|
74
|
+
' ',
|
|
75
|
+
),
|
|
76
|
+
),
|
|
77
|
+
yarn: new Set(
|
|
78
|
+
'add install remove upgrade up info init link unlink pack publish why workspace workspaces config cache dlx exec bin node plugin set version constraints npm patch explain dedupe'.split(
|
|
79
|
+
' ',
|
|
80
|
+
),
|
|
81
|
+
),
|
|
53
82
|
}
|
|
54
83
|
|
|
55
84
|
function scriptsOf(meta: ProjectMeta): Record<string, unknown> {
|
|
@@ -60,6 +89,29 @@ function scriptsOf(meta: ProjectMeta): Record<string, unknown> {
|
|
|
60
89
|
return raw as Record<string, unknown>
|
|
61
90
|
}
|
|
62
91
|
|
|
92
|
+
/**
|
|
93
|
+
* A `build` that only delegates (`build: pnpm run compile`) is a group over
|
|
94
|
+
* its target, and the `^build` edge every `build` carries belongs on the
|
|
95
|
+
* task that does the work: the group ran `compile` with no edge at all, so
|
|
96
|
+
* a package compiled before the ones it imports had built (item 907). On
|
|
97
|
+
* the group itself it would not hold — the group waits on both, `compile`
|
|
98
|
+
* on neither. Followed through a chain of groups to the first command.
|
|
99
|
+
*/
|
|
100
|
+
function upstreamBuildOnWorker(tasks: GeneratedTask[]): void {
|
|
101
|
+
const byName = new Map(tasks.map((t) => [t.name, t]))
|
|
102
|
+
const seen = new Set<string>()
|
|
103
|
+
let at = byName.get('build')
|
|
104
|
+
while (at?.task !== undefined && at.task !== null && !('exec' in at.task)) {
|
|
105
|
+
if (seen.has(at.name)) return
|
|
106
|
+
seen.add(at.name)
|
|
107
|
+
const deps = at.task['dependsOn']
|
|
108
|
+
at = Array.isArray(deps) ? byName.get(String(deps[0])) : undefined
|
|
109
|
+
}
|
|
110
|
+
if (at === undefined || at.name === 'build' || at.task === null || at.task === undefined) return
|
|
111
|
+
const deps = Array.isArray(at.task['dependsOn']) ? (at.task['dependsOn'] as string[]) : []
|
|
112
|
+
if (!deps.includes('^build')) at.task['dependsOn'] = ['^build', ...deps]
|
|
113
|
+
}
|
|
114
|
+
|
|
63
115
|
export function migrateScripts(metas: readonly ProjectMeta[]): MigrationPlan {
|
|
64
116
|
const projects: GeneratedProject[] = []
|
|
65
117
|
for (const meta of metas) {
|
|
@@ -96,11 +148,11 @@ export function migrateScripts(metas: readonly ProjectMeta[]): MigrationPlan {
|
|
|
96
148
|
continue
|
|
97
149
|
}
|
|
98
150
|
|
|
99
|
-
const command =
|
|
100
|
-
|
|
151
|
+
const command = foldScriptHooks(
|
|
152
|
+
hook(`pre${name}`) ? (scripts[`pre${name}`] as string) : undefined,
|
|
101
153
|
own,
|
|
102
|
-
|
|
103
|
-
|
|
154
|
+
hook(`post${name}`) ? (scripts[`post${name}`] as string) : undefined,
|
|
155
|
+
)
|
|
104
156
|
if (hooks.length > 0) {
|
|
105
157
|
todos.push(
|
|
106
158
|
`npm ran ${hooks.map((h) => `\`${h}\``).join(' and ')} around this script without being asked; folded into the command in that order`,
|
|
@@ -114,13 +166,14 @@ export function migrateScripts(metas: readonly ProjectMeta[]): MigrationPlan {
|
|
|
114
166
|
if (name === 'build') {
|
|
115
167
|
task['dependsOn'] = ['^build']
|
|
116
168
|
todos.push(
|
|
117
|
-
"cache: add `cache: { inputs: { files: ['src/**'] }, outputs: { files: ['dist/**'] } }` with this package's real inputs and outputs — without it the task always runs; a block with EMPTY outputs would be a cached no-op, not an uncached task",
|
|
169
|
+
"cache: add `cache: { inputs: { files: ['src/**'] }, outputs: { files: ['dist/**'] } }` with this package's real inputs and outputs — without it the task always runs and every file here, what it writes included, folds into the key its dependents fold; a block with EMPTY outputs would be a cached no-op, not an uncached task",
|
|
118
170
|
)
|
|
119
171
|
} else if (AFTER_BUILD.has(name) && hasBuild) {
|
|
120
172
|
task['dependsOn'] = ['build']
|
|
121
173
|
}
|
|
122
174
|
tasks.push({ name, todos, task })
|
|
123
175
|
}
|
|
176
|
+
upstreamBuildOnWorker(tasks)
|
|
124
177
|
if (tasks.length > 0) projects.push({ name: meta.name, dir: meta.dir, importLines: [], tasks })
|
|
125
178
|
}
|
|
126
179
|
return {
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import path from 'node:path'
|
|
9
9
|
import { relPosix, UserError } from '../util/index.js'
|
|
10
10
|
import type { ProjectMeta } from './workspace.js'
|
|
11
|
+
import { loadWorkspace, unreachedHint, unreachedPackages } from './workspace.js'
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Escape an arbitrary string into a single-quoted TS literal. Escapes
|
|
@@ -38,6 +39,32 @@ export const PERSISTENT_TASK_NAMES: ReadonlySet<string> = new Set([
|
|
|
38
39
|
'preview',
|
|
39
40
|
])
|
|
40
41
|
|
|
42
|
+
/**
|
|
43
|
+
* A package.json script with the `pre<name>` / `post<name>` hooks npm runs
|
|
44
|
+
* around it, as ONE sh command. Each part runs in its own subshell, so a
|
|
45
|
+
* `;` or an `exit` in one ends that part alone, and the chain stops at the
|
|
46
|
+
* first that fails, as npm stops. The parts sit in a function the
|
|
47
|
+
* forwarded `--` args are appended to, and only the body takes them, as
|
|
48
|
+
* npm appends them to the script and never to its hooks. A plain ` && `
|
|
49
|
+
* join handed them to the post hook, and `test -f x && echo A; echo B` ran
|
|
50
|
+
* `echo B` after a failed pre hook and went green (item 905). Each part
|
|
51
|
+
* ends on its own line, so a trailing `# comment` cannot swallow the
|
|
52
|
+
* paren. A script with no hooks is its body, verbatim.
|
|
53
|
+
*/
|
|
54
|
+
export function foldScriptHooks(
|
|
55
|
+
pre: string | undefined,
|
|
56
|
+
body: string,
|
|
57
|
+
post: string | undefined,
|
|
58
|
+
): string {
|
|
59
|
+
if (pre === undefined && post === undefined) return body
|
|
60
|
+
const parts = [
|
|
61
|
+
...(pre === undefined ? [] : [`(${pre}\n)`]),
|
|
62
|
+
`(${body} "$@"\n)`,
|
|
63
|
+
...(post === undefined ? [] : [`(${post}\n)`]),
|
|
64
|
+
]
|
|
65
|
+
return `vx_script() {\n${parts.join(' && ')}\n}\nvx_script`
|
|
66
|
+
}
|
|
67
|
+
|
|
41
68
|
/** The one wording every mapper emits for a task it made persistent. */
|
|
42
69
|
export const PERSISTENT_TODO =
|
|
43
70
|
'persistent task — set persistent.readyWhen (regex matched against output) so ' +
|
|
@@ -191,7 +218,19 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
|
|
|
191
218
|
}
|
|
192
219
|
}
|
|
193
220
|
const report: string[] = []
|
|
194
|
-
|
|
221
|
+
// Single-project mode with packages the root's missing `workspaces` never
|
|
222
|
+
// reaches: the scripts exist, the globs do not (item 248).
|
|
223
|
+
const unreached = empty ? await unreachedPackages(await loadWorkspace(root)) : []
|
|
224
|
+
if (empty && unreached.length > 0) {
|
|
225
|
+
report.push(
|
|
226
|
+
`${verb}: ${unreachedHint(unreached)}`,
|
|
227
|
+
hasWorkspaceFile
|
|
228
|
+
? `${workspaceName} already exists.`
|
|
229
|
+
: dry
|
|
230
|
+
? `would write ${workspaceName} (dry run, nothing written).`
|
|
231
|
+
: `wrote ${workspaceName}.`,
|
|
232
|
+
)
|
|
233
|
+
} else if (empty) {
|
|
195
234
|
report.push(
|
|
196
235
|
`${verb}: no package.json scripts to turn into tasks.`,
|
|
197
236
|
hasWorkspaceFile
|