@vzn/vx 0.0.637 → 0.0.638

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.
Files changed (76) hide show
  1. package/docs/caching.md +41 -14
  2. package/docs/cli.md +115 -73
  3. package/docs/execution.md +1 -1
  4. package/docs/features.md +6 -5
  5. package/docs/schema.md +24 -15
  6. package/package.json +5 -5
  7. package/schemas/plan.json +2 -2
  8. package/skills/vx/SKILL.md +13 -2
  9. package/src/bin.ts +13 -7
  10. package/src/cache/cache.ts +21 -2
  11. package/src/cache/chained-cache.ts +4 -0
  12. package/src/cache/index.ts +9 -1
  13. package/src/cache/inputs.ts +0 -24
  14. package/src/cache/layer.ts +7 -1
  15. package/src/cache/layered-cache.ts +4 -0
  16. package/src/cache/plugin-cache.ts +248 -0
  17. package/src/cache/policy.ts +9 -0
  18. package/src/cache/run-history.ts +68 -0
  19. package/src/cache/run-store.ts +77 -0
  20. package/src/cache/schema.ts +38 -9
  21. package/src/cache/scoped-cache.ts +15 -2
  22. package/src/cli/cache.ts +33 -16
  23. package/src/cli/completions.ts +9 -5
  24. package/src/cli/help.ts +16 -7
  25. package/src/cli/index.ts +25 -11
  26. package/src/cli/info.ts +6 -5
  27. package/src/cli/init.ts +14 -15
  28. package/src/cli/last.ts +14 -7
  29. package/src/cli/lock.ts +39 -27
  30. package/src/cli/plan-format.ts +5 -3
  31. package/src/cli/plugin-commands.ts +77 -6
  32. package/src/cli/plugin-templates.ts +1 -1
  33. package/src/cli/run.ts +14 -12
  34. package/src/cli/select.ts +18 -9
  35. package/src/cli/show.ts +66 -40
  36. package/src/cli/task-verb.ts +4 -4
  37. package/src/cli/upgrade.ts +20 -11
  38. package/src/cli/watch-filter.ts +7 -2
  39. package/src/cli/watch-judge.ts +6 -1
  40. package/src/cli/watch.ts +44 -20
  41. package/src/cli/why.ts +10 -1
  42. package/src/cli/workspace-config.ts +2 -2
  43. package/src/graph/scheduler.ts +8 -0
  44. package/src/graph/task-graph.ts +32 -4
  45. package/src/index.ts +11 -1
  46. package/src/orchestrator/affected-tasks.ts +8 -3
  47. package/src/orchestrator/doctor.ts +1 -1
  48. package/src/orchestrator/execute-task.ts +26 -43
  49. package/src/orchestrator/failure-mode.ts +19 -10
  50. package/src/orchestrator/framed-output.ts +1 -2
  51. package/src/orchestrator/history.ts +60 -4
  52. package/src/orchestrator/index.ts +2 -1
  53. package/src/orchestrator/logger.ts +9 -2
  54. package/src/orchestrator/metrics.ts +7 -3
  55. package/src/orchestrator/plugin-host.ts +14 -4
  56. package/src/orchestrator/plugin.ts +80 -8
  57. package/src/orchestrator/prepare.ts +51 -7
  58. package/src/orchestrator/projects.ts +3 -0
  59. package/src/orchestrator/remote-prefetch.ts +9 -5
  60. package/src/orchestrator/run-records.ts +1 -1
  61. package/src/orchestrator/run.ts +126 -27
  62. package/src/orchestrator/status-line.ts +1 -1
  63. package/src/orchestrator/summary.ts +69 -44
  64. package/src/orchestrator/telemetry.ts +1 -1
  65. package/src/settings.ts +12 -1
  66. package/src/{orchestrator → util}/colors.ts +5 -0
  67. package/src/util/config-frame.ts +31 -2
  68. package/src/util/errors.ts +32 -0
  69. package/src/util/index.ts +8 -1
  70. package/src/util/refusal-notice.ts +111 -0
  71. package/src/util/report.ts +31 -0
  72. package/src/util/ui.ts +186 -0
  73. package/src/workspace/affected.ts +20 -23
  74. package/src/workspace/dependency-rules.ts +12 -4
  75. package/src/workspace/migration.ts +87 -91
  76. package/src/workspace/workspace.ts +5 -4
