@vzn/vx 0.0.637 → 0.0.639
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/docs/caching.md +84 -28
- package/docs/cli.md +273 -408
- package/docs/execution.md +19 -44
- package/docs/features.md +13 -15
- package/docs/patterns.md +5 -7
- package/docs/schema.md +68 -61
- package/package.json +5 -5
- package/schemas/last.json +7 -4
- package/schemas/plan.json +2 -2
- package/skills/vx/SKILL.md +19 -6
- package/src/bin.ts +13 -7
- package/src/cache/cache.ts +60 -11
- package/src/cache/git-inputs.ts +6 -0
- package/src/cache/index.ts +10 -2
- package/src/cache/inputs.ts +0 -24
- package/src/cache/layer.ts +32 -12
- package/src/cache/layered-cache.ts +112 -315
- package/src/cache/plugin-cache.ts +240 -0
- package/src/cache/policy.ts +9 -0
- package/src/cache/remote-lane.ts +383 -0
- package/src/cache/run-history.ts +92 -8
- package/src/cache/run-store.ts +77 -0
- package/src/cache/schema.ts +44 -13
- package/src/cache/scoped-cache.ts +22 -2
- package/src/cli/cache.ts +33 -16
- package/src/cli/completions.ts +11 -7
- package/src/cli/foreign-flags.ts +10 -22
- package/src/cli/help.ts +22 -38
- package/src/cli/index.ts +25 -11
- package/src/cli/info.ts +8 -6
- package/src/cli/init.ts +14 -15
- package/src/cli/last.ts +20 -10
- package/src/cli/lock.ts +39 -27
- package/src/cli/plan-format.ts +10 -61
- package/src/cli/plugin-commands.ts +77 -6
- package/src/cli/plugin-templates.ts +1 -1
- package/src/cli/run.ts +31 -191
- package/src/cli/select.ts +18 -9
- package/src/cli/show.ts +72 -40
- package/src/cli/task-verb.ts +4 -4
- package/src/cli/upgrade.ts +132 -39
- package/src/cli/watch-cycle.ts +2 -1
- package/src/cli/watch-filter.ts +7 -2
- package/src/cli/watch-judge.ts +6 -1
- package/src/cli/watch.ts +73 -34
- package/src/cli/why.ts +14 -5
- package/src/cli/workspace-config.ts +2 -2
- package/src/config.ts +0 -6
- package/src/exec/executor.ts +1 -1
- package/src/exec/sandbox-runtime.ts +1 -1
- package/src/exec/sandbox-violations.ts +3 -1
- package/src/graph/index.ts +9 -1
- package/src/graph/priorities.ts +1 -1
- package/src/graph/scheduler.ts +12 -16
- package/src/graph/task-graph.ts +119 -21
- package/src/graph/task-status.ts +95 -0
- package/src/{orchestrator → history}/failure-mode.ts +20 -11
- package/src/{orchestrator → history}/history.ts +61 -5
- package/src/history/index.ts +48 -0
- package/src/{orchestrator → history}/metrics.ts +14 -23
- package/src/{orchestrator → history}/miss-reason.ts +1 -1
- package/src/index.ts +21 -29
- package/src/orchestrator/affected-tasks.ts +8 -3
- package/src/orchestrator/doctor.ts +2 -2
- package/src/orchestrator/execute-args.ts +80 -0
- package/src/orchestrator/execute-task.ts +47 -429
- package/src/orchestrator/fingerprint-watch.ts +2 -2
- package/src/orchestrator/index.ts +3 -74
- package/src/orchestrator/miss-save.ts +33 -3
- package/src/orchestrator/options.ts +1 -36
- package/src/orchestrator/output-host.ts +4 -3
- package/src/orchestrator/persistent-task.ts +321 -0
- package/src/orchestrator/persistent.ts +130 -3
- package/src/orchestrator/placement.ts +3 -3
- package/src/orchestrator/plan.ts +18 -5
- package/src/orchestrator/plugin-host.ts +77 -47
- package/src/orchestrator/plugin.ts +86 -14
- package/src/orchestrator/prepare.ts +74 -31
- package/src/orchestrator/projects.ts +3 -0
- package/src/orchestrator/remote-prefetch.ts +23 -6
- package/src/orchestrator/run-artifacts.ts +5 -177
- package/src/orchestrator/run-context.ts +55 -0
- package/src/orchestrator/run-failures.ts +15 -3
- package/src/orchestrator/run-records.ts +36 -4
- package/src/orchestrator/run-report.ts +81 -179
- package/src/orchestrator/run-settings.ts +1 -16
- package/src/orchestrator/run-stop.ts +153 -0
- package/src/orchestrator/run.ts +313 -555
- package/src/orchestrator/sandbox-fix.ts +0 -16
- package/src/orchestrator/signals.ts +14 -6
- package/src/orchestrator/stable-keys.ts +107 -3
- package/src/orchestrator/steps.ts +50 -0
- package/src/orchestrator/task-hash.ts +3 -16
- package/src/orchestrator/task-log-buffer.ts +1 -1
- package/src/orchestrator/task-process.ts +94 -0
- package/src/orchestrator/telemetry-host.ts +1 -1
- package/src/orchestrator/telemetry.ts +6 -83
- package/src/{orchestrator → report}/events.ts +41 -8
- package/src/{orchestrator → report}/forecast.ts +35 -2
- package/src/{orchestrator → report}/framed-output.ts +39 -34
- package/src/report/index.ts +36 -0
- package/src/{orchestrator → report}/logger.ts +166 -21
- package/src/{orchestrator → report}/status-line.ts +84 -7
- package/src/{orchestrator → report}/summary.ts +179 -63
- package/src/report/tally.ts +83 -0
- package/src/settings.ts +13 -23
- package/src/{orchestrator → util}/colors.ts +49 -4
- package/src/util/config-frame.ts +31 -2
- package/src/util/errors.ts +32 -0
- package/src/util/git-ignored.ts +143 -0
- package/src/util/index.ts +25 -3
- package/src/util/refusal-notice.ts +111 -0
- package/src/util/report.ts +39 -0
- package/src/util/settle.ts +14 -39
- package/src/util/tail.ts +1 -1
- package/src/util/ui.ts +197 -0
- package/src/workspace/affected.ts +20 -23
- package/src/workspace/dependency-rules.ts +12 -4
- package/src/workspace/migration.ts +87 -91
- package/src/workspace/workspace.ts +5 -4
- package/schemas/summary.json +0 -133
- package/src/cache/chained-cache.ts +0 -276
- package/src/orchestrator/tally.ts +0 -112
- /package/src/{orchestrator → report}/path-links.ts +0 -0
- /package/src/{orchestrator → report}/plain-output.ts +0 -0
- /package/src/{orchestrator → report}/terminal-signals.ts +0 -0
package/src/util/ui.ts
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// vx's output components (org/devex/log-design.md). A verb or a plugin
|
|
2
|
+
// composes these instead of writing raw lines: each component owns its
|
|
3
|
+
// color, indent and spacing, so every surface shares one look. Colors
|
|
4
|
+
// are decided at print time, per stream.
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
detectColors,
|
|
8
|
+
ERROR,
|
|
9
|
+
identityColor,
|
|
10
|
+
paint,
|
|
11
|
+
SUCCESS,
|
|
12
|
+
WARN,
|
|
13
|
+
type ColorSupport,
|
|
14
|
+
} from './colors.js'
|
|
15
|
+
import { gradientRule } from './report.js'
|
|
16
|
+
|
|
17
|
+
export type Tone = 'ok' | 'warn' | 'error' | 'info'
|
|
18
|
+
|
|
19
|
+
const INFO = '#06b6d4'
|
|
20
|
+
const HUE: Record<Tone, string> = { ok: SUCCESS, warn: WARN, error: ERROR, info: INFO }
|
|
21
|
+
const SYMBOL: Record<Tone, string> = { ok: '✓', warn: '!', error: '✗', info: '›' }
|
|
22
|
+
const INDENT = ' '
|
|
23
|
+
const LABEL_MIN = 8
|
|
24
|
+
|
|
25
|
+
type Style = Tone | 'dim' | 'bold' | 'id'
|
|
26
|
+
|
|
27
|
+
/** Styled text inside a component: a plain string, a token, or a run of them. */
|
|
28
|
+
export type Inline = string | { readonly style: Style; readonly text: string } | readonly Inline[]
|
|
29
|
+
|
|
30
|
+
/** A laid-out piece of output; `render` and `print` space blocks apart. */
|
|
31
|
+
export interface Block {
|
|
32
|
+
readonly lines: (colors: ColorSupport) => string[]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A `project` or `project#task` id, each half in its name's hue, the `#` dim. */
|
|
36
|
+
export const id = (text: string): Inline => ({ style: 'id', text })
|
|
37
|
+
/** A path, glob or file: bold. */
|
|
38
|
+
export const path = (text: string): Inline => ({ style: 'bold', text })
|
|
39
|
+
/** A command to type: bold. */
|
|
40
|
+
export const cmd = (text: string): Inline => ({ style: 'bold', text })
|
|
41
|
+
/** A secondary fact. */
|
|
42
|
+
export const dim = (text: string): Inline => ({ style: 'dim', text })
|
|
43
|
+
/** Text in a tone's color. */
|
|
44
|
+
export const tone = (t: Tone, text: string): Inline => ({ style: t, text })
|
|
45
|
+
|
|
46
|
+
function inline(x: Inline, c: ColorSupport): string {
|
|
47
|
+
if (typeof x === 'string') return x
|
|
48
|
+
if (Array.isArray(x)) return (x as readonly Inline[]).map((p) => inline(p, c)).join('')
|
|
49
|
+
const { style, text } = x as { style: Style; text: string }
|
|
50
|
+
switch (style) {
|
|
51
|
+
case 'dim':
|
|
52
|
+
return paint('', text, c, { dim: true })
|
|
53
|
+
case 'bold':
|
|
54
|
+
return paint('', text, c, { bold: true })
|
|
55
|
+
case 'id': {
|
|
56
|
+
const hash = text.lastIndexOf('#')
|
|
57
|
+
if (hash < 0) return paint(identityColor(text), text, c)
|
|
58
|
+
const task = text.slice(hash + 1)
|
|
59
|
+
return (
|
|
60
|
+
paint(identityColor(text.slice(0, hash)), text.slice(0, hash), c) +
|
|
61
|
+
paint('', '#', c, { dim: true }) +
|
|
62
|
+
paint(identityColor(task), task, c)
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
default:
|
|
66
|
+
return paint(HUE[style], text, c)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const block = (lines: (c: ColorSupport) => string[]): Block => ({ lines })
|
|
71
|
+
const lines = (x: Inline, c: ColorSupport): string[] => inline(x, c).split('\n')
|
|
72
|
+
const visible = (s: string): number => Bun.stringWidth(s)
|
|
73
|
+
|
|
74
|
+
/** `✗ vx lock lock not written`: the tone's symbol, the bold verb, the headline. */
|
|
75
|
+
export function status(t: Tone, verb: string, headline: Inline): Block {
|
|
76
|
+
return block((c) => [
|
|
77
|
+
`${paint(HUE[t], SYMBOL[t], c)} ${paint('', verb, c, { bold: true })} ${inline(headline, c)}`,
|
|
78
|
+
])
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Indented short lines: what is wrong, or what happened. */
|
|
82
|
+
export function text(...items: Inline[]): Block {
|
|
83
|
+
return block((c) => items.flatMap((i) => lines(i, c).map((l) => `${INDENT}${l}`)))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** An indented dim line: why it matters. */
|
|
87
|
+
export function why(line: Inline): Block {
|
|
88
|
+
return block((c) => lines(line, c).map((l) => `${INDENT}${paint('', l, c, { dim: true })}`))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A label and its values; values after the first sit under it. `null` is a blank line. */
|
|
92
|
+
export type FieldRow = readonly [label: string, ...values: Inline[]] | null
|
|
93
|
+
|
|
94
|
+
/** A dim label column with the values aligned beside it; `next` and `fix` are bold. */
|
|
95
|
+
export function fields(rows: readonly FieldRow[]): Block {
|
|
96
|
+
return block((c) => {
|
|
97
|
+
const width = Math.max(LABEL_MIN, ...rows.map((r) => (r === null ? 0 : r[0].length)))
|
|
98
|
+
const out: string[] = []
|
|
99
|
+
for (const row of rows) {
|
|
100
|
+
if (row === null) {
|
|
101
|
+
out.push('')
|
|
102
|
+
continue
|
|
103
|
+
}
|
|
104
|
+
const [label, ...values] = row
|
|
105
|
+
const strong = label === 'next' || label === 'fix'
|
|
106
|
+
values
|
|
107
|
+
.flatMap((v) => lines(v, c))
|
|
108
|
+
.forEach((v, i) => {
|
|
109
|
+
const head =
|
|
110
|
+
i === 0 ? paint('', label.padEnd(width), c, { dim: true }) : ' '.repeat(width)
|
|
111
|
+
const value = strong ? paint('', v, c, { bold: true }) : v
|
|
112
|
+
out.push(`${INDENT}${head} ${value}`.trimEnd())
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
return out
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** `fix <action>`: what to do about it. */
|
|
120
|
+
export function fix(action: Inline): Block {
|
|
121
|
+
return labelled('fix', action)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** `next <command>`: the command to type next. */
|
|
125
|
+
export function next(command: Inline): Block {
|
|
126
|
+
return labelled('next', command)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function labelled(label: string, value: Inline): Block {
|
|
130
|
+
return block((c) => [
|
|
131
|
+
`${INDENT}${paint('', label, c, { dim: true })} ${paint('', inline(value, c), c, { bold: true })}`,
|
|
132
|
+
])
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** A report: a blank line, the brand rule naming `mark`, then its fields. */
|
|
136
|
+
export function report(mark: string, rows: readonly FieldRow[]): Block {
|
|
137
|
+
return block((c) => ['', gradientRule(c, mark), ...fields(rows).lines(c)])
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Aligned columns under a dim header. */
|
|
141
|
+
export function table(columns: readonly string[], rows: readonly (readonly Inline[])[]): Block {
|
|
142
|
+
return block((c) => {
|
|
143
|
+
const cells = rows.map((r) => r.map((x) => inline(x, c)))
|
|
144
|
+
const widths = columns.map((h, i) =>
|
|
145
|
+
Math.max(h.length, ...cells.map((r) => visible(r[i] ?? ''))),
|
|
146
|
+
)
|
|
147
|
+
const pad = (s: string, i: number): string =>
|
|
148
|
+
i === columns.length - 1 ? s : s + ' '.repeat(widths[i]! - visible(s))
|
|
149
|
+
return [
|
|
150
|
+
`${INDENT}${paint('', columns.map((h, i) => pad(h, i)).join(' '), c, { dim: true })}`.trimEnd(),
|
|
151
|
+
...cells.map((r) => `${INDENT}${r.map(pad).join(' ')}`.trimEnd()),
|
|
152
|
+
]
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** A source frame (`> 1 | …` and its caret), indented. */
|
|
157
|
+
export function code(frame: string): Block {
|
|
158
|
+
return block(() => frame.split('\n').map((l) => (l === '' ? '' : `${INDENT}${l}`)))
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export interface NoticeParts {
|
|
162
|
+
tone: Tone
|
|
163
|
+
verb: string
|
|
164
|
+
headline: Inline
|
|
165
|
+
/** What is wrong, one fact a line. */
|
|
166
|
+
what?: readonly Inline[]
|
|
167
|
+
why?: Inline
|
|
168
|
+
/** A source frame, below the facts. */
|
|
169
|
+
code?: string
|
|
170
|
+
fix?: Inline
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** A refusal or a warning: status, then what and why, the frame, and the fix, a blank line apart. */
|
|
174
|
+
export function notice(p: NoticeParts): Block {
|
|
175
|
+
return block((c) => {
|
|
176
|
+
const parts: Block[] = [status(p.tone, p.verb, p.headline)]
|
|
177
|
+
const body = [
|
|
178
|
+
...(p.what !== undefined ? [text(...p.what)] : []),
|
|
179
|
+
...(p.why !== undefined ? [why(p.why)] : []),
|
|
180
|
+
]
|
|
181
|
+
if (body.length > 0) parts.push(block((cc) => body.flatMap((b) => b.lines(cc))))
|
|
182
|
+
if (p.code !== undefined) parts.push(code(p.code))
|
|
183
|
+
if (p.fix !== undefined) parts.push(fix(p.fix))
|
|
184
|
+
return parts.flatMap((b, i) => (i === 0 ? b.lines(c) : ['', ...b.lines(c)]))
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The blocks as text, a blank line apart, ending in a newline. */
|
|
189
|
+
export function render(blocks: readonly Block[], colors: ColorSupport): string {
|
|
190
|
+
return `${blocks.map((b) => b.lines(colors).join('\n')).join('\n\n')}\n`
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Write the blocks to stdout (`out`) or stderr (`err`), colored for that stream. */
|
|
194
|
+
export function print(stream: 'out' | 'err', ...blocks: Block[]): void {
|
|
195
|
+
const s = stream === 'out' ? process.stdout : process.stderr
|
|
196
|
+
s.write(render(blocks, detectColors(s)))
|
|
197
|
+
}
|
|
@@ -143,6 +143,8 @@ export interface AffectedChanges {
|
|
|
143
143
|
/** Project → its changed paths, relative to the project's directory. */
|
|
144
144
|
paths: ReadonlyMap<string, readonly string[]>
|
|
145
145
|
whole: ReadonlySet<string>
|
|
146
|
+
/** A whole project → the workspace-wide file that made it so (a workspace config, a claimed root file). */
|
|
147
|
+
wholeBy?: ReadonlyMap<string, string>
|
|
146
148
|
/**
|
|
147
149
|
* The changed paths that are nested repositories (a submodule, an
|
|
148
150
|
* embedded repository), workspace-relative: each stands for every file
|
|
@@ -269,11 +271,17 @@ export async function affectedChanges(
|
|
|
269
271
|
// itself, so an unclaimed one is a path like any other.
|
|
270
272
|
//
|
|
271
273
|
// Selection is not hashed, so widening it here changes no cache key.
|
|
272
|
-
const everything = (): AffectedChanges => {
|
|
274
|
+
const everything = (file: string): AffectedChanges => {
|
|
273
275
|
const all = new Set(args.projects.map((p) => p.name))
|
|
274
|
-
|
|
276
|
+
const wholeBy = new Map([...all].map((n) => [n, file]))
|
|
277
|
+
return { projects: all, changed, paths: new Map(), whole: all, wholeBy }
|
|
275
278
|
}
|
|
276
|
-
|
|
279
|
+
// The workspace config's plugins shape every project's resolved config,
|
|
280
|
+
// so its edit can re-key any task and selection cannot tell which (item
|
|
281
|
+
// 953). No key folds the file; selection is not hashed, so widening here
|
|
282
|
+
// changes no key.
|
|
283
|
+
const wsConfig = WORKSPACE_CONFIG_FILENAMES.find((n) => changed.includes(n))
|
|
284
|
+
if (wsConfig !== undefined) return everything(wsConfig)
|
|
277
285
|
// Loading the workspace file costs an evaluation; only a changed ROOT
|
|
278
286
|
// name can be claimed, so a diff inside the projects never loads it.
|
|
279
287
|
let claims: FingerprintClaims | undefined
|
|
@@ -287,12 +295,13 @@ export async function affectedChanges(
|
|
|
287
295
|
const lock = await bytesOrNull(path.join(args.workspaceRoot, 'bun.lock'))
|
|
288
296
|
if (lock !== null) {
|
|
289
297
|
const patches = new Set(bunPatchFiles(lock))
|
|
290
|
-
|
|
291
|
-
|
|
298
|
+
const patch = changed.find((p) => patches.has(p))
|
|
299
|
+
if (patch !== undefined && (await loadClaims())?.files.has('bun.lock')) {
|
|
300
|
+
return everything(patch)
|
|
292
301
|
}
|
|
293
302
|
}
|
|
294
303
|
}
|
|
295
|
-
const claimedOwned = new
|
|
304
|
+
const claimedOwned = new Map<string, string>()
|
|
296
305
|
const rootNamesChanged = changed.filter((p) => !p.includes('/'))
|
|
297
306
|
const claimed = rootNamesChanged.length > 0 ? await loadClaims() : undefined
|
|
298
307
|
for (const file of rootNamesChanged) {
|
|
@@ -302,8 +311,8 @@ export async function affectedChanges(
|
|
|
302
311
|
before: await gitBytesAt(args.workspaceRoot, base, file),
|
|
303
312
|
after: await bytesOrNull(path.join(args.workspaceRoot, file)),
|
|
304
313
|
})
|
|
305
|
-
if (answer === undefined) return everything()
|
|
306
|
-
for (const name of answer) claimedOwned.
|
|
314
|
+
if (answer === undefined) return everything(file)
|
|
315
|
+
for (const name of answer) if (!claimedOwned.has(name)) claimedOwned.set(name, file)
|
|
307
316
|
}
|
|
308
317
|
|
|
309
318
|
const realDirs = new Map(
|
|
@@ -312,7 +321,7 @@ export async function affectedChanges(
|
|
|
312
321
|
),
|
|
313
322
|
)
|
|
314
323
|
const paths = new Map<string, string[]>()
|
|
315
|
-
const whole = new Set<string>(claimedOwned)
|
|
324
|
+
const whole = new Set<string>(claimedOwned.keys())
|
|
316
325
|
const owned = projectsContaining(
|
|
317
326
|
args.workspaceRoot,
|
|
318
327
|
changed,
|
|
@@ -330,7 +339,7 @@ export async function affectedChanges(
|
|
|
330
339
|
}
|
|
331
340
|
: undefined,
|
|
332
341
|
)
|
|
333
|
-
for (const name of claimedOwned) owned.add(name)
|
|
342
|
+
for (const name of claimedOwned.keys()) owned.add(name)
|
|
334
343
|
|
|
335
344
|
// A config that makes an existing directory a project took files from
|
|
336
345
|
// the project above it.
|
|
@@ -367,6 +376,7 @@ export async function affectedChanges(
|
|
|
367
376
|
changed,
|
|
368
377
|
paths,
|
|
369
378
|
whole,
|
|
379
|
+
...(claimedOwned.size > 0 ? { wholeBy: claimedOwned } : {}),
|
|
370
380
|
...(nestedDirs.length > 0 ? { nested: nestedDirs } : {}),
|
|
371
381
|
})
|
|
372
382
|
if (changed.length === 0 || args.workspaceGlobOwners === undefined) return done()
|
|
@@ -462,19 +472,6 @@ async function gitBlobsAt(
|
|
|
462
472
|
return blobs
|
|
463
473
|
}
|
|
464
474
|
|
|
465
|
-
/**
|
|
466
|
-
* Whether `changed` holds the workspace config. Its plugins' `config` and
|
|
467
|
-
* `project` stages shape every project's resolved config, so such an edit
|
|
468
|
-
* can re-key any task, and selection cannot tell which: an edit that
|
|
469
|
-
* changed every key selected nothing (item 953). No key folds the file
|
|
470
|
-
* (its placement plugins must not split a cache between machines); a
|
|
471
|
-
* stage it installs re-keys through the resolved configs instead.
|
|
472
|
-
* Selection is not hashed, so widening here changes no key.
|
|
473
|
-
*/
|
|
474
|
-
function workspaceConfigChanged(changed: readonly string[]): boolean {
|
|
475
|
-
return WORKSPACE_CONFIG_FILENAMES.some((n) => changed.includes(n))
|
|
476
|
-
}
|
|
477
|
-
|
|
478
475
|
/**
|
|
479
476
|
* A root file's bytes at `ref`, or null when the ref has no such file.
|
|
480
477
|
* `./` anchors the path to the cwd (the workspace root) rather than the
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { DependencyRule } from '../config.js'
|
|
2
|
-
import {
|
|
2
|
+
import { refusal } from '../util/index.js'
|
|
3
3
|
import { compileNameGlob } from './filter.js'
|
|
4
4
|
import type { PackageGraph } from './package-graph.js'
|
|
5
5
|
|
|
@@ -40,9 +40,17 @@ export function checkDependencyRules(
|
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
if (broken.length === 0) return
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
const headline = `${broken.length === 1 ? '1 dependency breaks' : `${broken.length} dependencies break`} run.rules.dependencies`
|
|
44
|
+
throw refusal(
|
|
45
|
+
{
|
|
46
|
+
headline,
|
|
47
|
+
what: broken,
|
|
48
|
+
fix: "drop the dependency, or change the project's tags or the rule",
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
code: 'VX_E_DEPENDENCY_RULE',
|
|
52
|
+
message: `${headline}:\n${broken.map((b) => ` ${b}`).join('\n')}\nDrop the dependency, or change the project's tags or the rule.`,
|
|
53
|
+
},
|
|
46
54
|
)
|
|
47
55
|
}
|
|
48
56
|
|
|
@@ -9,7 +9,8 @@ import { existsSync, readFileSync } 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
|
-
import { DOT_VX_GITIGNORE, executablePath, gitEnv, relPosix, UserError } from '../util/index.js'
|
|
12
|
+
import { DOT_VX_GITIGNORE, executablePath, gitEnv, relPosix, UserError, ui } from '../util/index.js'
|
|
13
|
+
import { VERSION } from '../version.js'
|
|
13
14
|
import { WORKSPACE_CONFIG_FILENAMES } from './project-loader.js'
|
|
14
15
|
|
|
15
16
|
/**
|
|
@@ -136,6 +137,8 @@ export interface GeneratedProject {
|
|
|
136
137
|
importLines: string[]
|
|
137
138
|
/** The project's `tags` (an Nx project's); none written when empty. */
|
|
138
139
|
tags?: readonly string[]
|
|
140
|
+
/** Writes `name`: no manifest names the project, so its path would. */
|
|
141
|
+
writeName?: boolean
|
|
139
142
|
tasks: GeneratedTask[]
|
|
140
143
|
}
|
|
141
144
|
|
|
@@ -177,7 +180,7 @@ export interface ApplyMigrationArgs {
|
|
|
177
180
|
* migration with nothing to convert is an error. Default false.
|
|
178
181
|
*/
|
|
179
182
|
init?: boolean
|
|
180
|
-
/**
|
|
183
|
+
/** The report's `note` lines (e.g. "turbo.json found and not read"). */
|
|
181
184
|
notes?: readonly string[]
|
|
182
185
|
/**
|
|
183
186
|
* Scripts exist but no project can carry them (a member without a
|
|
@@ -205,11 +208,14 @@ export interface ApplyMigrationArgs {
|
|
|
205
208
|
/**
|
|
206
209
|
* Run after the files are written (not under `dry`): `vx lock`'s step, so
|
|
207
210
|
* the migrated workspace has its .vx/projects.lock.json and its graph rules checked
|
|
208
|
-
* (owner, 2026-10-14).
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
+
* (owner, 2026-10-14). On success `said` is its line, shown as the
|
|
212
|
+
* report's `lock` row; a refusal (a graph rule, a plugin not installed
|
|
213
|
+
* yet) prints above the report and the migration still succeeds: its
|
|
214
|
+
* files are written, and `vx lock` is the step to re-run.
|
|
211
215
|
*/
|
|
212
|
-
lock?: (root: string) => Promise<number>
|
|
216
|
+
lock?: (root: string) => Promise<{ code: number; said: string }>
|
|
217
|
+
/** Rows of what the tool did before the write, e.g. `['ran', 'bun add -d …']`. */
|
|
218
|
+
steps?: readonly (readonly [label: string, value: string])[]
|
|
213
219
|
/** What the written workspace file declares, e.g. `pnpm()` from `@vzn/vx-lockfile`. */
|
|
214
220
|
plugins?: readonly WorkspacePlugin[]
|
|
215
221
|
}
|
|
@@ -363,89 +369,20 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
|
|
|
363
369
|
)
|
|
364
370
|
const ignoredNote = ignored.length === 0 ? [] : [ignoreAdvice(root, ignored, dry)]
|
|
365
371
|
const todos = new Map<string, string[]>()
|
|
366
|
-
let todoCount = 0
|
|
367
372
|
let clean = 0
|
|
368
|
-
let
|
|
373
|
+
let todo = 0
|
|
369
374
|
for (const p of projects) {
|
|
370
375
|
for (const t of p.tasks) {
|
|
371
376
|
if (t.todos.length === 0 && t.task !== null) clean++
|
|
372
|
-
if (t.
|
|
377
|
+
if (t.todos.length > 0) todo++
|
|
373
378
|
for (const reason of t.todos) {
|
|
374
|
-
todoCount++
|
|
375
379
|
const ids = todos.get(reason) ?? []
|
|
376
380
|
ids.push(`${p.name}#${t.name}`)
|
|
377
381
|
todos.set(reason, ids)
|
|
378
382
|
}
|
|
379
383
|
}
|
|
380
384
|
}
|
|
381
|
-
const report: string[] = []
|
|
382
|
-
const emptyReason = empty ? args.emptyReason : undefined
|
|
383
|
-
if (emptyReason !== undefined) {
|
|
384
|
-
report.push(
|
|
385
|
-
`${verb}: ${emptyReason}`,
|
|
386
|
-
hasWorkspaceFile
|
|
387
|
-
? `${workspaceName} already exists.`
|
|
388
|
-
: dry
|
|
389
|
-
? `would write ${workspaceName} (dry run, nothing written).`
|
|
390
|
-
: `wrote ${workspaceName}.`,
|
|
391
|
-
)
|
|
392
|
-
} else if (empty) {
|
|
393
|
-
report.push(
|
|
394
|
-
args.unmapped === true
|
|
395
|
-
? `${verb}: no package.json scripts became tasks.`
|
|
396
|
-
: `${verb}: no package.json scripts to turn into tasks.`,
|
|
397
|
-
...[...(args.notes ?? []), ...ignoredNote].map((n) => `note: ${n}`),
|
|
398
|
-
hasWorkspaceFile
|
|
399
|
-
? `${workspaceName} already exists.`
|
|
400
|
-
: dry
|
|
401
|
-
? `would write ${workspaceName} (dry run, nothing written).`
|
|
402
|
-
: `wrote ${workspaceName}.`,
|
|
403
|
-
`Declare tasks in a ${configName} beside a package.json — your own command, for example:`,
|
|
404
|
-
'',
|
|
405
|
-
...exampleConfig(format)
|
|
406
|
-
.trimEnd()
|
|
407
|
-
.split('\n')
|
|
408
|
-
.map((l) => ` ${l}`),
|
|
409
|
-
)
|
|
410
|
-
} else {
|
|
411
|
-
report.push(`${verb}: ${source} → ${[...new Set(names.values())].join(', ') || configName}`)
|
|
412
|
-
for (const n of [...(args.notes ?? []), ...ignoredNote, ...formatted]) report.push(`note: ${n}`)
|
|
413
|
-
for (const n of plan.headerNotes) report.push(`note: ${n}`)
|
|
414
|
-
report.push(
|
|
415
|
-
'',
|
|
416
|
-
`${clean} task${clean === 1 ? '' : 's'} migrated clean, ` +
|
|
417
|
-
`${todoCount} TODO${todoCount === 1 ? '' : 's'}${todoCount > 0 ? ':' : ''}`,
|
|
418
|
-
)
|
|
419
|
-
for (const [reason, ids] of todos) {
|
|
420
|
-
if (ids.length === 1) report.push(` ${ids[0]}: ${reason}`)
|
|
421
|
-
else {
|
|
422
|
-
const shown = ids.slice(0, TODO_IDS_SHOWN).join(', ')
|
|
423
|
-
const more = ids.length - TODO_IDS_SHOWN
|
|
424
|
-
report.push(` ${shown}${more > 0 ? ` and ${more} more` : ''}:`, ` ${reason}`)
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
report.push(...plan.notes)
|
|
428
|
-
if (files.length === 0) report.push(dry ? 'no files (dry run)' : 'no files written')
|
|
429
|
-
else report.push(dry ? 'files (dry run, nothing written):' : 'files written:')
|
|
430
|
-
report.push(...fileLines(files.map((f) => f.relPath)))
|
|
431
|
-
if (kept.length > 0) {
|
|
432
|
-
report.push('kept (each already has a vx config):')
|
|
433
|
-
for (const f of kept) report.push(` ${f}`)
|
|
434
|
-
}
|
|
435
|
-
if (replaced.length > 0) {
|
|
436
|
-
report.push(dry ? 'would replace (dry run):' : 'replaced:')
|
|
437
|
-
for (const f of replaced) report.push(` ${relPosix(root, f)}`)
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
385
|
const firstTask = nextTask(plan.projects.flatMap((p) => p.tasks))
|
|
441
|
-
// A run of tasks none of which caches is never a hit, and a first try
|
|
442
|
-
// that runs twice to see the cache work saw it run twice.
|
|
443
|
-
if (!cached && [...todos.keys()].some(isCacheTodo)) {
|
|
444
|
-
report.push(
|
|
445
|
-
'',
|
|
446
|
-
'no task caches yet: uncomment the cache line in each TODO task with its real globs, and a second run hits',
|
|
447
|
-
)
|
|
448
|
-
}
|
|
449
386
|
const installed = existsSync(path.join(root, 'node_modules', '@vzn', 'vx', 'package.json'))
|
|
450
387
|
// The line is the next thing to type, so it names what that run would
|
|
451
388
|
// refuse without: a git work tree (vx keys inputs on git's view) and,
|
|
@@ -460,6 +397,14 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
|
|
|
460
397
|
...(!dry && args.lock === undefined ? [`${vx} lock`] : []),
|
|
461
398
|
]
|
|
462
399
|
const next = steps.length === 0 ? run : `${steps.join(', ')}, then ${run}`
|
|
400
|
+
const emptyReason = empty ? args.emptyReason : undefined
|
|
401
|
+
const notes = [
|
|
402
|
+
...(args.notes ?? []),
|
|
403
|
+
...ignoredNote,
|
|
404
|
+
...formatted,
|
|
405
|
+
...plan.headerNotes,
|
|
406
|
+
...plan.notes,
|
|
407
|
+
]
|
|
463
408
|
if (args.json === true) {
|
|
464
409
|
process.stdout.write(
|
|
465
410
|
`${JSON.stringify({
|
|
@@ -471,24 +416,75 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
|
|
|
471
416
|
todos: [...todos].map(([reason, tasks]) => ({ reason, tasks })),
|
|
472
417
|
notes: [
|
|
473
418
|
...(emptyReason === undefined ? [] : [emptyReason]),
|
|
474
|
-
...(args.
|
|
475
|
-
...
|
|
476
|
-
...plan.headerNotes,
|
|
477
|
-
...plan.notes,
|
|
419
|
+
...(args.steps ?? []).map(([label, value]) => `${label}: ${value}`),
|
|
420
|
+
...notes,
|
|
478
421
|
],
|
|
479
422
|
next,
|
|
480
423
|
})}\n`,
|
|
481
424
|
)
|
|
482
425
|
return 0
|
|
483
426
|
}
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
427
|
+
|
|
428
|
+
// The lock's own words become a row; a refusal prints above the report.
|
|
429
|
+
const lock = dry || args.lock === undefined ? undefined : await args.lock(root)
|
|
430
|
+
if (lock !== undefined && lock.code !== 0) process.stderr.write(lock.said)
|
|
431
|
+
|
|
432
|
+
const rows: ui.FieldRow[] = [['from', source]]
|
|
433
|
+
if (dry) rows.push(['dry run', 'nothing written'])
|
|
434
|
+
for (const [label, value] of args.steps ?? []) rows.push([label, value])
|
|
435
|
+
if (empty) {
|
|
436
|
+
const why =
|
|
437
|
+
emptyReason ??
|
|
438
|
+
(args.unmapped === true
|
|
439
|
+
? 'no package.json script became a task'
|
|
440
|
+
: 'no package.json scripts to turn into tasks')
|
|
441
|
+
rows.push(['tasks', [ui.dim('none'), `: ${why}`]])
|
|
442
|
+
} else {
|
|
443
|
+
const parts = [
|
|
444
|
+
...(clean > 0 ? [ui.tone('ok', `${clean} clean`)] : []),
|
|
445
|
+
...(todo > 0 ? [ui.tone('warn', `${todo} todo`)] : []),
|
|
446
|
+
]
|
|
447
|
+
if (parts.length > 0)
|
|
448
|
+
rows.push(['tasks', parts.flatMap((p, i) => (i === 0 ? [p] : [ui.dim(' · '), p]))])
|
|
449
|
+
const todoLines: string[] = []
|
|
450
|
+
for (const [reason, ids] of todos) {
|
|
451
|
+
if (ids.length === 1) todoLines.push(`${ids[0]}: ${reason}`)
|
|
452
|
+
else {
|
|
453
|
+
const shown = ids.slice(0, TODO_IDS_SHOWN).join(', ')
|
|
454
|
+
const more = ids.length - TODO_IDS_SHOWN
|
|
455
|
+
todoLines.push(`${shown}${more > 0 ? ` and ${more} more` : ''}:`, ` ${reason}`)
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
if (todoLines.length > 0) rows.push(['todo', ...todoLines])
|
|
459
|
+
}
|
|
460
|
+
if (files.length > 0) rows.push(['files', ...fileLines(files.map((f) => f.relPath))])
|
|
461
|
+
const keptLines = [
|
|
462
|
+
...kept.map((f) => `${f} (has a vx config)`),
|
|
463
|
+
...(empty && hasWorkspaceFile ? [workspaceName] : []),
|
|
464
|
+
]
|
|
465
|
+
if (keptLines.length > 0) rows.push(['kept', ...keptLines])
|
|
466
|
+
if (replaced.length > 0) rows.push(['replaced', ...replaced.map((f) => relPosix(root, f))])
|
|
467
|
+
if (lock !== undefined) {
|
|
468
|
+
rows.push([
|
|
469
|
+
'lock',
|
|
470
|
+
lock.code === 0
|
|
471
|
+
? lock.said.trim().replace(/^✓ vx lock\s+/, '')
|
|
472
|
+
: ui.tone('error', 'not written'),
|
|
473
|
+
])
|
|
474
|
+
}
|
|
475
|
+
if (notes.length > 0) rows.push(['note', ...notes])
|
|
476
|
+
if (empty && emptyReason === undefined) {
|
|
477
|
+
rows.push([
|
|
478
|
+
'example',
|
|
479
|
+
`a ${configName} beside a package.json, with your own command:`,
|
|
480
|
+
'',
|
|
481
|
+
exampleConfig(format).trimEnd(),
|
|
482
|
+
])
|
|
488
483
|
}
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
484
|
+
const said =
|
|
485
|
+
lock === undefined || lock.code === 0 ? next : `fix the above, then vx lock and ${next}`
|
|
486
|
+
rows.push(null, ['next', said])
|
|
487
|
+
ui.print('out', ui.report(`${verb} ${VERSION}`, rows))
|
|
492
488
|
return 0
|
|
493
489
|
}
|
|
494
490
|
|
|
@@ -615,10 +611,9 @@ export function vxInvocation(userAgent: string | undefined, installed: boolean):
|
|
|
615
611
|
const FILES_SHOWN = 10
|
|
616
612
|
|
|
617
613
|
function fileLines(paths: readonly string[]): string[] {
|
|
618
|
-
if (paths.length <= FILES_SHOWN) return paths
|
|
614
|
+
if (paths.length <= FILES_SHOWN) return [...paths]
|
|
619
615
|
const root = paths.filter((p) => !p.includes('/') || p.startsWith('.vx/'))
|
|
620
|
-
|
|
621
|
-
return [...root.map((p) => ` ${p}`), ` and ${more} more in package directories`]
|
|
616
|
+
return [...root, `and ${paths.length - root.length} more in package directories`]
|
|
622
617
|
}
|
|
623
618
|
|
|
624
619
|
/** Ids named per shared TODO; the files carry each one (sveltejs/kit: 92 persistent tasks). */
|
|
@@ -691,6 +686,7 @@ function renderConfigFile(p: GeneratedProject, format: MigrationFormat): string
|
|
|
691
686
|
if (p.importLines.length > 0) lines.push(...p.importLines)
|
|
692
687
|
if (lines.length > 0) lines.push('')
|
|
693
688
|
lines.push('export default {')
|
|
689
|
+
if (p.writeName === true) lines.push(` name: ${quoteTsLiteral(p.name)},`)
|
|
694
690
|
if (p.tags !== undefined && p.tags.length > 0) lines.push(` tags: ${renderValue(p.tags, ' ')},`)
|
|
695
691
|
lines.push(p.tasks.length === 0 ? ' tasks: {},' : ' tasks: {')
|
|
696
692
|
for (const t of p.tasks) {
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
gitSpawnRefusal,
|
|
16
16
|
isExecutableMissing,
|
|
17
17
|
isOutOfFds,
|
|
18
|
+
refusal,
|
|
18
19
|
relPosix,
|
|
19
20
|
UserError,
|
|
20
21
|
} from '../util/index.js'
|
|
@@ -75,10 +76,10 @@ export async function findWorkspaceRoot(
|
|
|
75
76
|
start = path.resolve(start)
|
|
76
77
|
const file = await workspaceFile(start)
|
|
77
78
|
if (file === null) {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
'VX_E_NO_WORKSPACE',
|
|
79
|
+
const headline = `no .vx/workspace.ts in ${start} or a parent${(await gitTop(start)) === null ? '' : ' up to the git root'}`
|
|
80
|
+
throw refusal(
|
|
81
|
+
{ headline, fix: 'vx init' },
|
|
82
|
+
{ code: 'VX_E_NO_WORKSPACE', message: `${headline} — run \`vx init\` to create one` },
|
|
82
83
|
)
|
|
83
84
|
}
|
|
84
85
|
const root = workspaceRootOf(file)
|