@vzn/vx 0.0.651 → 0.0.653
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 +3 -4
- package/launcher.sh +2 -1
- package/package.json +5 -5
- package/schemas/error.json +1 -1
- package/schemas/show.json +15 -20
- package/skills/vx/SKILL.md +3 -3
- package/src/bin.ts +37 -3
- package/src/cache/archive.ts +197 -32
- package/src/cache/cache.ts +882 -230
- package/src/cache/checkpointer.ts +102 -0
- package/src/cache/file-hashes.ts +2 -1
- package/src/cache/fold-memo.ts +24 -4
- package/src/cache/fs-clock.ts +26 -0
- package/src/cache/git-inputs.ts +188 -22
- package/src/cache/history-stream.ts +151 -0
- package/src/cache/history-writer.ts +76 -0
- package/src/cache/index.ts +12 -1
- package/src/cache/inputs.ts +616 -135
- package/src/cache/key-fold.ts +1 -1
- package/src/cache/layer.ts +29 -16
- package/src/cache/layered-cache.ts +28 -7
- package/src/cache/lockfile-memo.ts +2 -1
- package/src/cache/output-index.ts +106 -44
- package/src/cache/plugin-cache.ts +2 -2
- package/src/cache/policy.ts +6 -2
- package/src/cache/remote-lane.ts +3 -1
- package/src/cache/run-history.ts +94 -25
- package/src/cache/schema.ts +46 -10
- package/src/cli/cache.ts +149 -36
- package/src/cli/completions.ts +2 -2
- package/src/cli/core-alias.ts +9 -2
- package/src/cli/docs.ts +4 -2
- package/src/cli/help.ts +27 -4
- package/src/cli/index.ts +62 -7
- package/src/cli/info.ts +5 -4
- package/src/cli/init.ts +55 -20
- package/src/cli/last.ts +218 -43
- package/src/cli/lock.ts +187 -24
- package/src/cli/plan-format.ts +24 -21
- package/src/cli/plugin-commands.ts +6 -1
- package/src/cli/plugin-templates.ts +36 -45
- package/src/cli/run.ts +68 -34
- package/src/cli/select.ts +160 -64
- package/src/cli/show.ts +70 -28
- package/src/cli/task-verb.ts +12 -7
- package/src/cli/upgrade.ts +210 -76
- package/src/cli/watch-filter.ts +88 -15
- package/src/cli/watch-fs.ts +34 -47
- package/src/cli/watch-judge.ts +73 -9
- package/src/cli/watch-set.ts +10 -8
- package/src/cli/watch.ts +190 -33
- package/src/cli/why.ts +107 -33
- package/src/cli/workspace-config.ts +11 -1
- package/src/config.ts +56 -30
- package/src/exec/env.ts +3 -1
- package/src/exec/executor.ts +116 -16
- package/src/exec/index.ts +1 -0
- package/src/exec/kill-tree.ts +7 -7
- package/src/exec/runner.ts +270 -108
- package/src/exec/sandbox-binds.ts +109 -18
- package/src/exec/sandbox-paths.ts +3 -1
- package/src/exec/sandbox-runtime.ts +446 -64
- package/src/exec/sandbox-violations.ts +250 -46
- package/src/exec/spawn-off.ts +10 -5
- package/src/exec/task-proxy.ts +93 -0
- package/src/exec/warm-shell.ts +29 -7
- package/src/graph/definition.ts +16 -3
- package/src/graph/folds.ts +89 -16
- package/src/graph/index.ts +8 -0
- package/src/graph/priorities.ts +8 -5
- package/src/graph/scheduler.ts +176 -39
- package/src/graph/stand-ins.ts +36 -0
- package/src/graph/task-graph.ts +347 -160
- package/src/graph/task-status.ts +5 -1
- package/src/handoff.ts +45 -10
- package/src/history/failure-mode.ts +39 -6
- package/src/history/history.ts +103 -30
- package/src/history/index.ts +2 -0
- package/src/history/metrics.ts +193 -73
- package/src/index.ts +6 -3
- package/src/orchestrator/admission.ts +14 -10
- package/src/orchestrator/affected-tasks.ts +35 -13
- package/src/orchestrator/command-windows.ts +8 -5
- package/src/orchestrator/deferred-outputs.ts +42 -13
- package/src/orchestrator/doctor.ts +21 -9
- package/src/orchestrator/download-policy.ts +8 -10
- package/src/orchestrator/excluded-keys.ts +15 -3
- package/src/orchestrator/execute-args.ts +9 -2
- package/src/orchestrator/execute-task.ts +612 -138
- package/src/orchestrator/graph-memo.ts +26 -4
- package/src/orchestrator/graph-stage.ts +27 -4
- package/src/orchestrator/held-servers.ts +36 -14
- package/src/orchestrator/hit-restore.ts +141 -93
- package/src/orchestrator/index.ts +4 -1
- package/src/orchestrator/keyed-projects.ts +53 -0
- package/src/orchestrator/local-floor.ts +21 -3
- package/src/orchestrator/local-shortcircuit.ts +2 -2
- package/src/orchestrator/lockfile-claim.ts +30 -4
- package/src/orchestrator/miss-save.ts +60 -61
- package/src/orchestrator/options.ts +13 -0
- package/src/orchestrator/output-host.ts +16 -4
- package/src/orchestrator/output-log.ts +1 -54
- package/src/orchestrator/persistent-task.ts +32 -16
- package/src/orchestrator/persistent.ts +152 -15
- package/src/orchestrator/placement.ts +82 -16
- package/src/orchestrator/plan.ts +39 -17
- package/src/orchestrator/plugin-host.ts +238 -110
- package/src/orchestrator/plugin.ts +59 -27
- package/src/orchestrator/prepare.ts +194 -38
- package/src/orchestrator/projects.ts +89 -10
- package/src/orchestrator/read-only.ts +115 -0
- package/src/orchestrator/remote-prefetch.ts +1 -1
- package/src/orchestrator/run-context.ts +6 -2
- package/src/orchestrator/run-failures.ts +101 -23
- package/src/orchestrator/run-id.ts +13 -2
- package/src/orchestrator/run-keys.ts +87 -28
- package/src/orchestrator/run-live.ts +16 -13
- package/src/orchestrator/run-lock.ts +27 -4
- package/src/orchestrator/run-records.ts +95 -74
- package/src/orchestrator/run-report.ts +3 -2
- package/src/orchestrator/run-stop.ts +17 -9
- package/src/orchestrator/run.ts +531 -122
- package/src/orchestrator/sandbox-fix.ts +40 -12
- package/src/orchestrator/sandbox-request.ts +314 -44
- package/src/orchestrator/save-lane.ts +24 -13
- package/src/orchestrator/shell-verdict.ts +29 -1
- package/src/orchestrator/signals.ts +103 -8
- package/src/orchestrator/stable-keys.ts +34 -27
- package/src/orchestrator/task-artifacts.ts +16 -3
- package/src/orchestrator/task-hash.ts +191 -34
- package/src/orchestrator/telemetry-host.ts +25 -6
- package/src/orchestrator/telemetry.ts +85 -13
- package/src/orchestrator/upstream.ts +3 -1
- package/src/report/events.ts +23 -6
- package/src/report/forecast.ts +103 -37
- package/src/report/framed-output.ts +179 -162
- package/src/report/index.ts +2 -1
- package/src/report/logger.ts +108 -31
- package/src/report/plain-output.ts +15 -9
- package/src/report/status-line.ts +28 -55
- package/src/report/summary.ts +61 -18
- package/src/settings.ts +50 -14
- package/src/ui/components.ts +36 -42
- package/src/ui/index.ts +3 -1
- package/src/ui/live-region.ts +67 -8
- package/src/ui/terminal.ts +12 -0
- package/src/ui/theme.ts +16 -1
- package/src/util/config-frame.ts +142 -6
- package/src/util/edit-distance.ts +17 -1
- package/src/util/errors.ts +23 -7
- package/src/util/foreign-cache-dir.ts +60 -0
- package/src/util/git-ignored.ts +59 -5
- package/src/util/index.ts +15 -1
- package/src/util/paths.ts +51 -7
- package/src/util/plain-text.ts +19 -0
- package/src/util/secret-mask.ts +57 -1
- package/src/util/settle.ts +18 -1
- package/src/util/size.ts +12 -2
- package/src/util/tail.ts +4 -1
- package/src/util/write-file.ts +86 -9
- package/src/workspace/affected.ts +225 -27
- package/src/workspace/bracket-literal.ts +2 -2
- package/src/workspace/config-bytecode.ts +23 -4
- package/src/workspace/config-eval.ts +90 -16
- package/src/workspace/config-imports.ts +96 -41
- package/src/workspace/config-schema.ts +192 -86
- package/src/workspace/dependencies-lock.ts +7 -14
- package/src/workspace/filter.ts +18 -2
- package/src/workspace/index.ts +3 -1
- package/src/workspace/json-data.ts +34 -8
- package/src/workspace/lockfile.ts +86 -37
- package/src/workspace/migration.ts +58 -31
- package/src/workspace/module-state.ts +62 -0
- package/src/workspace/nested-dirs.ts +8 -0
- package/src/workspace/project-loader.ts +276 -32
- package/src/workspace/repo-id.ts +29 -6
- package/src/workspace/workspace.ts +190 -30
package/src/workspace/filter.ts
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
// given, the base set is "all projects" and excluded packages are removed.
|
|
23
23
|
|
|
24
24
|
import path from 'node:path'
|
|
25
|
-
import { BUN_GLOB_WILDCARDS, relPosix, UserError } from '../util/index.js'
|
|
25
|
+
import { BUN_GLOB_WILDCARDS, hasControl, printable, relPosix, UserError } from '../util/index.js'
|
|
26
26
|
import type { PackageGraph } from './package-graph.js'
|
|
27
27
|
import { canonicalDir } from './canonical-dir.js'
|
|
28
28
|
import type { ProjectMeta } from './workspace.js'
|
|
@@ -76,6 +76,13 @@ export function parseFilter(
|
|
|
76
76
|
/** The filter as typed, when `raw` is the `./` reading of a name (`dirFallback`). */
|
|
77
77
|
typed = raw,
|
|
78
78
|
): ParsedFilter {
|
|
79
|
+
// No project name, path or git ref holds one; echoed raw, a newline split
|
|
80
|
+
// the refusal in two and an escape painted the terminal.
|
|
81
|
+
if (hasControl(raw)) {
|
|
82
|
+
throw new UserError(
|
|
83
|
+
`filter "${printable(raw)}" holds a control character: no project name, path or git ref does`,
|
|
84
|
+
)
|
|
85
|
+
}
|
|
79
86
|
let s = raw
|
|
80
87
|
const negate = s.startsWith('!')
|
|
81
88
|
if (negate) s = s.slice(1)
|
|
@@ -124,9 +131,18 @@ export function parseFilter(
|
|
|
124
131
|
if (scoped !== null && !scoped[1]!.startsWith('./') && scoped[1] !== '.') {
|
|
125
132
|
s = scoped[1]!
|
|
126
133
|
gitSince = scoped[2]!
|
|
134
|
+
// Neither Turbo nor pnpm reads it (both keep `^...` in the name):
|
|
135
|
+
// `{dir}^...[ref]` dropped the `^...` and selected the inverse, and
|
|
136
|
+
// the name spellings matched nothing.
|
|
137
|
+
if (s.endsWith('^...'))
|
|
138
|
+
throw new UserError(
|
|
139
|
+
`filter "${raw}" puts ^... before a git ref, which no form reads: ` +
|
|
140
|
+
`<sel>...[<ref>] is <sel> where it or a dependency changed, ` +
|
|
141
|
+
`<sel>[<ref>]^... the dependencies of the changed ones`,
|
|
142
|
+
)
|
|
127
143
|
// The name glob kept the `...` and matched nothing (create-t3-turbo,
|
|
128
144
|
// `@acme/*...[HEAD]`).
|
|
129
|
-
if (s.endsWith('...')
|
|
145
|
+
if (s.endsWith('...')) {
|
|
130
146
|
s = s.slice(0, -3)
|
|
131
147
|
sinceViaDeps = true
|
|
132
148
|
}
|
package/src/workspace/index.ts
CHANGED
|
@@ -28,6 +28,7 @@ export {
|
|
|
28
28
|
lockKey,
|
|
29
29
|
NO_LOCK,
|
|
30
30
|
readLockfile,
|
|
31
|
+
lockfileWrite,
|
|
31
32
|
writeLockfile,
|
|
32
33
|
type Lockfile,
|
|
33
34
|
type LockedProject,
|
|
@@ -38,7 +39,7 @@ export {
|
|
|
38
39
|
parseDependenciesLock,
|
|
39
40
|
readDependenciesLock,
|
|
40
41
|
recordedDigests,
|
|
41
|
-
|
|
42
|
+
dependenciesLockWrite,
|
|
42
43
|
type DependenciesLock,
|
|
43
44
|
} from './dependencies-lock.js'
|
|
44
45
|
export {
|
|
@@ -60,6 +61,7 @@ export {
|
|
|
60
61
|
validateWorkspace,
|
|
61
62
|
} from './config-schema.js'
|
|
62
63
|
export { configImports } from './config-imports.js'
|
|
64
|
+
export { repoIdsOf } from './repo-id.js'
|
|
63
65
|
export {
|
|
64
66
|
checkProjectNames,
|
|
65
67
|
findWorkspaceRoot,
|
|
@@ -8,10 +8,14 @@
|
|
|
8
8
|
// worker's, which never saw it — or, where the validator does not type
|
|
9
9
|
// the value, it was silently outside the key on both. So every path
|
|
10
10
|
// refuses it, before schema validation, by this one rule: the first load
|
|
11
|
-
// on the live object (
|
|
11
|
+
// on the live object before its JSON copy (project-loader.ts), the worker before its
|
|
12
12
|
// `JSON.stringify` (config-eval.ts). The worker embeds `nonJsonPaths` by its source text, so
|
|
13
13
|
// the function must stay self-contained: no import, no module-scope name.
|
|
14
14
|
//
|
|
15
|
+
// JSON writes only own enumerable string keys: a symbol key or a
|
|
16
|
+
// non-enumerable property vanished from the key and the lock
|
|
17
|
+
// (`"config":{}`), so it is refused too.
|
|
18
|
+
//
|
|
15
19
|
// An `undefined` PROPERTY is allowed: JSON drops it, the validator reads
|
|
16
20
|
// it as absent, and configs write it with conditional spreads. Inside an
|
|
17
21
|
// array it is refused, because JSON writes it as `null`.
|
|
@@ -29,13 +33,20 @@ export function nonJsonPaths(value: unknown): NonJsonValue[] {
|
|
|
29
33
|
// The key or index at each level, rendered only for a finding: the walk
|
|
30
34
|
// runs on every first load, and a path string built per node cost three
|
|
31
35
|
// times the schema check itself.
|
|
32
|
-
const keys: Array<string | number> = []
|
|
36
|
+
const keys: Array<string | number | symbol> = []
|
|
33
37
|
// The objects on the path to the one being walked: a cycle throws in
|
|
34
38
|
// `JSON.stringify` and would recurse here forever. A DAG is data.
|
|
35
39
|
const ancestors: object[] = []
|
|
40
|
+
const MAX_DEPTH = 256
|
|
36
41
|
const found = (is: string): void => {
|
|
37
42
|
let path = ''
|
|
38
|
-
for (const k of keys)
|
|
43
|
+
for (const k of keys)
|
|
44
|
+
path +=
|
|
45
|
+
typeof k === 'number' || typeof k === 'symbol'
|
|
46
|
+
? `[${String(k)}]`
|
|
47
|
+
: path === ''
|
|
48
|
+
? k
|
|
49
|
+
: `.${k}`
|
|
39
50
|
out.push({ path: path === '' ? 'the default export' : path, is })
|
|
40
51
|
}
|
|
41
52
|
const walk = (v: unknown, inArray: boolean): void => {
|
|
@@ -45,6 +56,12 @@ export function nonJsonPaths(value: unknown): NonJsonValue[] {
|
|
|
45
56
|
else if (typeof v === 'number' && !Number.isFinite(v)) found(String(v))
|
|
46
57
|
else if (v === undefined && inArray) found('undefined in an array, which JSON writes as null')
|
|
47
58
|
else if (typeof v === 'object' && v !== null) {
|
|
59
|
+
// Deeper, the walk and `JSON.stringify` overflowed the stack: a
|
|
60
|
+
// config 100,000 levels deep was vx's own RangeError (VX_E_INTERNAL).
|
|
61
|
+
if (ancestors.length >= MAX_DEPTH) {
|
|
62
|
+
found(`nested deeper than ${MAX_DEPTH} levels`)
|
|
63
|
+
return
|
|
64
|
+
}
|
|
48
65
|
if (ancestors.includes(v)) {
|
|
49
66
|
found('a cyclic reference')
|
|
50
67
|
return
|
|
@@ -57,6 +74,13 @@ export function nonJsonPaths(value: unknown): NonJsonValue[] {
|
|
|
57
74
|
walk(v[i], true)
|
|
58
75
|
keys.pop()
|
|
59
76
|
}
|
|
77
|
+
// Symbols only: listing an array's own names allocates every index
|
|
78
|
+
// string, +1-4% on a 20k-task lock, for a named property no config writes.
|
|
79
|
+
for (const key of Object.getOwnPropertySymbols(v)) {
|
|
80
|
+
keys.push(key)
|
|
81
|
+
found('a symbol key, which JSON drops')
|
|
82
|
+
keys.pop()
|
|
83
|
+
}
|
|
60
84
|
ancestors.pop()
|
|
61
85
|
return
|
|
62
86
|
}
|
|
@@ -71,16 +95,18 @@ export function nonJsonPaths(value: unknown): NonJsonValue[] {
|
|
|
71
95
|
return
|
|
72
96
|
}
|
|
73
97
|
ancestors.push(v)
|
|
74
|
-
for (const key of
|
|
98
|
+
for (const key of Reflect.ownKeys(v)) {
|
|
75
99
|
keys.push(key)
|
|
76
100
|
// Read by descriptor: a getter is code, and each read may answer
|
|
77
101
|
// differently. `vx show` read one three times, so the key, the
|
|
78
102
|
// check and the command could each see another value, and a
|
|
79
103
|
// throwing one surfaced as vx's own stack (D-67). Never called here.
|
|
80
|
-
const d = Object.getOwnPropertyDescriptor(v, key)
|
|
81
|
-
if (
|
|
82
|
-
else if (d
|
|
83
|
-
else
|
|
104
|
+
const d = Object.getOwnPropertyDescriptor(v, key) as PropertyDescriptor
|
|
105
|
+
if (typeof key === 'symbol') found('a symbol key, which JSON drops')
|
|
106
|
+
else if (d.enumerable !== true) found('a non-enumerable property, which JSON drops')
|
|
107
|
+
else if (d.get !== undefined) found('a getter')
|
|
108
|
+
else if (d.set !== undefined) found('a setter')
|
|
109
|
+
else walk(d.value, false)
|
|
84
110
|
keys.pop()
|
|
85
111
|
}
|
|
86
112
|
ancestors.pop()
|
|
@@ -11,11 +11,18 @@
|
|
|
11
11
|
// eval-free.
|
|
12
12
|
// - `vx lock --check` AUDITS it: full re-evaluation + deep equality.
|
|
13
13
|
|
|
14
|
-
import { readFileSync } from 'node:fs'
|
|
15
|
-
import { rename, rm } from 'node:fs/promises'
|
|
14
|
+
import { readFileSync, statSync } from 'node:fs'
|
|
16
15
|
import path from 'node:path'
|
|
17
16
|
import type { ProjectConfig } from '../config.js'
|
|
18
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
keysSorted,
|
|
19
|
+
type PendingWrite,
|
|
20
|
+
relPosix,
|
|
21
|
+
UserError,
|
|
22
|
+
writeAllOrNone,
|
|
23
|
+
xxh3,
|
|
24
|
+
xxh3hex,
|
|
25
|
+
} from '../util/index.js'
|
|
19
26
|
import { VERSION } from '../version.js'
|
|
20
27
|
import { validateProjectConfig } from './config-schema.js'
|
|
21
28
|
import { PROJECT_CONFIG_FILENAMES } from './workspace.js'
|
|
@@ -38,6 +45,12 @@ export interface Lockfile {
|
|
|
38
45
|
* by directory, so a name a plugin gives never invalidates the lock.
|
|
39
46
|
*/
|
|
40
47
|
projects: Record<string, LockedProject>
|
|
48
|
+
/**
|
|
49
|
+
* Root-relative POSIX directories holding their own `.vx/workspace.*`,
|
|
50
|
+
* where every outer project's outputs stop. Absent when there is none, so a
|
|
51
|
+
* workspace without one locks the bytes it always did.
|
|
52
|
+
*/
|
|
53
|
+
nestedWorkspaces?: string[]
|
|
41
54
|
/** Written by `writeLockfile` from the bytes; never read off the object. */
|
|
42
55
|
validated?: string
|
|
43
56
|
}
|
|
@@ -101,7 +114,16 @@ export function lockDigest(lock: Lockfile): string | undefined {
|
|
|
101
114
|
*/
|
|
102
115
|
export async function readLockfile(root: string): Promise<Lockfile | null> {
|
|
103
116
|
const file = Bun.file(lockfilePath(root))
|
|
104
|
-
if (!(await file.exists()))
|
|
117
|
+
if (!(await file.exists())) {
|
|
118
|
+
// A directory reads as no file: `--check` said "run `vx lock`", which cannot write over it.
|
|
119
|
+
if (statSync(lockfilePath(root), { throwIfNoEntry: false })?.isDirectory()) {
|
|
120
|
+
throw new UserError(
|
|
121
|
+
`${LOCKFILE_NAME} is a directory — remove it and run \`vx lock\``,
|
|
122
|
+
'VX_E_LOCK',
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
return null
|
|
126
|
+
}
|
|
105
127
|
let parsed: unknown
|
|
106
128
|
let text: string
|
|
107
129
|
try {
|
|
@@ -123,7 +145,7 @@ export async function readLockfile(root: string): Promise<Lockfile | null> {
|
|
|
123
145
|
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
124
146
|
throw new UserError(`${LOCKFILE_NAME} must be a JSON object — re-run \`vx lock\``, 'VX_E_LOCK')
|
|
125
147
|
}
|
|
126
|
-
const lock = parsed as { version?: unknown; projects?: unknown }
|
|
148
|
+
const lock = parsed as { version?: unknown; projects?: unknown; nestedWorkspaces?: unknown }
|
|
127
149
|
if (lock.version !== LOCKFILE_VERSION) {
|
|
128
150
|
throw new UserError(
|
|
129
151
|
`${LOCKFILE_NAME} has unsupported version ${String(lock.version)} ` +
|
|
@@ -153,21 +175,47 @@ export async function readLockfile(root: string): Promise<Lockfile | null> {
|
|
|
153
175
|
Array.isArray(entry) ||
|
|
154
176
|
typeof file !== 'string' ||
|
|
155
177
|
!PROJECT_CONFIG_FILENAMES.includes(file) ||
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
178
|
+
!isObj(config) ||
|
|
179
|
+
// A run trusts these shapes: a string `name`, a `tags` list, a `tasks` object.
|
|
180
|
+
(config['name'] !== undefined && typeof config['name'] !== 'string') ||
|
|
181
|
+
(config['tags'] !== undefined && !Array.isArray(config['tags'])) ||
|
|
182
|
+
(config['tasks'] !== undefined && !isObj(config['tasks']))
|
|
159
183
|
) {
|
|
160
|
-
throw
|
|
161
|
-
`${LOCKFILE_NAME}: entry for "${dir}" is malformed — re-run \`vx lock\``,
|
|
162
|
-
'VX_E_LOCK',
|
|
163
|
-
)
|
|
184
|
+
throw malformed(dir)
|
|
164
185
|
}
|
|
165
186
|
}
|
|
187
|
+
const nested = lock.nestedWorkspaces
|
|
188
|
+
if (
|
|
189
|
+
nested !== undefined &&
|
|
190
|
+
(!Array.isArray(nested) ||
|
|
191
|
+
nested.some((d) => typeof d !== 'string' || d === '.' || !isLockKey(d)))
|
|
192
|
+
) {
|
|
193
|
+
throw new UserError(
|
|
194
|
+
`${LOCKFILE_NAME}: \`nestedWorkspaces\` must list directories under the workspace root — re-run \`vx lock\``,
|
|
195
|
+
'VX_E_LOCK',
|
|
196
|
+
)
|
|
197
|
+
}
|
|
166
198
|
pooledIn(parsed as Pooled)
|
|
199
|
+
// After the pool is put back, as a task may be an index into it. A
|
|
200
|
+
// string `dependsOn` was walked per character: "dependsOn b#a".
|
|
201
|
+
for (const [dir, entry] of Object.entries(lock.projects as Record<string, LockedProject>)) {
|
|
202
|
+
const tasks = entry.config.tasks as Obj | undefined
|
|
203
|
+
for (const name in tasks) {
|
|
204
|
+
const t = tasks[name]
|
|
205
|
+
if (!isObj(t) || (t['dependsOn'] !== undefined && !Array.isArray(t['dependsOn'])))
|
|
206
|
+
throw malformed(dir)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
167
209
|
digests.set(parsed as Lockfile, xxh3hex(text))
|
|
168
210
|
return parsed as Lockfile
|
|
169
211
|
}
|
|
170
212
|
|
|
213
|
+
const malformed = (dir: string): UserError =>
|
|
214
|
+
new UserError(
|
|
215
|
+
`${LOCKFILE_NAME}: entry for "${dir}" is malformed — re-run \`vx lock\``,
|
|
216
|
+
'VX_E_LOCK',
|
|
217
|
+
)
|
|
218
|
+
|
|
171
219
|
/**
|
|
172
220
|
* The project entries of a lock's bytes with its pool put back, or null
|
|
173
221
|
* for bytes that do not parse as a lock: an entry compares across two
|
|
@@ -335,31 +383,32 @@ function taskKeysSorted(key: string, value: unknown): unknown {
|
|
|
335
383
|
return Object.fromEntries(Object.entries(value).map(([name, task]) => [name, keysSorted(task)]))
|
|
336
384
|
}
|
|
337
385
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
386
|
+
/**
|
|
387
|
+
* Written beside its name and renamed over it: `Bun.write` truncates in
|
|
388
|
+
* place, so a run, or `vx watch` woken by the write itself (item 971),
|
|
389
|
+
* could read an empty or half-written lock and fail on "not valid JSON"
|
|
390
|
+
* (item 972).
|
|
391
|
+
*/
|
|
392
|
+
export function writeLockfile(root: string, lock: Lockfile): Promise<void> {
|
|
393
|
+
return writeAllOrNone([lockfileWrite(root, lock)], root)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** The lock's file and bytes, for `writeAllOrNone`. */
|
|
397
|
+
export function lockfileWrite(root: string, lock: Lockfile): PendingWrite {
|
|
398
|
+
// Minified: the file only vx reads, and it loads faster than pretty
|
|
399
|
+
// JSON (measured, 10k projects: 15.6 ms against 22.5 ms). `vx.lock.json`
|
|
400
|
+
// beside each config is the copy for people.
|
|
401
|
+
const sorted = JSON.parse(JSON.stringify(lock.projects, taskKeysSorted)) as Lockfile['projects']
|
|
402
|
+
const nested = lock.nestedWorkspaces ?? []
|
|
403
|
+
const body = JSON.stringify({
|
|
404
|
+
version: lock.version,
|
|
405
|
+
...(nested.length > 0 ? { nestedWorkspaces: nested } : {}),
|
|
406
|
+
...pooledOut(sorted),
|
|
407
|
+
})
|
|
408
|
+
return {
|
|
409
|
+
file: lockfilePath(root),
|
|
410
|
+
text: `${body.slice(0, -1)},"validated":"${validatedDigest(body)}"}\n`,
|
|
411
|
+
shown: LOCKFILE_NAME,
|
|
363
412
|
}
|
|
364
413
|
}
|
|
365
414
|
|
|
@@ -5,11 +5,12 @@
|
|
|
5
5
|
// whichever tool wrote it. Core knows no source format here: a mapper returns a
|
|
6
6
|
// `MigrationPlan` and this file does the rest.
|
|
7
7
|
|
|
8
|
-
import { existsSync, readFileSync } from 'node:fs'
|
|
8
|
+
import { existsSync, readFileSync, realpathSync } from 'node:fs'
|
|
9
9
|
import { unlink } from 'node:fs/promises'
|
|
10
10
|
import path from 'node:path'
|
|
11
11
|
import { workspaceFileWritten } from '../handoff.js'
|
|
12
12
|
import {
|
|
13
|
+
checkIgnoreSync,
|
|
13
14
|
DOT_VX_GITIGNORE,
|
|
14
15
|
executablePath,
|
|
15
16
|
gitEnv,
|
|
@@ -22,6 +23,31 @@ import * as ui from '../ui/index.js'
|
|
|
22
23
|
import { VERSION } from '../version.js'
|
|
23
24
|
import { WORKSPACE_CONFIG_FILENAMES } from './project-loader.js'
|
|
24
25
|
|
|
26
|
+
/**
|
|
27
|
+
* The nearest existing directory at or above `dir` (under `root`) and its
|
|
28
|
+
* real path when that path is outside `realRoot`; none when it is inside.
|
|
29
|
+
*/
|
|
30
|
+
function linkOut(
|
|
31
|
+
root: string,
|
|
32
|
+
realRoot: string,
|
|
33
|
+
dir: string,
|
|
34
|
+
): [link: string | undefined, real: string] {
|
|
35
|
+
for (let at = dir; ; at = path.dirname(at)) {
|
|
36
|
+
let real: string
|
|
37
|
+
try {
|
|
38
|
+
real = realpathSync(at)
|
|
39
|
+
} catch {
|
|
40
|
+
// quiet: not made yet; mkdir makes it a real directory under its parent
|
|
41
|
+
if (at === root) return [undefined, realRoot]
|
|
42
|
+
continue
|
|
43
|
+
}
|
|
44
|
+
const rel = path.relative(realRoot, real)
|
|
45
|
+
return rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)
|
|
46
|
+
? [at, real]
|
|
47
|
+
: [undefined, real]
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
25
51
|
/** The root config `vx init` writes outside a Turbo or Nx repo: no task yet. */
|
|
26
52
|
export function starterProject(format: MigrationFormat): string {
|
|
27
53
|
return format === 'ts'
|
|
@@ -42,6 +68,10 @@ export function isStarterConfig(file: string): boolean {
|
|
|
42
68
|
}
|
|
43
69
|
}
|
|
44
70
|
|
|
71
|
+
/** A C0 or C1 control, or DEL. */
|
|
72
|
+
// oxlint-disable-next-line no-control-regex
|
|
73
|
+
const CONTROL = /[\x00-\x1f\x7f-\x9f]/g
|
|
74
|
+
|
|
45
75
|
/**
|
|
46
76
|
* Escape an arbitrary string into a single-quoted TS literal. Escapes
|
|
47
77
|
* backslash + quote AND raw newlines/CR — a value with an embedded newline
|
|
@@ -55,7 +85,10 @@ export function quoteTsLiteral(s: string): string {
|
|
|
55
85
|
.replaceAll('\\', '\\\\')
|
|
56
86
|
.replaceAll("'", "\\'")
|
|
57
87
|
.replaceAll('\n', '\\n')
|
|
58
|
-
.replaceAll('\r', '\\r')
|
|
88
|
+
.replaceAll('\r', '\\r')
|
|
89
|
+
// Any other control as its escape: a script's raw ESC landed in the
|
|
90
|
+
// file as the byte, and `cat vx.config.ts` retitled the terminal.
|
|
91
|
+
.replace(CONTROL, (c) => `\\x${c.charCodeAt(0).toString(16).padStart(2, '0')}`)}'`
|
|
59
92
|
}
|
|
60
93
|
|
|
61
94
|
/**
|
|
@@ -239,8 +272,9 @@ export interface ApplyMigrationArgs {
|
|
|
239
272
|
* the migrated workspace has its .vx/projects.lock.json and its graph rules checked
|
|
240
273
|
* (2026-10-14). On success `said` is its line, shown as the
|
|
241
274
|
* report's `lock` row; a refusal (a graph rule, a plugin not installed
|
|
242
|
-
* yet) prints above the report
|
|
243
|
-
*
|
|
275
|
+
* yet) prints above the report, the files stay written, `vx lock` is the
|
|
276
|
+
* step to re-run, and the exit is 1: a run reads only the lock, so the
|
|
277
|
+
* migration is not done (as `vx init`'s own path exits).
|
|
244
278
|
*/
|
|
245
279
|
lock?: (root: string) => Promise<{ code: number; said: string }>
|
|
246
280
|
/** Rows of what the tool did before the write, e.g. `['ran', 'bun add -d …']`. */
|
|
@@ -345,11 +379,23 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
|
|
|
345
379
|
|
|
346
380
|
// A plan is a mapper's reading of the repo's own files (an Nx node's
|
|
347
381
|
// `root`, a workspace glob): one that names `../x` wrote outside it.
|
|
382
|
+
// A member directory linked out of the root is spelled inside it, so the
|
|
383
|
+
// real directory is asked too: core's discovery never loads a config
|
|
384
|
+
// there. `.vx` is not a project: its files are refused outside the
|
|
385
|
+
// root at the write (`refuseSymlink`).
|
|
386
|
+
const realRoot = realpathSync(root)
|
|
348
387
|
for (const f of files) {
|
|
349
388
|
const rel = relPosix(root, f.abs)
|
|
350
389
|
if (rel === '..' || rel.startsWith('../') || path.isAbsolute(rel)) {
|
|
351
390
|
throw new UserError(`${f.relPath}: outside the workspace root`)
|
|
352
391
|
}
|
|
392
|
+
if (rel.startsWith('.vx/')) continue
|
|
393
|
+
const [link, real] = linkOut(root, realRoot, path.dirname(f.abs))
|
|
394
|
+
if (link !== undefined) {
|
|
395
|
+
throw new UserError(
|
|
396
|
+
`${f.relPath}: outside the workspace root (${relPosix(root, link)} resolves to ${real}); vx loads no project there`,
|
|
397
|
+
)
|
|
398
|
+
}
|
|
353
399
|
}
|
|
354
400
|
|
|
355
401
|
if (!dry && !force) {
|
|
@@ -389,8 +435,8 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
|
|
|
389
435
|
ui.print('out', ui.excerpt(f.relPath, f.contents))
|
|
390
436
|
}
|
|
391
437
|
} else if (!dry) {
|
|
392
|
-
for (const f of files) refuseSymlink(f.abs)
|
|
393
|
-
for (const f of files) await writeNoFollow(f.abs, f.contents)
|
|
438
|
+
for (const f of files) refuseSymlink(f.abs, root)
|
|
439
|
+
for (const f of files) await writeNoFollow(f.abs, f.contents, root)
|
|
394
440
|
for (const f of replaced) await unlink(f)
|
|
395
441
|
// The lock that follows loads its plugins from the file just written.
|
|
396
442
|
workspaceFileWritten()
|
|
@@ -531,7 +577,7 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
|
|
|
531
577
|
lock === undefined || lock.code === 0 ? next : `fix the above, then vx lock and ${next}`
|
|
532
578
|
rows.push(null, ['next', said])
|
|
533
579
|
ui.print('out', ui.report(`${verb} ${VERSION}`, rows))
|
|
534
|
-
return 0
|
|
580
|
+
return lock === undefined || lock.code === 0 ? 0 : 1
|
|
535
581
|
}
|
|
536
582
|
|
|
537
583
|
/**
|
|
@@ -583,25 +629,7 @@ function ignoreAdvice(root: string, ignored: readonly string[], dry: boolean): s
|
|
|
583
629
|
|
|
584
630
|
/** The paths (root-relative) git's ignore rules drop; none outside a work tree. */
|
|
585
631
|
function gitIgnored(root: string, relPaths: readonly string[]): string[] {
|
|
586
|
-
|
|
587
|
-
try {
|
|
588
|
-
const r = Bun.spawnSync({
|
|
589
|
-
cmd: [executablePath('git'), 'check-ignore', '--stdin', '-z'],
|
|
590
|
-
env: gitEnv(),
|
|
591
|
-
cwd: root,
|
|
592
|
-
stdin: Buffer.from(relPaths.map((p) => `${p}\0`).join('')),
|
|
593
|
-
stdout: 'pipe',
|
|
594
|
-
stderr: 'ignore',
|
|
595
|
-
})
|
|
596
|
-
// 0: some ignored; 1: none; anything else (no work tree): nothing to say.
|
|
597
|
-
if (r.exitCode !== 0) return []
|
|
598
|
-
return r.stdout
|
|
599
|
-
.toString()
|
|
600
|
-
.split('\0')
|
|
601
|
-
.filter((p) => p !== '')
|
|
602
|
-
} catch {
|
|
603
|
-
return []
|
|
604
|
-
}
|
|
632
|
+
return [...(checkIgnoreSync(root, relPaths) ?? [])]
|
|
605
633
|
}
|
|
606
634
|
|
|
607
635
|
function inGitWorkTree(root: string): boolean {
|
|
@@ -658,7 +686,7 @@ const EXAMPLE_BODY = `export default {
|
|
|
658
686
|
build: {
|
|
659
687
|
exec: { command: 'tsc -b' },
|
|
660
688
|
dependsOn: ['^build'],
|
|
661
|
-
inputs: { files: ['src/**'] },
|
|
689
|
+
inputs: { files: ['src/**'] }, outputs: { files: ['dist/**'] }, cache: true,
|
|
662
690
|
},
|
|
663
691
|
},
|
|
664
692
|
}`
|
|
@@ -747,7 +775,7 @@ function renderConfigFile(p: GeneratedProject, format: MigrationFormat): string
|
|
|
747
775
|
// The suggested block sits in the task, commented: deleting `// `
|
|
748
776
|
// after setting the real globs caches it, no copying from the TODO.
|
|
749
777
|
const block =
|
|
750
|
-
t.task['cache']
|
|
778
|
+
t.task['cache'] !== true
|
|
751
779
|
? t.todos.find(isCacheTodo)?.match(/^cache: add `(.+?)`/)?.[1]
|
|
752
780
|
: undefined
|
|
753
781
|
lines.push(
|
|
@@ -790,11 +818,10 @@ function ownConfigNamed(
|
|
|
790
818
|
return {
|
|
791
819
|
...p,
|
|
792
820
|
tasks: p.tasks.map((t) => {
|
|
793
|
-
const outputs =
|
|
821
|
+
const outputs = t.task?.['outputs'] as { files?: unknown } | undefined
|
|
794
822
|
if (!Array.isArray(outputs?.files) || !outputs.files.includes(planned)) return t
|
|
795
|
-
const cache = t.task!['cache'] as Record<string, unknown>
|
|
796
823
|
const files = outputs.files.map((f: unknown) => (f === planned ? `!${name}` : f))
|
|
797
|
-
return { ...t, task: { ...t.task,
|
|
824
|
+
return { ...t, task: { ...t.task, outputs: { ...outputs, files } } }
|
|
798
825
|
}),
|
|
799
826
|
}
|
|
800
827
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// What a module's exports hold, to tell whether a config changed a module
|
|
2
|
+
// other configs import. Both functions are self-contained: the config
|
|
3
|
+
// worker embeds them by their source (config-eval.ts), as it does
|
|
4
|
+
// `nonJsonPaths`, and a minified build renames only what each one holds.
|
|
5
|
+
|
|
6
|
+
/** One entry per export: every value reachable from it, in walk order, compared by identity. */
|
|
7
|
+
export type ExportState = Map<PropertyKey, unknown[]>
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Every value each export of `ns` reaches through own properties and Map
|
|
11
|
+
* and Set entries, without calling a getter. Functions and typed arrays
|
|
12
|
+
* are compared by identity only. State a module keeps unexported, behind a
|
|
13
|
+
* function, is not reached.
|
|
14
|
+
*/
|
|
15
|
+
export function exportState(ns: object): ExportState {
|
|
16
|
+
const ownKeys = Reflect.ownKeys
|
|
17
|
+
const describe = Object.getOwnPropertyDescriptor
|
|
18
|
+
const protoOf = Object.getPrototypeOf
|
|
19
|
+
const state: ExportState = new Map()
|
|
20
|
+
for (const name of ownKeys(ns)) {
|
|
21
|
+
const out: unknown[] = []
|
|
22
|
+
const seen = new Set<object>()
|
|
23
|
+
const walk = (v: unknown): void => {
|
|
24
|
+
out.push(v)
|
|
25
|
+
if (v === null || typeof v !== 'object' || seen.has(v) || ArrayBuffer.isView(v)) return
|
|
26
|
+
seen.add(v)
|
|
27
|
+
out.push(protoOf(v))
|
|
28
|
+
if (v instanceof Map)
|
|
29
|
+
for (const [k, x] of v) {
|
|
30
|
+
out.push(k)
|
|
31
|
+
walk(x)
|
|
32
|
+
}
|
|
33
|
+
else if (v instanceof Set) for (const x of v) walk(x)
|
|
34
|
+
for (const k of ownKeys(v)) {
|
|
35
|
+
const d = describe(v, k)!
|
|
36
|
+
out.push(k, d.get, d.set, d.writable)
|
|
37
|
+
if ('value' in d) walk(d.value)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
walk((ns as Record<PropertyKey, unknown>)[name])
|
|
42
|
+
} catch {
|
|
43
|
+
// A binding not yet initialized, or a Proxy's trap that throws.
|
|
44
|
+
out.push('\0threw')
|
|
45
|
+
}
|
|
46
|
+
state.set(name, out)
|
|
47
|
+
}
|
|
48
|
+
return state
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The exports whose state differs between `before` and `after`. */
|
|
52
|
+
export function changedExports(before: ExportState, after: ExportState): string[] {
|
|
53
|
+
const out: string[] = []
|
|
54
|
+
for (const name of new Set([...before.keys(), ...after.keys()])) {
|
|
55
|
+
const a = before.get(name)
|
|
56
|
+
const b = after.get(name)
|
|
57
|
+
let same = a !== undefined && b !== undefined && a.length === b.length
|
|
58
|
+
for (let i = 0; same && i < a!.length; i++) same = Object.is(a![i], b![i])
|
|
59
|
+
if (!same) out.push(String(name))
|
|
60
|
+
}
|
|
61
|
+
return out
|
|
62
|
+
}
|
|
@@ -19,9 +19,16 @@ import type { ProjectEntry } from './workspace.js'
|
|
|
19
19
|
* once we leave the `p.dir` block entirely. The inner loop is still bounded by
|
|
20
20
|
* that block (near-O(P log P) on real trees; interlopers are few), not the
|
|
21
21
|
* full tail.
|
|
22
|
+
*
|
|
23
|
+
* `workspaces`: the absolute roots of nested workspaces (each holds its own
|
|
24
|
+
* `.vx/workspace.*`), a boundary for outputs: each joins the dirs of every
|
|
25
|
+
* project it lies under. An outer output glob that ran into one let the
|
|
26
|
+
* clean before a run delete its files. Never passed for inputs: a task may
|
|
27
|
+
* read them, and an input left out of the key is a stale hit.
|
|
22
28
|
*/
|
|
23
29
|
export function computeNestedProjectDirs(
|
|
24
30
|
entries: Array<Pick<ProjectEntry, 'name' | 'dir'>>,
|
|
31
|
+
workspaces: readonly string[] = [],
|
|
25
32
|
): Map<string, string[]> {
|
|
26
33
|
const result = new Map<string, string[]>()
|
|
27
34
|
if (entries.length === 0) return result
|
|
@@ -42,6 +49,7 @@ export function computeNestedProjectDirs(
|
|
|
42
49
|
// else: an interloping sibling (`foo-utils` for `foo`) that shares the
|
|
43
50
|
// string prefix but isn't nested — skip it, descendants may follow.
|
|
44
51
|
}
|
|
52
|
+
for (const w of workspaces) if (w.startsWith(prefix)) nested.push(w)
|
|
45
53
|
result.set(p.name, nested)
|
|
46
54
|
}
|
|
47
55
|
return result
|