@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.
- package/docs/caching.md +41 -14
- package/docs/cli.md +115 -73
- package/docs/execution.md +1 -1
- package/docs/features.md +6 -5
- package/docs/schema.md +24 -15
- package/package.json +5 -5
- package/schemas/plan.json +2 -2
- package/skills/vx/SKILL.md +13 -2
- package/src/bin.ts +13 -7
- package/src/cache/cache.ts +21 -2
- package/src/cache/chained-cache.ts +4 -0
- package/src/cache/index.ts +9 -1
- package/src/cache/inputs.ts +0 -24
- package/src/cache/layer.ts +7 -1
- package/src/cache/layered-cache.ts +4 -0
- package/src/cache/plugin-cache.ts +248 -0
- package/src/cache/policy.ts +9 -0
- package/src/cache/run-history.ts +68 -0
- package/src/cache/run-store.ts +77 -0
- package/src/cache/schema.ts +38 -9
- package/src/cache/scoped-cache.ts +15 -2
- package/src/cli/cache.ts +33 -16
- package/src/cli/completions.ts +9 -5
- package/src/cli/help.ts +16 -7
- package/src/cli/index.ts +25 -11
- package/src/cli/info.ts +6 -5
- package/src/cli/init.ts +14 -15
- package/src/cli/last.ts +14 -7
- package/src/cli/lock.ts +39 -27
- package/src/cli/plan-format.ts +5 -3
- package/src/cli/plugin-commands.ts +77 -6
- package/src/cli/plugin-templates.ts +1 -1
- package/src/cli/run.ts +14 -12
- package/src/cli/select.ts +18 -9
- package/src/cli/show.ts +66 -40
- package/src/cli/task-verb.ts +4 -4
- package/src/cli/upgrade.ts +20 -11
- package/src/cli/watch-filter.ts +7 -2
- package/src/cli/watch-judge.ts +6 -1
- package/src/cli/watch.ts +44 -20
- package/src/cli/why.ts +10 -1
- package/src/cli/workspace-config.ts +2 -2
- package/src/graph/scheduler.ts +8 -0
- package/src/graph/task-graph.ts +32 -4
- package/src/index.ts +11 -1
- package/src/orchestrator/affected-tasks.ts +8 -3
- package/src/orchestrator/doctor.ts +1 -1
- package/src/orchestrator/execute-task.ts +26 -43
- package/src/orchestrator/failure-mode.ts +19 -10
- package/src/orchestrator/framed-output.ts +1 -2
- package/src/orchestrator/history.ts +60 -4
- package/src/orchestrator/index.ts +2 -1
- package/src/orchestrator/logger.ts +9 -2
- package/src/orchestrator/metrics.ts +7 -3
- package/src/orchestrator/plugin-host.ts +14 -4
- package/src/orchestrator/plugin.ts +80 -8
- package/src/orchestrator/prepare.ts +51 -7
- package/src/orchestrator/projects.ts +3 -0
- package/src/orchestrator/remote-prefetch.ts +9 -5
- package/src/orchestrator/run-records.ts +1 -1
- package/src/orchestrator/run.ts +126 -27
- package/src/orchestrator/status-line.ts +1 -1
- package/src/orchestrator/summary.ts +69 -44
- package/src/orchestrator/telemetry.ts +1 -1
- package/src/settings.ts +12 -1
- package/src/{orchestrator → util}/colors.ts +5 -0
- package/src/util/config-frame.ts +31 -2
- package/src/util/errors.ts +32 -0
- package/src/util/index.ts +8 -1
- package/src/util/refusal-notice.ts +111 -0
- package/src/util/report.ts +31 -0
- package/src/util/ui.ts +186 -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
|
@@ -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
|
-
|
|
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
|
|