@vzn/vx 0.0.18 → 0.0.22
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/LICENSE +1 -1
- package/README.md +107 -70
- package/package.json +10 -20
- package/src/bin.ts +64 -5
- package/src/cache/archive.ts +279 -28
- package/src/cache/cache.ts +952 -1584
- package/src/cache/chained-cache.ts +14 -10
- package/src/cache/config-evals.ts +112 -0
- package/src/cache/file-hashes.ts +290 -0
- package/src/cache/git-inputs.ts +966 -0
- package/src/cache/index.ts +23 -7
- package/src/cache/inputs.ts +553 -739
- package/src/cache/key-fold.ts +232 -0
- package/src/cache/layer.ts +674 -0
- package/src/cache/layered-cache.ts +211 -49
- package/src/cache/output-index.ts +323 -0
- package/src/cache/policy.ts +84 -0
- package/src/cache/run-history.ts +152 -0
- package/src/cache/zstd.ts +183 -0
- package/src/cli/cache.ts +88 -30
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/format.ts +4 -15
- package/src/cli/help.ts +76 -38
- package/src/cli/index.ts +82 -17
- package/src/cli/info.ts +162 -91
- package/src/cli/init.ts +87 -0
- package/src/cli/last.ts +130 -23
- package/src/cli/lock.ts +36 -18
- package/src/cli/plan-format.ts +18 -7
- package/src/cli/plugin-commands.ts +34 -12
- package/src/cli/run.ts +138 -313
- package/src/cli/select.ts +342 -0
- package/src/cli/show.ts +151 -71
- package/src/cli/upgrade.ts +136 -15
- package/src/cli/watch.ts +1175 -116
- package/src/cli/why.ts +51 -29
- package/src/cli/workspace-config.ts +138 -0
- package/src/config.ts +183 -126
- package/src/exec/env.ts +11 -0
- package/src/exec/executor.ts +62 -31
- package/src/exec/index.ts +16 -2
- package/src/exec/kill-tree.ts +281 -0
- package/src/exec/local-executor.ts +41 -0
- package/src/exec/runner.ts +361 -77
- package/src/exec/sandbox-binds.ts +211 -0
- package/src/exec/sandbox-paths.ts +70 -0
- package/src/exec/sandbox-runtime.ts +1152 -415
- package/src/exec/sandbox-violations.ts +293 -0
- package/src/graph/dependency-spec.ts +14 -14
- package/src/graph/index.ts +6 -4
- package/src/graph/priorities.ts +130 -0
- package/src/graph/scheduler.ts +332 -329
- package/src/graph/task-graph.ts +552 -188
- package/src/index.ts +111 -102
- package/src/orchestrator/admission.ts +161 -0
- package/src/orchestrator/colors.ts +8 -3
- package/src/orchestrator/deferred-outputs.ts +25 -7
- package/src/orchestrator/doctor.ts +363 -0
- package/src/orchestrator/events.ts +111 -8
- package/src/orchestrator/excluded-keys.ts +155 -0
- package/src/orchestrator/execute-task.ts +450 -565
- package/src/orchestrator/failure-mode.ts +169 -28
- package/src/orchestrator/failure-recap.ts +121 -0
- package/src/orchestrator/fingerprint-watch.ts +89 -0
- package/src/orchestrator/framed-output.ts +121 -49
- package/src/orchestrator/history.ts +123 -104
- package/src/orchestrator/hit-restore.ts +285 -0
- package/src/orchestrator/index.ts +42 -5
- package/src/orchestrator/keyed-projects.ts +89 -0
- package/src/orchestrator/local-shortcircuit.ts +83 -23
- package/src/orchestrator/lockfile-claim.ts +401 -0
- package/src/orchestrator/logger.ts +128 -41
- package/src/orchestrator/metrics.ts +201 -72
- package/src/orchestrator/miss-save.ts +232 -0
- package/src/orchestrator/options.ts +63 -34
- package/src/orchestrator/persistent.ts +99 -0
- package/src/orchestrator/placement.ts +212 -0
- package/src/orchestrator/plan.ts +24 -11
- package/src/orchestrator/plugin-host.ts +200 -49
- package/src/orchestrator/plugin.ts +190 -14
- package/src/orchestrator/prepare.ts +168 -137
- package/src/orchestrator/projects.ts +270 -0
- package/src/orchestrator/remote-prefetch.ts +22 -2
- package/src/orchestrator/run-artifacts.ts +34 -3
- package/src/orchestrator/run-context.ts +20 -5
- package/src/orchestrator/run-lock.ts +349 -0
- package/src/orchestrator/run-records.ts +132 -0
- package/src/orchestrator/run-report.ts +6 -4
- package/src/orchestrator/run.ts +467 -611
- package/src/orchestrator/sandbox-request.ts +569 -0
- package/src/orchestrator/save-lane.ts +44 -0
- package/src/orchestrator/shell-verdict.ts +117 -0
- package/src/orchestrator/signals.ts +167 -0
- package/src/orchestrator/stable-keys.ts +259 -10
- package/src/orchestrator/status-line.ts +35 -2
- package/src/orchestrator/summary.ts +115 -25
- package/src/orchestrator/task-hash.ts +103 -21
- package/src/orchestrator/task-log-buffer.ts +15 -18
- package/src/orchestrator/telemetry-host.ts +27 -8
- package/src/orchestrator/telemetry.ts +71 -44
- package/src/orchestrator/upstream.ts +91 -45
- package/src/util/bun-version.ts +43 -0
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +49 -0
- package/src/util/errors.ts +86 -0
- package/src/util/hash.ts +10 -1
- package/src/util/index.ts +42 -5
- package/src/util/paths.ts +168 -5
- package/src/util/procfs.ts +20 -0
- package/src/util/settle.ts +23 -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/verbs.ts +39 -0
- package/src/util/which.ts +48 -0
- package/src/workspace/affected.ts +368 -60
- package/src/workspace/config-cache.ts +271 -32
- package/src/workspace/config-eval.ts +60 -16
- package/src/workspace/config-imports.ts +131 -9
- package/src/workspace/config-schema.ts +1033 -0
- package/src/workspace/filter.ts +69 -17
- package/src/workspace/fingerprint.ts +59 -7
- package/src/workspace/index.ts +37 -3
- package/src/workspace/json-data.ts +90 -0
- package/src/workspace/load-reads.ts +32 -0
- package/src/workspace/lockfile.ts +41 -14
- package/src/workspace/migrate-scripts.ts +187 -0
- package/src/workspace/migration.ts +353 -0
- package/src/workspace/package-graph.ts +239 -31
- package/src/workspace/project-loader.ts +295 -815
- package/src/workspace/workspace.ts +266 -31
- package/plugins/local-cache/index.ts +0 -2
- package/plugins/local-executor/index.ts +0 -2
- package/plugins/schedule-history/index.ts +0 -2
- package/src/cache/cas-backend.ts +0 -115
- package/src/cache/digest.ts +0 -32
- package/src/cli/migrate-emit.ts +0 -21
- package/src/cli/migrate-nx.ts +0 -473
- package/src/cli/migrate-scripts.ts +0 -127
- package/src/cli/migrate-turbo.ts +0 -401
- package/src/cli/migrate.ts +0 -305
- package/src/cli/prune.ts +0 -291
- package/src/orchestrator/missing-plugin.ts +0 -9
- package/src/orchestrator/resources.ts +0 -45
- package/src/orchestrator/verify.ts +0 -174
- package/src/plugins/local-cache/index.ts +0 -11
- package/src/plugins/local-executor/index.ts +0 -45
- package/src/plugins/schedule-history/index.ts +0 -109
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
|
|
@@ -34,6 +38,10 @@ export interface ParsedFilter {
|
|
|
34
38
|
* to a concrete set of project names before calling applyFilters.
|
|
35
39
|
*/
|
|
36
40
|
gitSince?: string
|
|
41
|
+
/** A path form carrying a glob (`./packages/*`): matched over the root-relative project dir. */
|
|
42
|
+
pathGlob?: Bun.Glob
|
|
43
|
+
/** The workspace root `pathGlob` is relative to. */
|
|
44
|
+
pathRoot?: string
|
|
37
45
|
}
|
|
38
46
|
|
|
39
47
|
export function parseFilter(raw: string, workspaceRoot: string): ParsedFilter {
|
|
@@ -43,6 +51,10 @@ export function parseFilter(raw: string, workspaceRoot: string): ParsedFilter {
|
|
|
43
51
|
|
|
44
52
|
const withDependents = s.startsWith('...')
|
|
45
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)
|
|
46
58
|
|
|
47
59
|
let onlyDeps = false
|
|
48
60
|
let withDeps = false
|
|
@@ -63,6 +75,7 @@ export function parseFilter(raw: string, workspaceRoot: string): ParsedFilter {
|
|
|
63
75
|
withDeps,
|
|
64
76
|
withDependents,
|
|
65
77
|
onlyDeps,
|
|
78
|
+
onlyDependents,
|
|
66
79
|
isPath: false,
|
|
67
80
|
matcher: '',
|
|
68
81
|
gitSince: s.slice(1, -1),
|
|
@@ -71,15 +84,37 @@ export function parseFilter(raw: string, workspaceRoot: string): ParsedFilter {
|
|
|
71
84
|
|
|
72
85
|
let isPath = false
|
|
73
86
|
let matcher = s
|
|
74
|
-
|
|
87
|
+
let pathGlob: Bun.Glob | undefined
|
|
88
|
+
const pathForm =
|
|
89
|
+
s.startsWith('./') || s === '.'
|
|
90
|
+
? s
|
|
91
|
+
: s.startsWith('{') && s.endsWith('}')
|
|
92
|
+
? s.slice(1, -1)
|
|
93
|
+
: undefined
|
|
94
|
+
if (pathForm !== undefined) {
|
|
75
95
|
isPath = true
|
|
76
|
-
matcher = path.resolve(workspaceRoot,
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
96
|
+
matcher = path.resolve(workspaceRoot, pathForm)
|
|
97
|
+
// `./packages/*` — pnpm's and Turbo's spelling for "every package under
|
|
98
|
+
// packages": a glob over the root-relative project dir. Resolved
|
|
99
|
+
// against the workspace root like the literal form.
|
|
100
|
+
// A member glob, not a task glob: `Bun.Glob`'s alphabet, the class included.
|
|
101
|
+
if (BUN_GLOB_WILDCARDS.test(pathForm)) {
|
|
102
|
+
const rel = path.relative(workspaceRoot, matcher).split(path.sep).join('/')
|
|
103
|
+
pathGlob = new Bun.Glob(rel.replace(/\/+$/, ''))
|
|
104
|
+
}
|
|
80
105
|
}
|
|
81
106
|
|
|
82
|
-
return {
|
|
107
|
+
return {
|
|
108
|
+
raw,
|
|
109
|
+
negate,
|
|
110
|
+
withDeps,
|
|
111
|
+
withDependents,
|
|
112
|
+
onlyDeps,
|
|
113
|
+
onlyDependents,
|
|
114
|
+
isPath,
|
|
115
|
+
matcher,
|
|
116
|
+
...(pathGlob !== undefined ? { pathGlob, pathRoot: workspaceRoot } : {}),
|
|
117
|
+
}
|
|
83
118
|
}
|
|
84
119
|
|
|
85
120
|
function matchProjects(
|
|
@@ -99,14 +134,28 @@ function matchProjects(
|
|
|
99
134
|
for (const p of projects) {
|
|
100
135
|
if (p.dir === filter.matcher || p.dir.startsWith(prefix)) out.push(p.name)
|
|
101
136
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
142
|
+
if (filter.pathGlob !== undefined) {
|
|
143
|
+
// The glob is matched against the project's own dir, as pnpm and
|
|
144
|
+
// Turbo do: `./packages/*` is the packages directly under `packages`,
|
|
145
|
+
// `./packages/**` reaches the nested ones too.
|
|
146
|
+
for (const p of projects) {
|
|
147
|
+
const rel = path
|
|
148
|
+
.relative(filter.pathRoot ?? '', p.dir)
|
|
149
|
+
.split(path.sep)
|
|
150
|
+
.join('/')
|
|
151
|
+
if (filter.pathGlob.match(rel)) out.push(p.name)
|
|
152
|
+
}
|
|
107
153
|
}
|
|
108
154
|
return out
|
|
109
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).
|
|
110
159
|
const re = compileNameGlob(filter.matcher)
|
|
111
160
|
for (const p of projects) {
|
|
112
161
|
if (re.test(p.name)) out.push(p.name)
|
|
@@ -149,12 +198,17 @@ export function applyFilters(opts: ApplyFiltersOptions): Set<string> {
|
|
|
149
198
|
const hasInclude = opts.filters.some((f) => !f.negate)
|
|
150
199
|
const selected = new Set<string>(hasInclude ? [] : allNames)
|
|
151
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>[] = []
|
|
152
206
|
for (const f of opts.filters) {
|
|
153
207
|
const matched = matchProjects(f, opts.projects, opts.affectedByFilter)
|
|
154
208
|
if (matched.length === 0) opts.onNoMatch?.(f)
|
|
155
209
|
const expanded = new Set<string>()
|
|
156
210
|
for (const name of matched) {
|
|
157
|
-
if (!f.onlyDeps) expanded.add(name)
|
|
211
|
+
if (!f.onlyDeps && !f.onlyDependents) expanded.add(name)
|
|
158
212
|
if (f.withDeps || f.onlyDeps) {
|
|
159
213
|
for (const d of opts.graph.transitiveDeps(name)) expanded.add(d)
|
|
160
214
|
}
|
|
@@ -162,12 +216,10 @@ export function applyFilters(opts: ApplyFiltersOptions): Set<string> {
|
|
|
162
216
|
for (const d of opts.graph.transitiveDependents(name)) expanded.add(d)
|
|
163
217
|
}
|
|
164
218
|
}
|
|
165
|
-
if (f.negate)
|
|
166
|
-
|
|
167
|
-
} else {
|
|
168
|
-
for (const name of expanded) selected.add(name)
|
|
169
|
-
}
|
|
219
|
+
if (f.negate) excludes.push(expanded)
|
|
220
|
+
else for (const name of expanded) selected.add(name)
|
|
170
221
|
}
|
|
222
|
+
for (const expanded of excludes) for (const name of expanded) selected.delete(name)
|
|
171
223
|
|
|
172
224
|
return selected
|
|
173
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,14 +50,59 @@ 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
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const NONE: ReadonlySet<string> = new Set()
|
|
61
|
+
|
|
62
|
+
export interface WorkspaceFingerprints {
|
|
63
|
+
/** Every file folded — what the config-evaluation cache keys on. */
|
|
64
|
+
readonly all: string
|
|
65
|
+
/**
|
|
66
|
+
* The same fold minus the files a plugin claims (`VxPlugin.fingerprint`)
|
|
67
|
+
* — what every task key folds. Identical to `all` with nothing claimed.
|
|
68
|
+
*/
|
|
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>
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
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.
|
|
84
|
+
*/
|
|
85
|
+
export async function computeWorkspaceFingerprints(
|
|
86
|
+
workspaceRoot: string,
|
|
87
|
+
claimed: ReadonlySet<string>,
|
|
88
|
+
reads?: LoadReads,
|
|
89
|
+
): Promise<WorkspaceFingerprints> {
|
|
90
|
+
let all = 0n
|
|
91
|
+
let unclaimed = 0n
|
|
92
|
+
const files = new Map<string, Uint8Array>()
|
|
48
93
|
for (const f of WORKSPACE_FINGERPRINT_FILES) {
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
94
|
+
const bytes = await readOnce(reads, path.join(workspaceRoot, f))
|
|
95
|
+
if (bytes === null) continue
|
|
96
|
+
files.set(f, bytes)
|
|
97
|
+
all = xxh3(`${f}\0`, all)
|
|
98
|
+
all = xxh3(bytes, all)
|
|
99
|
+
if (claimed.has(f)) continue
|
|
100
|
+
unclaimed = xxh3(`${f}\0`, unclaimed)
|
|
101
|
+
unclaimed = xxh3(bytes, unclaimed)
|
|
54
102
|
}
|
|
103
|
+
return { all: hex(all), unclaimed: hex(unclaimed), files }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function hex(h: bigint): string {
|
|
55
107
|
return h.toString(16).padStart(16, '0')
|
|
56
108
|
}
|
package/src/workspace/index.ts
CHANGED
|
@@ -1,9 +1,20 @@
|
|
|
1
1
|
// Module contract. Cross-module imports must come through here; see
|
|
2
2
|
// docs/design/module-isolation-2026-06.md and tests/module-boundaries.test.ts.
|
|
3
3
|
|
|
4
|
-
export {
|
|
4
|
+
export {
|
|
5
|
+
affectedProjects,
|
|
6
|
+
defaultAffectedBase,
|
|
7
|
+
type FingerprintClaims,
|
|
8
|
+
refIsHead,
|
|
9
|
+
workspaceGlobsMatch,
|
|
10
|
+
} from './affected.js'
|
|
5
11
|
export { applyFilters, parseFilter } from './filter.js'
|
|
6
|
-
export {
|
|
12
|
+
export {
|
|
13
|
+
computeWorkspaceFingerprint,
|
|
14
|
+
computeWorkspaceFingerprints,
|
|
15
|
+
WORKSPACE_FINGERPRINT_FILES,
|
|
16
|
+
type WorkspaceFingerprints,
|
|
17
|
+
} from './fingerprint.js'
|
|
7
18
|
export { computeNestedProjectDirs } from './nested-dirs.js'
|
|
8
19
|
export { buildPackageGraph, type PackageGraph } from './package-graph.js'
|
|
9
20
|
export {
|
|
@@ -11,6 +22,7 @@ export {
|
|
|
11
22
|
LOCKFILE_NAME,
|
|
12
23
|
LOCKFILE_VERSION,
|
|
13
24
|
lockfilePath,
|
|
25
|
+
FROZEN_WITHOUT_LOCK,
|
|
14
26
|
readLockfile,
|
|
15
27
|
writeLockfile,
|
|
16
28
|
type Lockfile,
|
|
@@ -20,20 +32,42 @@ export {
|
|
|
20
32
|
loadProjectConfig,
|
|
21
33
|
loadProjectConfigs,
|
|
22
34
|
loadWorkspaceConfig,
|
|
23
|
-
|
|
35
|
+
WORKSPACE_CONFIG_FILENAMES,
|
|
24
36
|
} from './project-loader.js'
|
|
37
|
+
export { validateProjectConfig } from './config-schema.js'
|
|
25
38
|
export type { LoadProjectConfigOptions } from './project-loader.js'
|
|
26
39
|
export {
|
|
27
40
|
blobOidOf,
|
|
28
41
|
configEvalKey,
|
|
29
42
|
configEvalKeyFromClosure,
|
|
43
|
+
configImports,
|
|
30
44
|
type ConfigEvalStore,
|
|
31
45
|
} from './config-cache.js'
|
|
32
46
|
export {
|
|
33
47
|
findWorkspaceRoot,
|
|
34
48
|
listProjects,
|
|
35
49
|
loadWorkspace,
|
|
50
|
+
unreachedHint,
|
|
51
|
+
unreachedPackages,
|
|
52
|
+
memberBaseDirs,
|
|
53
|
+
PROJECT_CONFIG_FILENAMES,
|
|
36
54
|
resolveCacheDir,
|
|
37
55
|
type ProjectEntry,
|
|
38
56
|
type ProjectMeta,
|
|
39
57
|
} from './workspace.js'
|
|
58
|
+
export type { LoadReads } from './load-reads.js'
|
|
59
|
+
// The migration seam (see migration.ts) and core's own mapper, the scripts one.
|
|
60
|
+
export {
|
|
61
|
+
applyMigration,
|
|
62
|
+
foldScriptHooks,
|
|
63
|
+
PERSISTENT_TASK_NAMES,
|
|
64
|
+
PERSISTENT_TODO,
|
|
65
|
+
quoteTsLiteral,
|
|
66
|
+
type ApplyMigrationArgs,
|
|
67
|
+
type MigrationFormat,
|
|
68
|
+
type GeneratedProject,
|
|
69
|
+
type GeneratedTask,
|
|
70
|
+
type MigrationPlan,
|
|
71
|
+
type RawExpr,
|
|
72
|
+
} from './migration.js'
|
|
73
|
+
export { delegatedScript, migrateScripts } from './migrate-scripts.js'
|
|
@@ -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,19 +2,22 @@
|
|
|
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'
|
|
17
|
-
import { validateProjectConfig } from './
|
|
20
|
+
import { validateProjectConfig } from './config-schema.js'
|
|
18
21
|
|
|
19
22
|
export const LOCKFILE_NAME = 'vx-lock.json'
|
|
20
23
|
export const LOCKFILE_VERSION = 1
|
|
@@ -37,6 +40,10 @@ 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
|
|
@@ -92,16 +99,36 @@ export async function readLockfile(root: string): Promise<Lockfile | null> {
|
|
|
92
99
|
}
|
|
93
100
|
|
|
94
101
|
export async function writeLockfile(root: string, lock: Lockfile): Promise<void> {
|
|
95
|
-
|
|
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
|
+
}
|
|
96
122
|
}
|
|
97
123
|
|
|
98
124
|
/**
|
|
99
|
-
* Run-time config load from the lock:
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
125
|
+
* Run-time config load from the lock: return the FROZEN resolved config
|
|
126
|
+
* for a project the lock has an entry for. No evaluation happens — this
|
|
127
|
+
* is the fast, eval-free trust path — and no staleness check either (see
|
|
128
|
+
* below): the lock's contract is "what runs is what was locked", so a
|
|
129
|
+
* config file edited since `vx lock` still runs as locked until
|
|
130
|
+
* `vx lock --check` says otherwise. An unlocked project is a hard error;
|
|
131
|
+
* silently falling back to evaluation would break frozen-env semantics.
|
|
105
132
|
*/
|
|
106
133
|
export async function frozenProjectConfig(
|
|
107
134
|
lock: Lockfile,
|