@@ -0,0 +1,111 @@
1
+ // Every refusal vx prints, as one `ui.notice`: the parts a refusal
2
+ // carries, a config refusal's frame, or a plain message laid out a fact a
3
+ // line. The flat message stays what `--format json` and logs carry.
4
+
5
+ import { configRefusal } from './config-frame.js'
6
+ import type { RefusalParts } from './errors.js'
7
+ import { notice, print, type Block, type Inline, type Tone } from './ui.js'
8
+
9
+ // A sentence break before a capital, a backtick or a paren: vx joins
10
+ // its facts with these.
11
+ const BREAK = /(?<=[.?!])\s+(?=[A-Z`(])/
12
+ // `(see \`vx run --help\`)`: where to read more.
13
+ const SEE = /\s*\(see `([^`]+)`\)/
14
+ // `(did you mean --concurrency?)`, `. Did you mean build?`: the fix.
15
+ const MEANT = /\s*(?:\(did you mean ([^?]+)\?\)|(?:^|[.;—]\s*)[Dd]id you mean ([^?]+)\?)/
16
+
17
+ const bare = (s: string): string => s.trim().replace(/[.:]$/, '')
18
+
19
+ /** A plain message in parts: its first sentence heads, each later one is a line. */
20
+ export function messageParts(message: string): RefusalParts {
21
+ const [head = '', ...rest] = message.split('\n')
22
+ let first = head
23
+ const see = SEE.exec(first)
24
+ if (see !== null) first = first.replace(SEE, '')
25
+ const meant = MEANT.exec(first)
26
+ if (meant !== null) first = first.replace(MEANT, '')
27
+ const sentences = first
28
+ .split(BREAK)
29
+ .map(bare)
30
+ .filter((s) => s !== '')
31
+ const [headline = bare(first), ...what] = sentences
32
+ for (const l of rest) {
33
+ if (l.trim() !== '') what.push(...l.replace(/^ {2}/, '').split(BREAK).map(bare))
34
+ }
35
+ const fix = meant !== null ? (meant[1] ?? meant[2])!.trim() : undefined
36
+ return {
37
+ headline,
38
+ ...(what.length > 0 ? { what } : {}),
39
+ ...(see !== null ? { why: `see ${see[1]}` } : {}),
40
+ ...(fix !== undefined ? { fix } : {}),
41
+ }
42
+ }
43
+
44
+ /**
45
+ * `✗ <verb> <headline>` with what is wrong, why, and the fix under it.
46
+ * `headline` set: that is the status line and the refusal's own headline
47
+ * joins what is wrong (`vx lock lock not written`).
48
+ */
49
+ export function refusalNotice(
50
+ verb: string,
51
+ message: string,
52
+ parts?: RefusalParts,
53
+ opts: { headline?: string; cwd?: string } = {},
54
+ ): Block {
55
+ const c = parts === undefined ? configRefusal(message, opts.cwd ?? process.cwd()) : undefined
56
+ if (c !== undefined) {
57
+ const what: Inline[] = [{ style: 'bold', text: c.where }, c.problem]
58
+ return notice({
59
+ tone: 'error',
60
+ verb,
61
+ headline: opts.headline ?? 'config refused',
62
+ what,
63
+ ...(c.allowed !== undefined ? { why: `allowed: ${c.allowed}` } : {}),
64
+ code: c.frame,
65
+ ...(c.fix !== undefined ? { fix: c.fix } : {}),
66
+ })
67
+ }
68
+ const p = parts ?? messageParts(message)
69
+ const what = opts.headline !== undefined ? [p.headline, ...(p.what ?? [])] : (p.what ?? [])
70
+ return notice({
71
+ tone: p.tone ?? 'error',
72
+ verb,
73
+ headline: opts.headline ?? p.headline,
74
+ ...(what.length > 0 ? { what } : {}),
75
+ ...(p.why !== undefined ? { why: p.why } : {}),
76
+ ...(p.fix !== undefined ? { fix: p.fix } : {}),
77
+ })
78
+ }
79
+
80
+ /** A message of `tone` from `verb`, a fact a line. */
81
+ function messageNotice(tone: Tone, verb: string, message: string): Block {
82
+ const p = messageParts(message)
83
+ return notice({
84
+ tone,
85
+ verb,
86
+ headline: p.headline,
87
+ ...(p.what !== undefined ? { what: p.what } : {}),
88
+ ...(p.why !== undefined ? { why: p.why } : {}),
89
+ ...(p.fix !== undefined ? { fix: p.fix } : {}),
90
+ })
91
+ }
92
+
93
+ /** A line that may open with its verb (`vx why: …`), as `messageNotice` draws it. */
94
+ export function lineNotice(tone: Tone, line: string, verb = 'vx'): Block {
95
+ const named = /^(vx(?: [a-z][\w-]*)?): /.exec(line)
96
+ return messageNotice(
97
+ tone,
98
+ named?.[1] ?? verb,
99
+ named === null ? line : line.slice(named[0].length),
100
+ )
101
+ }
102
+
103
+ /** `messageNotice` on stderr. */
104
+ export function sayNotice(tone: Tone, verb: string, message: string): void {
105
+ print('err', messageNotice(tone, verb, message))
106
+ }
107
+
108
+ /** `lineNotice` on stderr. */
109
+ export function sayLine(tone: Tone, line: string, verb = 'vx'): void {
110
+ print('err', lineNotice(tone, line, verb))
111
+ }
@@ -0,0 +1,31 @@
1
+ import { paint, type ColorSupport } from './colors.js'
2
+
3
+ // Brand gradient for the rule: identity violet → pink (the project /
4
+ // task hues), faded across the dash run. Plain dashes when colors
5
+ // are off.
6
+ const GRADIENT_FROM = [0xa7, 0x8b, 0xfa] as const
7
+ const GRADIENT_TO = [0xf4, 0x72, 0xb6] as const
8
+ // Rule ends flush with the bars: 2 indent + 8 label + 2 gap + 50 cells.
9
+ const RULE_DASHES = 57
10
+
11
+ /** Total visible rule width: `─ <mark> ` + dashes. Shared by the run summary and every report. */
12
+ export function gradientRule(colors: ColorSupport, mark = 'vx'): string {
13
+ const head = `─ `
14
+ const dashes = '─'.repeat(Math.max(8, RULE_DASHES + 2 - mark.length))
15
+ if (!colors.enabled) return `${head}${mark} ${dashes}`
16
+ const chunks = 8
17
+ const per = Math.ceil(RULE_DASHES / chunks)
18
+ let out = ''
19
+ for (let i = 0; i < chunks; i++) {
20
+ const seg = dashes.slice(i * per, (i + 1) * per)
21
+ if (seg.length === 0) break
22
+ const f = i / (chunks - 1)
23
+ const hex = `#${GRADIENT_FROM.map((c, j) =>
24
+ Math.round(c + (GRADIENT_TO[j]! - c) * f)
25
+ .toString(16)
26
+ .padStart(2, '0'),
27
+ ).join('')}`
28
+ out += paint(hex, seg, colors)
29
+ }
30
+ return `${paint(`#${GRADIENT_FROM.map((c) => c.toString(16).padStart(2, '0')).join('')}`, head, colors)}${paint('', mark, colors, { bold: true })} ${out}`
31
+ }
package/src/util/ui.ts ADDED
@@ -0,0 +1,186 @@
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 { detectColors, ERROR, paint, SUCCESS, WARN, type ColorSupport } from './colors.js'
7
+ import { gradientRule } from './report.js'
8
+
9
+ export type Tone = 'ok' | 'warn' | 'error' | 'info'
10
+
11
+ const INFO = '#06b6d4'
12
+ const PROJECT = '#a78bfa'
13
+ const TASK = '#f472b6'
14
+ const HUE: Record<Tone, string> = { ok: SUCCESS, warn: WARN, error: ERROR, info: INFO }
15
+ const SYMBOL: Record<Tone, string> = { ok: '✓', warn: '!', error: '✗', info: '›' }
16
+ const INDENT = ' '
17
+ const LABEL_MIN = 8
18
+
19
+ type Style = Tone | 'dim' | 'bold' | 'id'
20
+
21
+ /** Styled text inside a component: a plain string, a token, or a run of them. */
22
+ export type Inline = string | { readonly style: Style; readonly text: string } | readonly Inline[]
23
+
24
+ /** A laid-out piece of output; `render` and `print` space blocks apart. */
25
+ export interface Block {
26
+ readonly lines: (colors: ColorSupport) => string[]
27
+ }
28
+
29
+ /** A `project#task` id, in the run's project and task hues. */
30
+ export const id = (text: string): Inline => ({ style: 'id', text })
31
+ /** A path, glob or file: bold. */
32
+ export const path = (text: string): Inline => ({ style: 'bold', text })
33
+ /** A command to type: bold. */
34
+ export const cmd = (text: string): Inline => ({ style: 'bold', text })
35
+ /** A secondary fact. */
36
+ export const dim = (text: string): Inline => ({ style: 'dim', text })
37
+ /** Text in a tone's color. */
38
+ export const tone = (t: Tone, text: string): Inline => ({ style: t, text })
39
+
40
+ function inline(x: Inline, c: ColorSupport): string {
41
+ if (typeof x === 'string') return x
42
+ if (Array.isArray(x)) return (x as readonly Inline[]).map((p) => inline(p, c)).join('')
43
+ const { style, text } = x as { style: Style; text: string }
44
+ switch (style) {
45
+ case 'dim':
46
+ return paint('', text, c, { dim: true })
47
+ case 'bold':
48
+ return paint('', text, c, { bold: true })
49
+ case 'id': {
50
+ const hash = text.lastIndexOf('#')
51
+ if (hash < 0) return paint(PROJECT, text, c)
52
+ return paint(PROJECT, text.slice(0, hash), c) + paint(TASK, text.slice(hash), c)
53
+ }
54
+ default:
55
+ return paint(HUE[style], text, c)
56
+ }
57
+ }
58
+
59
+ const block = (lines: (c: ColorSupport) => string[]): Block => ({ lines })
60
+ const lines = (x: Inline, c: ColorSupport): string[] => inline(x, c).split('\n')
61
+ const visible = (s: string): number => Bun.stringWidth(s)
62
+
63
+ /** `✗ vx lock lock not written`: the tone's symbol, the bold verb, the headline. */
64
+ export function status(t: Tone, verb: string, headline: Inline): Block {
65
+ return block((c) => [
66
+ `${paint(HUE[t], SYMBOL[t], c)} ${paint('', verb, c, { bold: true })} ${inline(headline, c)}`,
67
+ ])
68
+ }
69
+
70
+ /** Indented short lines: what is wrong, or what happened. */
71
+ export function text(...items: Inline[]): Block {
72
+ return block((c) => items.flatMap((i) => lines(i, c).map((l) => `${INDENT}${l}`)))
73
+ }
74
+
75
+ /** An indented dim line: why it matters. */
76
+ export function why(line: Inline): Block {
77
+ return block((c) => lines(line, c).map((l) => `${INDENT}${paint('', l, c, { dim: true })}`))
78
+ }
79
+
80
+ /** A label and its values; values after the first sit under it. `null` is a blank line. */
81
+ export type FieldRow = readonly [label: string, ...values: Inline[]] | null
82
+
83
+ /** A dim label column with the values aligned beside it; `next` and `fix` are bold. */
84
+ export function fields(rows: readonly FieldRow[]): Block {
85
+ return block((c) => {
86
+ const width = Math.max(LABEL_MIN, ...rows.map((r) => (r === null ? 0 : r[0].length)))
87
+ const out: string[] = []
88
+ for (const row of rows) {
89
+ if (row === null) {
90
+ out.push('')
91
+ continue
92
+ }
93
+ const [label, ...values] = row
94
+ const strong = label === 'next' || label === 'fix'
95
+ values
96
+ .flatMap((v) => lines(v, c))
97
+ .forEach((v, i) => {
98
+ const head =
99
+ i === 0 ? paint('', label.padEnd(width), c, { dim: true }) : ' '.repeat(width)
100
+ const value = strong ? paint('', v, c, { bold: true }) : v
101
+ out.push(`${INDENT}${head} ${value}`.trimEnd())
102
+ })
103
+ }
104
+ return out
105
+ })
106
+ }
107
+
108
+ /** `fix <action>`: what to do about it. */
109
+ export function fix(action: Inline): Block {
110
+ return labelled('fix', action)
111
+ }
112
+
113
+ /** `next <command>`: the command to type next. */
114
+ export function next(command: Inline): Block {
115
+ return labelled('next', command)
116
+ }
117
+
118
+ function labelled(label: string, value: Inline): Block {
119
+ return block((c) => [
120
+ `${INDENT}${paint('', label, c, { dim: true })} ${paint('', inline(value, c), c, { bold: true })}`,
121
+ ])
122
+ }
123
+
124
+ /** A report: a blank line, the brand rule naming `mark`, then its fields. */
125
+ export function report(mark: string, rows: readonly FieldRow[]): Block {
126
+ return block((c) => ['', gradientRule(c, mark), ...fields(rows).lines(c)])
127
+ }
128
+
129
+ /** Aligned columns under a dim header. */
130
+ export function table(columns: readonly string[], rows: readonly (readonly Inline[])[]): Block {
131
+ return block((c) => {
132
+ const cells = rows.map((r) => r.map((x) => inline(x, c)))
133
+ const widths = columns.map((h, i) =>
134
+ Math.max(h.length, ...cells.map((r) => visible(r[i] ?? ''))),
135
+ )
136
+ const pad = (s: string, i: number): string =>
137
+ i === columns.length - 1 ? s : s + ' '.repeat(widths[i]! - visible(s))
138
+ return [
139
+ `${INDENT}${paint('', columns.map((h, i) => pad(h, i)).join(' '), c, { dim: true })}`.trimEnd(),
140
+ ...cells.map((r) => `${INDENT}${r.map(pad).join(' ')}`.trimEnd()),
141
+ ]
142
+ })
143
+ }
144
+
145
+ /** A source frame (`> 1 | …` and its caret), indented. */
146
+ export function code(frame: string): Block {
147
+ return block(() => frame.split('\n').map((l) => (l === '' ? '' : `${INDENT}${l}`)))
148
+ }
149
+
150
+ export interface NoticeParts {
151
+ tone: Tone
152
+ verb: string
153
+ headline: Inline
154
+ /** What is wrong, one fact a line. */
155
+ what?: readonly Inline[]
156
+ why?: Inline
157
+ /** A source frame, below the facts. */
158
+ code?: string
159
+ fix?: Inline
160
+ }
161
+
162
+ /** A refusal or a warning: status, then what and why, the frame, and the fix, a blank line apart. */
163
+ export function notice(p: NoticeParts): Block {
164
+ return block((c) => {
165
+ const parts: Block[] = [status(p.tone, p.verb, p.headline)]
166
+ const body = [
167
+ ...(p.what !== undefined ? [text(...p.what)] : []),
168
+ ...(p.why !== undefined ? [why(p.why)] : []),
169
+ ]
170
+ if (body.length > 0) parts.push(block((cc) => body.flatMap((b) => b.lines(cc))))
171
+ if (p.code !== undefined) parts.push(code(p.code))
172
+ if (p.fix !== undefined) parts.push(fix(p.fix))
173
+ return parts.flatMap((b, i) => (i === 0 ? b.lines(c) : ['', ...b.lines(c)]))
174
+ })
175
+ }
176
+
177
+ /** The blocks as text, a blank line apart, ending in a newline. */
178
+ export function render(blocks: readonly Block[], colors: ColorSupport): string {
179
+ return `${blocks.map((b) => b.lines(colors).join('\n')).join('\n\n')}\n`
180
+ }
181
+
182
+ /** Write the blocks to stdout (`out`) or stderr (`err`), colored for that stream. */
183
+ export function print(stream: 'out' | 'err', ...blocks: Block[]): void {
184
+ const s = stream === 'out' ? process.stdout : process.stderr
185
+ s.write(render(blocks, detectColors(s)))
186
+ }
@@ -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
- return { projects: all, changed, paths: new Map(), whole: all }
276
+ const wholeBy = new Map([...all].map((n) => [n, file]))
277
+ return { projects: all, changed, paths: new Map(), whole: all, wholeBy }
275
278
  }
276
- if (workspaceConfigChanged(changed)) return everything()
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
- if (changed.some((p) => patches.has(p)) && (await loadClaims())?.files.has('bun.lock')) {
291
- return everything()
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 Set<string>()
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.add(name)
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 { UserError } from '../util/index.js'
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
- 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',
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