@vzn/vx 0.0.636 → 0.0.637
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -11
- package/docs/caching.md +213 -207
- package/docs/cli.md +699 -655
- package/docs/execution.md +67 -75
- package/docs/features.md +44 -40
- package/docs/patterns.md +26 -25
- package/docs/schema.md +376 -198
- package/docs/security.md +76 -39
- package/launcher.sh +43 -0
- package/package.json +5 -5
- package/schemas/cache.json +41 -19
- package/schemas/info.json +23 -11
- package/schemas/last.json +48 -2
- package/schemas/lock.json +1 -1
- package/schemas/plan.json +2 -2
- package/schemas/show.json +5 -3
- package/schemas/why.json +15 -1
- package/skills/vx/SKILL.md +16 -12
- package/src/bin.ts +17 -2
- package/src/cache/archive.ts +11 -0
- package/src/cache/cache.ts +105 -53
- package/src/cache/git-inputs.ts +277 -63
- package/src/cache/index.ts +7 -1
- package/src/cache/inputs.ts +125 -35
- package/src/cache/key-fold.ts +8 -7
- package/src/cache/layer.ts +8 -17
- package/src/cache/layered-cache.ts +16 -102
- package/src/cache/policy.ts +25 -29
- package/src/cache/run-history.ts +36 -14
- package/src/cache/schema.ts +21 -23
- package/src/cache/scoped-cache.ts +224 -0
- package/src/cli/cache.ts +150 -28
- package/src/cli/completions.ts +2 -2
- package/src/cli/core-alias.ts +1 -1
- package/src/cli/foreign-flags.ts +13 -12
- package/src/cli/help.ts +94 -63
- package/src/cli/index.ts +42 -13
- package/src/cli/info.ts +38 -23
- package/src/cli/init.ts +115 -406
- package/src/cli/last.ts +145 -4
- package/src/cli/lock.ts +333 -111
- package/src/cli/plan-format.ts +2 -2
- package/src/cli/plugin-commands.ts +5 -9
- package/src/cli/plugin-templates.ts +10 -10
- package/src/cli/run.ts +103 -149
- package/src/cli/select.ts +33 -39
- package/src/cli/show.ts +17 -19
- package/src/cli/task-verb.ts +2 -9
- package/src/cli/upgrade.ts +402 -145
- package/src/cli/watch-cycle.ts +9 -2
- package/src/cli/watch-filter.ts +33 -60
- package/src/cli/watch-fs.ts +29 -16
- package/src/cli/watch-judge.ts +12 -3
- package/src/cli/watch-set.ts +16 -42
- package/src/cli/watch.ts +188 -215
- package/src/cli/why.ts +28 -17
- package/src/cli/workspace-config.ts +31 -68
- package/src/config.ts +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 +19 -7
- package/src/graph/task-graph.ts +44 -20
- package/src/handoff.ts +169 -0
- package/src/index.ts +24 -57
- package/src/orchestrator/affected-tasks.ts +7 -5
- package/src/orchestrator/command-windows.ts +44 -0
- package/src/orchestrator/deferred-outputs.ts +12 -10
- package/src/orchestrator/doctor.ts +98 -110
- package/src/orchestrator/download-policy.ts +3 -3
- package/src/orchestrator/events.ts +19 -3
- package/src/orchestrator/excluded-keys.ts +0 -2
- package/src/orchestrator/execute-task.ts +158 -63
- package/src/orchestrator/fingerprint-watch.ts +18 -15
- package/src/orchestrator/forecast.ts +10 -2
- package/src/orchestrator/framed-output.ts +8 -3
- package/src/orchestrator/graph-stage.ts +5 -5
- package/src/orchestrator/history.ts +70 -30
- package/src/orchestrator/hit-restore.ts +15 -3
- package/src/orchestrator/index.ts +18 -4
- package/src/orchestrator/local-shortcircuit.ts +2 -1
- package/src/orchestrator/lockfile-claim.ts +79 -20
- package/src/orchestrator/logger.ts +71 -39
- package/src/orchestrator/metrics.ts +71 -6
- package/src/orchestrator/miss-save.ts +3 -0
- package/src/orchestrator/options.ts +42 -29
- package/src/orchestrator/output-host.ts +72 -0
- package/src/orchestrator/plan.ts +6 -8
- package/src/orchestrator/plugin-host.ts +87 -63
- package/src/orchestrator/plugin.ts +94 -60
- package/src/orchestrator/prepare.ts +113 -72
- package/src/orchestrator/projects.ts +102 -99
- package/src/orchestrator/remote-prefetch.ts +2 -1
- package/src/orchestrator/run-context.ts +25 -6
- package/src/orchestrator/run-failures.ts +17 -8
- package/src/orchestrator/run-lock.ts +1 -1
- package/src/orchestrator/run-report.ts +9 -9
- package/src/orchestrator/run-settings.ts +48 -0
- package/src/orchestrator/run.ts +121 -95
- package/src/orchestrator/sandbox-fix.ts +39 -20
- package/src/orchestrator/sandbox-request.ts +84 -13
- package/src/orchestrator/signals.ts +1 -1
- package/src/orchestrator/stable-keys.ts +13 -11
- package/src/orchestrator/status-line.ts +48 -6
- package/src/orchestrator/summary.ts +46 -62
- package/src/orchestrator/task-hash.ts +12 -83
- package/src/orchestrator/telemetry.ts +3 -3
- package/src/settings.ts +417 -0
- package/src/util/errors.ts +1 -1
- package/src/util/essential-env.ts +31 -0
- package/src/util/hide-env.ts +107 -0
- package/src/util/index.ts +8 -2
- package/src/util/paths.ts +10 -0
- package/src/util/settle.ts +49 -30
- package/src/util/size.ts +23 -4
- package/src/util/timing.ts +3 -2
- package/src/util/ulid.ts +1 -1
- package/src/util/verbs.ts +0 -15
- package/src/workspace/affected.ts +77 -222
- package/src/workspace/config-eval.ts +15 -20
- package/src/workspace/config-imports.ts +78 -3
- package/src/workspace/config-schema.ts +209 -268
- package/src/workspace/dependency-rules.ts +57 -0
- package/src/workspace/filter.ts +2 -2
- package/src/workspace/fingerprint.ts +11 -121
- package/src/workspace/index.ts +20 -27
- package/src/workspace/load-reads.ts +4 -4
- package/src/workspace/lockfile.ts +96 -140
- package/src/workspace/migration.ts +242 -146
- package/src/workspace/package-graph.ts +30 -216
- package/src/workspace/project-loader.ts +80 -285
- package/src/workspace/repo-id.ts +2 -1
- package/src/workspace/workspace.ts +362 -831
- package/src/cache/config-evals.ts +0 -130
- package/src/workspace/config-cache.ts +0 -661
- package/src/workspace/migrate-scripts.ts +0 -1121
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { DependencyRule } from '../config.js'
|
|
2
|
+
import { UserError } from '../util/index.js'
|
|
3
|
+
import { compileNameGlob } from './filter.js'
|
|
4
|
+
import type { PackageGraph } from './package-graph.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `run.rules.dependencies`, held to each project's direct workspace
|
|
8
|
+
* dependencies (`PackageGraph.directDeps`: the manifest's and the
|
|
9
|
+
* cross-project `dependsOn` edges). A rule constrains the projects its
|
|
10
|
+
* `from` selects: each dependency must match an `allow` selector when
|
|
11
|
+
* `allow` is given, and no `deny` one. Every rule that selects a project
|
|
12
|
+
* applies, so a deny beats any allow. Checked by `vx lock` only (owner,
|
|
13
|
+
* 2026-10-13); every broken edge is named in one refusal.
|
|
14
|
+
*/
|
|
15
|
+
export function checkDependencyRules(
|
|
16
|
+
rules: readonly DependencyRule[],
|
|
17
|
+
projects: readonly string[],
|
|
18
|
+
tags: ReadonlyMap<string, readonly string[]>,
|
|
19
|
+
graph: PackageGraph,
|
|
20
|
+
): void {
|
|
21
|
+
if (rules.length === 0) return
|
|
22
|
+
const compiled = rules.map((r) => ({
|
|
23
|
+
from: selector(r.from),
|
|
24
|
+
allow: r.allow?.map(selector),
|
|
25
|
+
deny: r.deny?.map(selector),
|
|
26
|
+
}))
|
|
27
|
+
const broken: string[] = []
|
|
28
|
+
for (const name of projects) {
|
|
29
|
+
const own = tags.get(name) ?? []
|
|
30
|
+
for (const [i, rule] of compiled.entries()) {
|
|
31
|
+
if (!rule.from(name, own)) continue
|
|
32
|
+
for (const dep of graph.directDeps(name)) {
|
|
33
|
+
const theirs = tags.get(dep) ?? []
|
|
34
|
+
const denied = rule.deny?.some((s) => s(dep, theirs)) === true
|
|
35
|
+
if (denied || (rule.allow !== undefined && !rule.allow.some((s) => s(dep, theirs))))
|
|
36
|
+
broken.push(
|
|
37
|
+
`${name} depends on ${dep}, which run.rules.dependencies[${i}] (from '${rules[i]!.from}') ${denied ? 'denies' : 'does not allow'}`,
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (broken.length === 0) return
|
|
43
|
+
throw new UserError(
|
|
44
|
+
`${broken.length === 1 ? '1 dependency breaks' : `${broken.length} dependencies break`} run.rules.dependencies:\n${broken.map((b) => ` ${b}`).join('\n')}\nDrop the dependency, or change the project's tags or the rule.`,
|
|
45
|
+
'VX_E_DEPENDENCY_RULE',
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** `tag:<glob>` matches a project carrying a matching tag; anything else, its name. */
|
|
50
|
+
function selector(s: string): (name: string, tags: readonly string[]) => boolean {
|
|
51
|
+
if (s.startsWith('tag:')) {
|
|
52
|
+
const re = compileNameGlob(s.slice(4))
|
|
53
|
+
return (_, tags) => tags.some((t) => re.test(t))
|
|
54
|
+
}
|
|
55
|
+
const re = compileNameGlob(s)
|
|
56
|
+
return (name) => re.test(name)
|
|
57
|
+
}
|
package/src/workspace/filter.ts
CHANGED
|
@@ -131,8 +131,8 @@ export function parseFilter(raw: string, workspaceRoot: string): ParsedFilter {
|
|
|
131
131
|
if (s === '') throw new UserError(`filter "${raw}" names no project`)
|
|
132
132
|
|
|
133
133
|
// `//` is Turbo's name for the root package (`--filter=//`, `//...`,
|
|
134
|
-
// `!//`, probed on 2.8.17)
|
|
135
|
-
//
|
|
134
|
+
// `!//`, probed on 2.8.17), and vx's for the root project unless
|
|
135
|
+
// something names it, so `//` is its directory, and only a project
|
|
136
136
|
// there: read as `.`, it would select every project when the root is
|
|
137
137
|
// none.
|
|
138
138
|
if (s === '//') {
|
|
@@ -1,129 +1,23 @@
|
|
|
1
1
|
import path from 'node:path'
|
|
2
|
-
import { xxh3 } from '../util/index.js'
|
|
3
2
|
import { type LoadReads, readOnce } from './load-reads.js'
|
|
4
3
|
|
|
5
|
-
// Every package-manager lockfile we know about, plus the workspace
|
|
6
|
-
// definition files. Whichever ones exist get folded into the
|
|
7
|
-
// fingerprint; missing ones are skipped. Hashed in declaration order
|
|
8
|
-
// for determinism — adding a new entry here changes the fingerprint
|
|
9
|
-
// for projects that have that file present, which is fine: that's the
|
|
10
|
-
// whole point of bumping CACHE_VERSION when the fingerprint surface
|
|
11
|
-
// expands.
|
|
12
|
-
// Exported so `--affected` can consult the SAME list. A change to any of
|
|
13
|
-
// these re-keys every task in the workspace, so selection has to widen to
|
|
14
|
-
// match — two copies of this list would silently drift into a run that
|
|
15
|
-
// rebuilds everything while selecting nothing.
|
|
16
|
-
export const WORKSPACE_FINGERPRINT_FILES = [
|
|
17
|
-
// Lockfiles
|
|
18
|
-
'pnpm-lock.yaml',
|
|
19
|
-
'package-lock.json',
|
|
20
|
-
'npm-shrinkwrap.json',
|
|
21
|
-
'yarn.lock',
|
|
22
|
-
'bun.lock',
|
|
23
|
-
'bun.lockb',
|
|
24
|
-
// Workspace definition (pnpm); package.json is hashed per-project
|
|
25
|
-
// via projectPackageJsonHash, not here.
|
|
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',
|
|
33
|
-
// Install settings no lockfile records: bun's `linker = "isolated"` and
|
|
34
|
-
// pnpm's `node-linker` / hoisting lay `node_modules` out anew under a
|
|
35
|
-
// byte-identical lockfile, and a build that imported an undeclared
|
|
36
|
-
// hoisted package kept its green hit where it now fails (D-7).
|
|
37
|
-
'.npmrc',
|
|
38
|
-
'bunfig.toml',
|
|
39
|
-
]
|
|
40
|
-
|
|
41
|
-
// DELIBERATELY ABSENT: `vx.workspace.{ts,mts,js,mjs,cts,cjs}`. Everything it can
|
|
42
|
-
// declare — `concurrency`, `cacheDir`, `timeout`, and the
|
|
43
|
-
// plugin list — is placement, storage or observability, never what a
|
|
44
|
-
// command produces; core's three seams may change WHERE a task runs, not
|
|
45
|
-
// WHAT it runs (architecture principle #3). Folding it in would also do
|
|
46
|
-
// active harm: a laptop declaring only the local plugins and a CI runner
|
|
47
|
-
// declaring `reapi()` would compute different fingerprints and share not
|
|
48
|
-
// one cache entry — the same split the rejected `NODE_OPTIONS` non-goal
|
|
49
|
-
// describes. A per-task input that genuinely varies belongs in
|
|
50
|
-
// `cache.inputs`, which is the surface built for it.
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* One hash for the workspace as a whole, derived from whichever
|
|
54
|
-
* lockfile + workspace-definition files exist at the root. Folded
|
|
55
|
-
* into every task's cache key so any lockfile bump (`pnpm update`,
|
|
56
|
-
* `npm install`, `bun install`, …) or a workspace-shape change
|
|
57
|
-
* invalidates every cached entry. Coarse but correct.
|
|
58
|
-
*/
|
|
59
|
-
export async function computeWorkspaceFingerprint(
|
|
60
|
-
workspaceRoot: string,
|
|
61
|
-
reads?: LoadReads,
|
|
62
|
-
): Promise<string> {
|
|
63
|
-
return (await computeWorkspaceFingerprints(workspaceRoot, NONE, reads)).all
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const NONE: ReadonlySet<string> = new Set()
|
|
67
|
-
|
|
68
|
-
export interface WorkspaceFingerprints {
|
|
69
|
-
/** Every file folded — what the config-evaluation cache keys on. */
|
|
70
|
-
readonly all: string
|
|
71
|
-
/**
|
|
72
|
-
* The same fold minus the files a plugin claims (`VxPlugin.fingerprint`)
|
|
73
|
-
* — what every task key folds. Identical to `all` with nothing claimed.
|
|
74
|
-
*/
|
|
75
|
-
readonly unclaimed: string
|
|
76
|
-
/**
|
|
77
|
-
* The bytes folded, by file name — what a mid-run check compares against
|
|
78
|
-
* when a task may have rewritten one (orchestrator/fingerprint-watch.ts).
|
|
79
|
-
* Already in memory for the fold; kept, not copied.
|
|
80
|
-
*/
|
|
81
|
-
readonly files: ReadonlyMap<string, Uint8Array>
|
|
82
|
-
}
|
|
83
|
-
|
|
84
4
|
/**
|
|
85
|
-
*
|
|
86
|
-
* `
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
* re-key the workspace when the file appeared.
|
|
5
|
+
* The bytes of each named root file that exists: the files the plugins
|
|
6
|
+
* claim (`VxPlugin.fingerprint`), read once at run start so a task that
|
|
7
|
+
* rewrites one is caught (orchestrator/fingerprint-watch.ts). Core folds
|
|
8
|
+
* none of them into a key; the claimant does.
|
|
90
9
|
*/
|
|
91
|
-
export async function
|
|
10
|
+
export async function readClaimedFiles(
|
|
92
11
|
workspaceRoot: string,
|
|
93
|
-
|
|
12
|
+
names: Iterable<string>,
|
|
94
13
|
reads?: LoadReads,
|
|
95
|
-
): Promise<
|
|
96
|
-
let all = 0n
|
|
97
|
-
let unclaimed = 0n
|
|
14
|
+
): Promise<ReadonlyMap<string, Uint8Array>> {
|
|
98
15
|
const files = new Map<string, Uint8Array>()
|
|
99
|
-
for (const f of
|
|
16
|
+
for (const f of names) {
|
|
100
17
|
const bytes = await readOnce(reads, path.join(workspaceRoot, f))
|
|
101
|
-
if (bytes
|
|
102
|
-
files.set(f, bytes)
|
|
103
|
-
all = xxh3(`${f}\0`, all)
|
|
104
|
-
all = xxh3(bytes, all)
|
|
105
|
-
if (claimed.has(f)) continue
|
|
106
|
-
unclaimed = xxh3(`${f}\0`, unclaimed)
|
|
107
|
-
unclaimed = xxh3(bytes, unclaimed)
|
|
18
|
+
if (bytes !== null) files.set(f, bytes)
|
|
108
19
|
}
|
|
109
|
-
|
|
110
|
-
// left the lockfile byte-identical and every key unmoved while the
|
|
111
|
-
// install applied the new one (item 1014, where only `bun()`'s claim
|
|
112
|
-
// learned it). Each file it names folds here; a claimed bun.lock's
|
|
113
|
-
// plugin folds them itself.
|
|
114
|
-
const bunLock = files.get('bun.lock')
|
|
115
|
-
if (bunLock !== undefined) {
|
|
116
|
-
for (const rel of bunPatchFiles(bunLock)) {
|
|
117
|
-
const bytes = await readOnce(reads, path.join(workspaceRoot, rel))
|
|
118
|
-
const mark = `patch\0${rel}\0${bytes === null ? 'gone' : 'present'}`
|
|
119
|
-
all = xxh3(mark, all)
|
|
120
|
-
if (bytes !== null) all = xxh3(bytes, all)
|
|
121
|
-
if (claimed.has('bun.lock')) continue
|
|
122
|
-
unclaimed = xxh3(mark, unclaimed)
|
|
123
|
-
if (bytes !== null) unclaimed = xxh3(bytes, unclaimed)
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
return { all: hex(all), unclaimed: hex(unclaimed), files }
|
|
20
|
+
return files
|
|
127
21
|
}
|
|
128
22
|
|
|
129
23
|
const PATCHED = new TextEncoder().encode('"patchedDependencies"')
|
|
@@ -132,7 +26,7 @@ const PATCHED = new TextEncoder().encode('"patchedDependencies"')
|
|
|
132
26
|
* The workspace-relative patch files a `bun.lock` names, sorted; none
|
|
133
27
|
* outside the workspace. A lockfile without the key is not parsed (the
|
|
134
28
|
* common case costs one byte search), and one that will not parse names
|
|
135
|
-
* none
|
|
29
|
+
* none.
|
|
136
30
|
*/
|
|
137
31
|
export function bunPatchFiles(bytes: Uint8Array): string[] {
|
|
138
32
|
if (Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).indexOf(PATCHED) < 0) return []
|
|
@@ -153,7 +47,3 @@ export function bunPatchFiles(bytes: Uint8Array): string[] {
|
|
|
153
47
|
}
|
|
154
48
|
return [...out].sort()
|
|
155
49
|
}
|
|
156
|
-
|
|
157
|
-
function hex(h: bigint): string {
|
|
158
|
-
return h.toString(16).padStart(16, '0')
|
|
159
|
-
}
|
package/src/workspace/index.ts
CHANGED
|
@@ -10,24 +10,23 @@ export {
|
|
|
10
10
|
refIsHead,
|
|
11
11
|
} from './affected.js'
|
|
12
12
|
export { applyFilters, parseFilter } from './filter.js'
|
|
13
|
-
export {
|
|
14
|
-
|
|
15
|
-
computeWorkspaceFingerprints,
|
|
16
|
-
WORKSPACE_FINGERPRINT_FILES,
|
|
17
|
-
type WorkspaceFingerprints,
|
|
18
|
-
} from './fingerprint.js'
|
|
13
|
+
export { readClaimedFiles } from './fingerprint.js'
|
|
14
|
+
export { checkDependencyRules } from './dependency-rules.js'
|
|
19
15
|
export { computeNestedProjectDirs } from './nested-dirs.js'
|
|
20
16
|
export { buildPackageGraph, type PackageGraph } from './package-graph.js'
|
|
21
17
|
export {
|
|
22
18
|
frozenProjectConfigs,
|
|
23
19
|
LOCKFILE_NAME,
|
|
24
20
|
LOCKFILE_VERSION,
|
|
21
|
+
LOCK_INVALID,
|
|
22
|
+
lockEntryBroken,
|
|
25
23
|
lockfilePath,
|
|
26
|
-
|
|
24
|
+
lockKey,
|
|
25
|
+
NO_LOCK,
|
|
27
26
|
readLockfile,
|
|
28
27
|
writeLockfile,
|
|
29
28
|
type Lockfile,
|
|
30
|
-
type
|
|
29
|
+
type LockedProject,
|
|
31
30
|
} from './lockfile.js'
|
|
32
31
|
export {
|
|
33
32
|
loadProjectConfig,
|
|
@@ -40,47 +39,41 @@ export {
|
|
|
40
39
|
cacheScopeError,
|
|
41
40
|
inputStabilityEnv,
|
|
42
41
|
isCacheScope,
|
|
42
|
+
taskNameProblem,
|
|
43
43
|
validateProjectConfig,
|
|
44
44
|
validateWorkspace,
|
|
45
45
|
} from './config-schema.js'
|
|
46
|
-
export
|
|
47
|
-
export {
|
|
48
|
-
blobOidOf,
|
|
49
|
-
configEvalKey,
|
|
50
|
-
configEvalKeyFromClosure,
|
|
51
|
-
configImports,
|
|
52
|
-
type ConfigEvalStore,
|
|
53
|
-
} from './config-cache.js'
|
|
46
|
+
export { configImports } from './config-imports.js'
|
|
54
47
|
export {
|
|
48
|
+
checkProjectNames,
|
|
55
49
|
findWorkspaceRoot,
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
unreachedPackages,
|
|
62
|
-
memberBaseDirs,
|
|
50
|
+
findProjectConfigs,
|
|
51
|
+
discoveredProject,
|
|
52
|
+
linkDiscoveredEdges,
|
|
53
|
+
lockedProjects,
|
|
54
|
+
pathName,
|
|
63
55
|
PROJECT_CONFIG_FILENAMES,
|
|
64
56
|
resolveCacheDir,
|
|
65
57
|
resolveStoreRoot,
|
|
58
|
+
ROOT_NAME,
|
|
59
|
+
type ConfigDir,
|
|
66
60
|
type ProjectEntry,
|
|
67
61
|
type ProjectMeta,
|
|
68
|
-
type Workspace,
|
|
69
62
|
} from './workspace.js'
|
|
70
63
|
export type { LoadReads } from './load-reads.js'
|
|
71
|
-
// The migration seam (see migration.ts)
|
|
64
|
+
// The migration seam (see migration.ts).
|
|
72
65
|
export {
|
|
73
66
|
applyMigration,
|
|
74
67
|
foldScriptHooks,
|
|
75
|
-
PERSISTENT_TASK_NAMES,
|
|
76
68
|
PERSISTENT_TODO,
|
|
77
69
|
pruneOrphanPersistentNotes,
|
|
78
70
|
quoteTsLiteral,
|
|
79
71
|
type ApplyMigrationArgs,
|
|
80
72
|
type MigrationFormat,
|
|
73
|
+
type MigrationMember,
|
|
81
74
|
type GeneratedProject,
|
|
82
75
|
type GeneratedTask,
|
|
83
76
|
type MigrationPlan,
|
|
84
77
|
type RawExpr,
|
|
78
|
+
vxInvocation,
|
|
85
79
|
} from './migration.js'
|
|
86
|
-
export { delegatedScript, migrateScripts } from './migrate-scripts.js'
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// A load reads each root file once. Finding the root reads its manifest,
|
|
2
|
-
// listing the package globs read it again and the
|
|
3
|
-
//
|
|
2
|
+
// listing the package globs read it again, and the claimed root files are
|
|
3
|
+
// read for the run (`readClaimedFiles`). Every such read goes through a `LoadReads` the load owns, so
|
|
4
4
|
// a file costs one probe and one read per load however many stages ask for
|
|
5
5
|
// it. The map lives exactly as long as the load: a `vx watch` cycle is a
|
|
6
6
|
// new load and reads fresh bytes.
|
|
@@ -14,8 +14,8 @@ export type LoadReads = Map<string, Promise<Uint8Array | null>>
|
|
|
14
14
|
* `file`'s bytes, read at most once per `reads`; null when there is no file
|
|
15
15
|
* there (`Bun.file(<dir>).exists()` is false, so a directory is none).
|
|
16
16
|
*
|
|
17
|
-
* The probe stays ahead of the read: most names asked about are absent
|
|
18
|
-
*
|
|
17
|
+
* The probe stays ahead of the read: most names asked about are absent
|
|
18
|
+
* (three of the four workspace config names, a manager's settings files),
|
|
19
19
|
* and a failed read costs 90–200 µs building its error where `exists()`
|
|
20
20
|
* answers in 15–40 µs (measured 2026-09-24) — so one stat per present file
|
|
21
21
|
* is cheaper than letting the open answer both questions.
|
|
@@ -1,41 +1,43 @@
|
|
|
1
|
-
// vx
|
|
1
|
+
// .vx/projects.lock.json — frozen resolved-config lockfile.
|
|
2
2
|
//
|
|
3
|
-
// `vx lock`
|
|
4
|
-
// environment and freezes the resolved objects
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// docs/design/config-lock-2026-06.md.
|
|
3
|
+
// `vx lock` finds every project's vx.config.*, evaluates each in the
|
|
4
|
+
// CURRENT environment and freezes the resolved objects, by directory, into
|
|
5
|
+
// `.vx/projects.lock.json` at the workspace root. Every run loads configs from
|
|
6
|
+
// it and never evaluates one (owner, 2026-10-14); only `vx lock` writes
|
|
7
|
+
// it. See docs/design/config-lock-2026-06.md.
|
|
9
8
|
//
|
|
10
9
|
// Verification asymmetry (deliberate):
|
|
11
|
-
// -
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
// that file hashes cannot see.
|
|
10
|
+
// - runs TRUST the lock: no staleness check, no schema validation,
|
|
11
|
+
// eval-free.
|
|
12
|
+
// - `vx lock --check` AUDITS it: full re-evaluation + deep equality.
|
|
15
13
|
|
|
14
|
+
import { readFileSync } from 'node:fs'
|
|
16
15
|
import { rename, rm } from 'node:fs/promises'
|
|
17
16
|
import path from 'node:path'
|
|
18
17
|
import type { ProjectConfig } from '../config.js'
|
|
19
18
|
import { relPosix, UserError, xxh3 } from '../util/index.js'
|
|
20
19
|
import { VERSION } from '../version.js'
|
|
21
|
-
import { CONFIG_EVAL_VERSION, type ConfigEvalStore } from './config-cache.js'
|
|
22
20
|
import { validateProjectConfig } from './config-schema.js'
|
|
21
|
+
import { PROJECT_CONFIG_FILENAMES } from './workspace.js'
|
|
23
22
|
|
|
24
|
-
export const LOCKFILE_NAME = 'vx
|
|
25
|
-
export const LOCKFILE_VERSION =
|
|
23
|
+
export const LOCKFILE_NAME = '.vx/projects.lock.json'
|
|
24
|
+
export const LOCKFILE_VERSION = 3
|
|
26
25
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
/** The resolved (post-evaluation) config object, JSON-normalized. */
|
|
26
|
+
/** One project as the lock holds it. */
|
|
27
|
+
export interface LockedProject {
|
|
28
|
+
/** The config file's name in the directory (`vx.config.ts`). */
|
|
29
|
+
file: string
|
|
30
|
+
/** Its resolved (post-evaluation) config, JSON-normalized. */
|
|
33
31
|
config: ProjectConfig
|
|
34
32
|
}
|
|
35
33
|
|
|
36
34
|
export interface Lockfile {
|
|
37
35
|
version: number
|
|
38
|
-
|
|
36
|
+
/**
|
|
37
|
+
* Root-relative POSIX directory (`.` for the root) → its project. Keyed
|
|
38
|
+
* by directory, so a name a plugin gives never invalidates the lock.
|
|
39
|
+
*/
|
|
40
|
+
projects: Record<string, LockedProject>
|
|
39
41
|
/** Written by `writeLockfile` from the bytes; never read off the object. */
|
|
40
42
|
validated?: string
|
|
41
43
|
}
|
|
@@ -44,12 +46,38 @@ export function lockfilePath(root: string): string {
|
|
|
44
46
|
return path.join(root, LOCKFILE_NAME)
|
|
45
47
|
}
|
|
46
48
|
|
|
47
|
-
/** What a
|
|
48
|
-
export const
|
|
49
|
-
"--frozen requires vx-lock.json at the workspace root — run 'vx lock' and commit it"
|
|
49
|
+
/** What a run says when a crash came from a hand-broken lock entry (M, 2026-10-14). */
|
|
50
|
+
export const LOCK_INVALID = `${LOCKFILE_NAME} is invalid, run \`vx lock\``
|
|
50
51
|
|
|
51
52
|
/**
|
|
52
|
-
*
|
|
53
|
+
* Whether the lock holds an entry `vx lock` could not have written. Runs
|
|
54
|
+
* validate no entry, so a hand-broken one crashed deep in a run with a
|
|
55
|
+
* raw TypeError; asked only on that crash's path, so a run pays nothing.
|
|
56
|
+
*/
|
|
57
|
+
export function lockEntryBroken(root: string): boolean {
|
|
58
|
+
let projects: unknown
|
|
59
|
+
try {
|
|
60
|
+
projects = (JSON.parse(readFileSync(lockfilePath(root), 'utf8')) as { projects?: unknown })
|
|
61
|
+
.projects
|
|
62
|
+
} catch {
|
|
63
|
+
return false
|
|
64
|
+
}
|
|
65
|
+
if (typeof projects !== 'object' || projects === null) return false
|
|
66
|
+
for (const [dir, entry] of Object.entries(projects)) {
|
|
67
|
+
try {
|
|
68
|
+
validateProjectConfig((entry as LockedProject).config, `${LOCKFILE_NAME}: "${dir}"`, null)
|
|
69
|
+
} catch {
|
|
70
|
+
return true
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return false
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** What a run says when there is no lock to read. */
|
|
77
|
+
export const NO_LOCK = 'no lock: run `vx lock`'
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Read and shape-validate `.vx/projects.lock.json`. Returns `null` when no lock
|
|
53
81
|
* exists (the common case — the feature is opt-in). The lock is a
|
|
54
82
|
* user-editable file, so this is a system boundary: malformed content
|
|
55
83
|
* throws a `UserError` instead of crashing deeper in the run.
|
|
@@ -84,68 +112,57 @@ export async function readLockfile(root: string): Promise<Lockfile | null> {
|
|
|
84
112
|
}
|
|
85
113
|
// Same trap, and this one is worse than a bad message: an ARRAY of entries is
|
|
86
114
|
// ACCEPTED, because `Object.entries` happily walks it — yielding a project
|
|
87
|
-
//
|
|
88
|
-
// lock that declares none of them.
|
|
115
|
+
// at directory "0".
|
|
89
116
|
if (typeof lock.projects !== 'object' || lock.projects === null || Array.isArray(lock.projects)) {
|
|
90
117
|
throw new UserError(`${LOCKFILE_NAME}: \`projects\` must be an object — re-run \`vx lock\``)
|
|
91
118
|
}
|
|
92
|
-
for (const [
|
|
93
|
-
const
|
|
119
|
+
for (const [dir, entry] of Object.entries(lock.projects)) {
|
|
120
|
+
const { file, config } = (entry ?? {}) as { file?: unknown; config?: unknown }
|
|
94
121
|
if (
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
typeof
|
|
99
|
-
|
|
122
|
+
typeof entry !== 'object' ||
|
|
123
|
+
entry === null ||
|
|
124
|
+
Array.isArray(entry) ||
|
|
125
|
+
typeof file !== 'string' ||
|
|
126
|
+
!PROJECT_CONFIG_FILENAMES.includes(file) ||
|
|
127
|
+
typeof config !== 'object' ||
|
|
128
|
+
config === null ||
|
|
129
|
+
Array.isArray(config)
|
|
100
130
|
) {
|
|
101
|
-
throw new UserError(`${LOCKFILE_NAME}: entry for "${
|
|
131
|
+
throw new UserError(`${LOCKFILE_NAME}: entry for "${dir}" is malformed — re-run \`vx lock\``)
|
|
102
132
|
}
|
|
103
133
|
}
|
|
104
|
-
lockDigests.set(parsed, xxh3(text).toString(16))
|
|
105
|
-
const stamped = VALIDATED_LINE.exec(text)
|
|
106
|
-
if (stamped !== null && stamped[1] === validatedDigest(text.slice(0, stamped.index) + '\n}')) {
|
|
107
|
-
selfValidated.add(parsed)
|
|
108
|
-
}
|
|
109
134
|
return parsed as Lockfile
|
|
110
135
|
}
|
|
111
136
|
|
|
112
|
-
/** The digest of the bytes each lock `readLockfile` returned was parsed from. */
|
|
113
|
-
const lockDigests = new WeakMap<object, string>()
|
|
114
|
-
|
|
115
137
|
/**
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
* is left out and does not churn a committed lock), as the last field. A lock
|
|
120
|
-
* whose digest matches is validated already, and a cold `--frozen` run
|
|
121
|
-
* skips the ~30 ms of validating 1,090 entries before its first task. An
|
|
122
|
-
* edit, a merge, or another vx version leaves it unmatched, and the
|
|
123
|
-
* entries are validated as before.
|
|
138
|
+
* `vx lock` evaluates and validates every config, then writes a digest of
|
|
139
|
+
* the bytes it wrote, under vx's version, as the last field: the lock's
|
|
140
|
+
* record of which vx validated it. No reader consults it.
|
|
124
141
|
*/
|
|
125
|
-
const VALIDATED_LINE = /,\n {2}"validated": "([0-9a-f]+)"\n\}\n?$/
|
|
126
|
-
const selfValidated = new WeakSet<object>()
|
|
127
|
-
|
|
128
142
|
function validatedDigest(body: string): string {
|
|
129
|
-
return xxh3(`vx-lock-valid-
|
|
143
|
+
return xxh3(`vx-lock-valid-v3\0${VERSION}\0${body}`).toString(16)
|
|
130
144
|
}
|
|
131
145
|
|
|
132
146
|
export async function writeLockfile(root: string, lock: Lockfile): Promise<void> {
|
|
133
147
|
// Written beside its name and renamed over it: `Bun.write` truncates in
|
|
134
|
-
// place, so a
|
|
148
|
+
// place, so a run, or `vx watch` woken by the write
|
|
135
149
|
// itself (item 971), could read an empty or half-written lock and fail
|
|
136
150
|
// on "not valid JSON" (item 972).
|
|
137
151
|
const file = lockfilePath(root)
|
|
138
152
|
const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`
|
|
139
153
|
try {
|
|
140
|
-
|
|
141
|
-
|
|
154
|
+
// Minified: the file only vx reads, and it loads faster than pretty
|
|
155
|
+
// JSON (measured, 10k projects: 15.6 ms against 22.5 ms). `vx.lock.json`
|
|
156
|
+
// beside each config is the copy for people.
|
|
157
|
+
const body = JSON.stringify({ version: lock.version, projects: lock.projects })
|
|
158
|
+
await Bun.write(tmp, `${body.slice(0, -1)},"validated":"${validatedDigest(body)}"}\n`)
|
|
142
159
|
await rename(tmp, file)
|
|
143
160
|
} catch (err) {
|
|
144
161
|
// Best effort: where the write was refused the temp cannot be named
|
|
145
162
|
// either (ENOTDIR), and that must not replace the refusal itself.
|
|
146
163
|
await rm(tmp, { force: true }).catch(() => {})
|
|
147
164
|
// The refusal names the file the user knows: a read-only checkout read
|
|
148
|
-
// `open '
|
|
165
|
+
// `open '…/.vx/projects.lock.json.tmp-…'` where it had read the lock's own name.
|
|
149
166
|
const e = err as NodeJS.ErrnoException
|
|
150
167
|
if (typeof e.message === 'string') e.message = e.message.split(tmp).join(file)
|
|
151
168
|
if (e.path === tmp) e.path = file
|
|
@@ -155,93 +172,32 @@ export async function writeLockfile(root: string, lock: Lockfile): Promise<void>
|
|
|
155
172
|
|
|
156
173
|
/**
|
|
157
174
|
* Run-time config load from the lock: return the FROZEN resolved config
|
|
158
|
-
* for each project, in order. No evaluation happens
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
* back to evaluation would break frozen-env semantics.
|
|
164
|
-
*
|
|
165
|
-
* `store` remembers which entries of which lock bytes this validator
|
|
166
|
-
* accepted, beside the config evaluations and keyed like them (vx's and
|
|
167
|
-
* Bun's versions): validating 1,000 entries was ~30 ms of every warm
|
|
168
|
-
* `--frozen` run, for a verdict that cannot change until the bytes or vx
|
|
169
|
-
* do.
|
|
175
|
+
* for each project, in order. No evaluation happens, and no staleness
|
|
176
|
+
* check or schema validation either: the lock's contract is "what runs is
|
|
177
|
+
* what was locked", `vx lock` validated every entry it wrote, and
|
|
178
|
+
* `vx lock --check` re-evaluates them all. An unlocked project is a hard
|
|
179
|
+
* error; a run never evaluates.
|
|
170
180
|
*/
|
|
171
|
-
export
|
|
181
|
+
export function frozenProjectConfigs(
|
|
172
182
|
lock: Lockfile,
|
|
173
|
-
metas: ReadonlyArray<{ name: string; configPath: string }>,
|
|
183
|
+
metas: ReadonlyArray<{ name: string; dir: string; configPath: string }>,
|
|
174
184
|
root: string,
|
|
175
|
-
|
|
176
|
-
)
|
|
177
|
-
|
|
178
|
-
const rel = relPosix(root, meta.configPath)
|
|
185
|
+
): ProjectConfig[] {
|
|
186
|
+
return metas.map((meta) => {
|
|
187
|
+
const dir = lockKey(root, meta.dir)
|
|
179
188
|
// Own entries only: `constructor` read Object's, "locked" at undefined (D-152).
|
|
180
|
-
const entry = Object.hasOwn(lock.projects,
|
|
181
|
-
if (entry
|
|
182
|
-
// A renamed or moved config: "has no entry for a" named an entry the
|
|
183
|
-
// lock holds, under its old path (D-88).
|
|
184
|
-
throw new UserError(
|
|
185
|
-
`${LOCKFILE_NAME} locks "${meta.name}" at ${entry.configPath}, but its config is ${rel} now — ` +
|
|
186
|
-
`run \`vx lock\` to refresh, or delete ${LOCKFILE_NAME}`,
|
|
187
|
-
)
|
|
188
|
-
}
|
|
189
|
-
if (!entry) {
|
|
189
|
+
const entry = Object.hasOwn(lock.projects, dir) ? lock.projects[dir] : undefined
|
|
190
|
+
if (entry === undefined) {
|
|
190
191
|
throw new UserError(
|
|
191
|
-
`${LOCKFILE_NAME} has no entry for "${meta.name}" (${
|
|
192
|
-
|
|
192
|
+
`${LOCKFILE_NAME} has no entry for "${meta.name}" (${relPosix(root, meta.configPath)}) — ` +
|
|
193
|
+
'run `vx lock`',
|
|
193
194
|
)
|
|
194
195
|
}
|
|
195
|
-
return entry
|
|
196
|
+
return entry.config
|
|
196
197
|
})
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
// in the file solely for --check's fast file-changed reporting.
|
|
203
|
-
// The lock is hand-editable; the stored config crosses the same
|
|
204
|
-
// boundary a freshly evaluated one does.
|
|
205
|
-
const digest = store === undefined ? undefined : lockDigests.get(lock)
|
|
206
|
-
const prefix =
|
|
207
|
-
digest === undefined
|
|
208
|
-
? undefined
|
|
209
|
-
: `vx-lock-valid-v${CONFIG_EVAL_VERSION}\0${VERSION}\0${Bun.version}\0${digest}`
|
|
210
|
-
// One row for a lock whose every entry was accepted: a warm `--all` run
|
|
211
|
-
// asked for 1,090 per-project rows in one `IN` query, ~10 ms of its
|
|
212
|
-
// config load, where the eval-cache path it replaces is cheaper (X-181).
|
|
213
|
-
// A scoped run still validates, and remembers, only what it loads.
|
|
214
|
-
if (
|
|
215
|
-
selfValidated.has(lock) ||
|
|
216
|
-
(prefix !== undefined && store !== undefined && store.getConfigEval(prefix) !== null)
|
|
217
|
-
) {
|
|
218
|
-
return entries.map((e) => e.config)
|
|
219
|
-
}
|
|
220
|
-
const keys = prefix === undefined ? undefined : metas.map((m) => `${prefix}\0${m.name}`)
|
|
221
|
-
const known =
|
|
222
|
-
keys === undefined || store === undefined
|
|
223
|
-
? new Map<string, string>()
|
|
224
|
-
: store.getConfigEvals !== undefined
|
|
225
|
-
? store.getConfigEvals(keys)
|
|
226
|
-
: new Map(keys.flatMap((k) => (store.getConfigEval(k) === null ? [] : [[k, '1'] as const])))
|
|
227
|
-
const accepted: Array<readonly [string, string]> = []
|
|
228
|
-
for (let i = 0; i < entries.length; i++) {
|
|
229
|
-
const key = keys?.[i]
|
|
230
|
-
if (key !== undefined && known.has(key)) continue
|
|
231
|
-
validateProjectConfig(entries[i]!.config, `${LOCKFILE_NAME} (${metas[i]!.name})`)
|
|
232
|
-
if (key !== undefined) accepted.push([key, '1'])
|
|
233
|
-
}
|
|
234
|
-
// Every meta found its entry above, so as many distinct names as the
|
|
235
|
-
// lock holds is every entry of it.
|
|
236
|
-
if (
|
|
237
|
-
prefix !== undefined &&
|
|
238
|
-
new Set(metas.map((m) => m.name)).size === Object.keys(lock.projects).length
|
|
239
|
-
) {
|
|
240
|
-
accepted.push([prefix, '1'])
|
|
241
|
-
}
|
|
242
|
-
if (store !== undefined && accepted.length > 0) {
|
|
243
|
-
if (store.putConfigEvals !== undefined) store.putConfigEvals(accepted)
|
|
244
|
-
else for (const [k, v] of accepted) store.putConfigEval(k, v)
|
|
245
|
-
}
|
|
246
|
-
return entries.map((e) => e.config)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** A project directory as the lock keys it: root-relative POSIX, `.` for the root. */
|
|
201
|
+
export function lockKey(root: string, dir: string): string {
|
|
202
|
+
return relPosix(root, dir) || '.'
|
|
247
203
|
}
|