@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
|
@@ -13,8 +13,15 @@ const RESET = '\x1b[0m'
|
|
|
13
13
|
const BOLD = '\x1b[1m'
|
|
14
14
|
const DIM = '\x1b[2m'
|
|
15
15
|
|
|
16
|
+
// State hues every report shares with the run summary.
|
|
17
|
+
export const SUCCESS = '#22c55e'
|
|
18
|
+
export const WARN = '#eab308'
|
|
19
|
+
export const ERROR = '#ef4444'
|
|
20
|
+
|
|
16
21
|
export interface ColorSupport {
|
|
17
22
|
enabled: boolean
|
|
23
|
+
/** The terminal's width, read live; none off a terminal. Bars and rules fit it. */
|
|
24
|
+
readonly columns?: number | undefined
|
|
18
25
|
}
|
|
19
26
|
|
|
20
27
|
export interface PaintOptions {
|
|
@@ -27,16 +34,32 @@ export interface PaintOptions {
|
|
|
27
34
|
* NO_COLOR non-empty → off (overrides FORCE_COLOR)
|
|
28
35
|
* FORCE_COLOR=0 or =false → off, even on a TTY
|
|
29
36
|
* FORCE_COLOR any other value → on (the empty string too)
|
|
30
|
-
* else: on iff stdout is a TTY
|
|
37
|
+
* else: on iff stdout is a TTY that is not `TERM=dumb`.
|
|
31
38
|
* `0` and `false` are the convention's "off" (supports-color, chalk,
|
|
32
39
|
* Node); reading every non-empty value as on painted a piped CI log that
|
|
33
40
|
* set `FORCE_COLOR=0` (nx#35292's bug).
|
|
34
41
|
*/
|
|
35
42
|
export function detectColors(stream: NodeJS.WriteStream = process.stdout): ColorSupport {
|
|
36
|
-
if (process.env['NO_COLOR']) return { enabled: false }
|
|
37
43
|
const force = process.env['FORCE_COLOR']
|
|
38
|
-
|
|
39
|
-
|
|
44
|
+
const enabled = process.env['NO_COLOR']
|
|
45
|
+
? false
|
|
46
|
+
: force !== undefined
|
|
47
|
+
? force !== '0' && force !== 'false'
|
|
48
|
+
: stream.isTTY === true && !dumbTerminal()
|
|
49
|
+
return {
|
|
50
|
+
enabled,
|
|
51
|
+
get columns() {
|
|
52
|
+
return stream.isTTY === true ? stream.columns : undefined
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* `TERM=dumb` (Emacs `M-x shell`, some CI log viewers) can neither paint
|
|
59
|
+
* nor move the cursor: vx prints to it as off a terminal.
|
|
60
|
+
*/
|
|
61
|
+
export function dumbTerminal(): boolean {
|
|
62
|
+
return process.env['TERM'] === 'dumb'
|
|
40
63
|
}
|
|
41
64
|
|
|
42
65
|
// Bun.color() is pure for a given (hex, mode) pair but does FFI work
|
|
@@ -66,3 +89,25 @@ export function paint(
|
|
|
66
89
|
if (prefix.length === 0) return text
|
|
67
90
|
return `${prefix}${text}${RESET}`
|
|
68
91
|
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Identity hues for the two halves of a `project#task` id, never a status
|
|
95
|
+
* colour: one name, one hue, in every run, list and row (M, 2026-10-15:
|
|
96
|
+
* every `#build` matches every other `#build`).
|
|
97
|
+
*/
|
|
98
|
+
const IDENTITY_PALETTE = [
|
|
99
|
+
'#a78bfa', // violet-400
|
|
100
|
+
'#60a5fa', // blue-400
|
|
101
|
+
'#e879f9', // fuchsia-400
|
|
102
|
+
'#818cf8', // indigo-400
|
|
103
|
+
'#c084fc', // purple-400
|
|
104
|
+
'#93c5fd', // blue-300
|
|
105
|
+
'#f472b6', // pink-400
|
|
106
|
+
] as const
|
|
107
|
+
|
|
108
|
+
/** A project's or a target's hue: a fold of the name into the palette. */
|
|
109
|
+
export function identityColor(name: string): string {
|
|
110
|
+
let h = 0
|
|
111
|
+
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0
|
|
112
|
+
return IDENTITY_PALETTE[h % IDENTITY_PALETTE.length]!
|
|
113
|
+
}
|
package/src/util/config-frame.ts
CHANGED
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
import { readFileSync } from 'node:fs'
|
|
8
8
|
import path from 'node:path'
|
|
9
9
|
|
|
10
|
-
// `<file>: tasks.<path>[:] …`, or a top-level `<file> has unknown field
|
|
11
|
-
|
|
10
|
+
// `<file>: tasks.<path>[:] …`, or a top-level `<file> has unknown field …`,
|
|
11
|
+
// from a project's vx.config or the workspace's .vx/workspace.ts.
|
|
12
|
+
const HEAD = /^(\/[^\n]*?\/(?:vx\.config|\.vx\/workspace)\.[cm]?[jt]s)(?:: (tasks\.[^\s:]+)(:?))? /
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* The message with its file named at `line:col`, relative to `cwd`, and a
|
|
@@ -88,3 +89,31 @@ function locate(
|
|
|
88
89
|
function escapeRe(s: string): string {
|
|
89
90
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
90
91
|
}
|
|
92
|
+
|
|
93
|
+
/** A config refusal laid out for `ui.notice`: where, what, the allowed set, the fix, the frame. */
|
|
94
|
+
export interface ConfigRefusal {
|
|
95
|
+
where: string
|
|
96
|
+
problem: string
|
|
97
|
+
allowed?: string
|
|
98
|
+
fix?: string
|
|
99
|
+
frame: string
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** `configErrorFrame`'s answer in parts, or undefined when it has none. */
|
|
103
|
+
export function configRefusal(message: string, cwd: string): ConfigRefusal | undefined {
|
|
104
|
+
const framed = configErrorFrame(message, cwd)
|
|
105
|
+
if (framed === undefined) return undefined
|
|
106
|
+
const cut = framed.indexOf('\n\n')
|
|
107
|
+
const line = framed.slice(0, cut)
|
|
108
|
+
const frame = framed.slice(cut + 2)
|
|
109
|
+
const at = /^(\S+?:\d+:\d+):? (.*)$/s.exec(line)
|
|
110
|
+
if (at === null) return { where: line, problem: '', frame }
|
|
111
|
+
const said = /^(.*?)(?: \(allowed: ([^)]*)\))?(?: — did you mean ([^?]+)\?)?$/s.exec(at[2]!)!
|
|
112
|
+
return {
|
|
113
|
+
where: at[1]!,
|
|
114
|
+
problem: said[1]!.replace(/^has /, ''),
|
|
115
|
+
...(said[2] !== undefined ? { allowed: said[2] } : {}),
|
|
116
|
+
...(said[3] !== undefined ? { fix: `use ${said[3]}` } : {}),
|
|
117
|
+
frame,
|
|
118
|
+
}
|
|
119
|
+
}
|
package/src/util/errors.ts
CHANGED
|
@@ -13,6 +13,8 @@ export class UserError extends Error {
|
|
|
13
13
|
* does not. `VX_E_REFUSED` is any refusal without a code of its own.
|
|
14
14
|
*/
|
|
15
15
|
readonly code: string
|
|
16
|
+
/** The refusal laid out for a terminal (`refusal`); the message is the same in one line. */
|
|
17
|
+
parts?: RefusalParts
|
|
16
18
|
// Two signatures, not an optional parameter: the package-api record
|
|
17
19
|
// keeps the one-argument form every plugin already calls.
|
|
18
20
|
constructor(message: string)
|
|
@@ -24,6 +26,36 @@ export class UserError extends Error {
|
|
|
24
26
|
}
|
|
25
27
|
}
|
|
26
28
|
|
|
29
|
+
/** A refusal in parts, as `ui.notice` draws it: one fact a line, why, and the fix. */
|
|
30
|
+
export interface RefusalParts {
|
|
31
|
+
/** `info`: nothing is wrong, there is just nothing to show yet. Default `error`. */
|
|
32
|
+
readonly tone?: 'info'
|
|
33
|
+
readonly headline: string
|
|
34
|
+
readonly what?: readonly string[]
|
|
35
|
+
readonly why?: string
|
|
36
|
+
readonly fix?: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A UserError whose terminal form is `parts`. Its message, what JSON and
|
|
41
|
+
* logs carry, is `message` when given, else the parts in one line.
|
|
42
|
+
*/
|
|
43
|
+
export function refusal(
|
|
44
|
+
parts: RefusalParts,
|
|
45
|
+
opts: { code?: string; message?: string } = {},
|
|
46
|
+
): UserError {
|
|
47
|
+
const message =
|
|
48
|
+
opts.message ??
|
|
49
|
+
[
|
|
50
|
+
[parts.headline, ...(parts.what ?? [])].join(': '),
|
|
51
|
+
...(parts.why !== undefined ? [parts.why] : []),
|
|
52
|
+
...(parts.fix !== undefined ? [parts.fix] : []),
|
|
53
|
+
].join('; ')
|
|
54
|
+
const err = new UserError(message, opts.code ?? 'VX_E_REFUSED')
|
|
55
|
+
err.parts = parts
|
|
56
|
+
return err
|
|
57
|
+
}
|
|
58
|
+
|
|
27
59
|
// A refusal that opens with the config that caused it: the schema's
|
|
28
60
|
// `<file>: tasks.<name> …` and the loader's `<file> has unknown field …`.
|
|
29
61
|
const CONFIG_REFUSAL = /^\/\S*\/(?:vx\.config|\.vx\/workspace)\.[cm]?[jt]s\b/
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// Whether git ignores everything an output glob can write (M, 2026-10-15):
|
|
2
|
+
// a key reads only what git lists, so such an output never reaches a
|
|
3
|
+
// reader's key. Asked of git, never read from a `.gitignore`: git alone
|
|
4
|
+
// knows every rule source (nested files, `info/exclude`, `core.excludesFile`)
|
|
5
|
+
// and that a directory holding a tracked file is not ignored.
|
|
6
|
+
|
|
7
|
+
import { gitEnv } from './hide-env.js'
|
|
8
|
+
import { isLiteralPattern, normalizeGlob } from './paths.js'
|
|
9
|
+
import { executablePath } from './which.js'
|
|
10
|
+
|
|
11
|
+
/** A directory name no ignore rule names, standing for one a task may create. */
|
|
12
|
+
const FRESH = '__vx_probe__'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The paths git is asked about for a root-relative output glob: when git
|
|
16
|
+
* ignores each, it ignores every untracked path the glob can match. `deep`:
|
|
17
|
+
* the glob reaches below them (a `dist` at any depth is asked as `dist/` and
|
|
18
|
+
* a fresh `__vx_probe__/dist/`), so a listed file it matches must be looked
|
|
19
|
+
* for too (a tracked one, or one a `!` rule takes back). Null when no few paths
|
|
20
|
+
* stand for the glob (`*.js`): undecided, the output overlaps as before.
|
|
21
|
+
* `dir`: the glob's literal directory, where a listed file it matches lies.
|
|
22
|
+
*/
|
|
23
|
+
export function ignoreProbes(glob: string): { paths: string[]; deep: boolean; dir: string } | null {
|
|
24
|
+
const segs = normalizeGlob(glob).split('/')
|
|
25
|
+
let i = 0
|
|
26
|
+
while (i < segs.length && isLiteralPattern(segs[i]!)) i++
|
|
27
|
+
const lit = segs.slice(0, i).join('/')
|
|
28
|
+
if (i === segs.length) return { paths: [lit], deep: false, dir: lit }
|
|
29
|
+
const at = lit === '' ? '' : `${lit}/`
|
|
30
|
+
const name = segs[i + 1]
|
|
31
|
+
if (segs[i] === '**' && name !== undefined && isLiteralPattern(name)) {
|
|
32
|
+
const leaf = i + 2 < segs.length ? `${name}/` : name
|
|
33
|
+
return { paths: [at + leaf, `${at}${FRESH}/${leaf}`], deep: true, dir: lit }
|
|
34
|
+
}
|
|
35
|
+
return lit === '' ? null : { paths: [`${lit}/`], deep: false, dir: lit }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const CHECK_IGNORE = ['check-ignore', '--stdin', '-z']
|
|
39
|
+
|
|
40
|
+
function ignoredOf(exitCode: number, stdout: Uint8Array): Set<string> | null {
|
|
41
|
+
// 0: some ignored; 1: none; anything else (no work tree): undecided.
|
|
42
|
+
if (exitCode === 1) return new Set()
|
|
43
|
+
if (exitCode !== 0) return null
|
|
44
|
+
return new Set(
|
|
45
|
+
new TextDecoder()
|
|
46
|
+
.decode(stdout)
|
|
47
|
+
.split('\0')
|
|
48
|
+
.filter((p) => p !== ''),
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The `paths` (relative to `cwd`) git ignores; null when git cannot say. */
|
|
53
|
+
export async function checkIgnore(
|
|
54
|
+
cwd: string,
|
|
55
|
+
paths: readonly string[],
|
|
56
|
+
): Promise<Set<string> | null> {
|
|
57
|
+
if (paths.length === 0) return new Set()
|
|
58
|
+
try {
|
|
59
|
+
const proc = Bun.spawn({
|
|
60
|
+
cmd: [executablePath('git'), ...CHECK_IGNORE],
|
|
61
|
+
env: gitEnv(),
|
|
62
|
+
cwd,
|
|
63
|
+
stdin: Buffer.from(paths.map((p) => `${p}\0`).join('')),
|
|
64
|
+
stdout: 'pipe',
|
|
65
|
+
stderr: 'ignore',
|
|
66
|
+
})
|
|
67
|
+
const stdout = await new Response(proc.stdout).bytes()
|
|
68
|
+
return ignoredOf(await proc.exited, stdout)
|
|
69
|
+
} catch {
|
|
70
|
+
return null
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** {@link checkIgnore} for `vx lock`, which builds its graph synchronously. */
|
|
75
|
+
export function checkIgnoreSync(cwd: string, paths: readonly string[]): Set<string> | null {
|
|
76
|
+
if (paths.length === 0) return new Set()
|
|
77
|
+
try {
|
|
78
|
+
const r = Bun.spawnSync({
|
|
79
|
+
cmd: [executablePath('git'), ...CHECK_IGNORE],
|
|
80
|
+
env: gitEnv(),
|
|
81
|
+
cwd,
|
|
82
|
+
stdin: Buffer.from(paths.map((p) => `${p}\0`).join('')),
|
|
83
|
+
stdout: 'pipe',
|
|
84
|
+
stderr: 'ignore',
|
|
85
|
+
})
|
|
86
|
+
return ignoredOf(r.exitCode, r.stdout)
|
|
87
|
+
} catch {
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* What git lists under `dirs` (relative to `cwd`): tracked files and the
|
|
94
|
+
* untracked ones it does not ignore, the set a key filters. Null when git
|
|
95
|
+
* cannot say. A run has it from its enumeration; this is `vx lock`'s.
|
|
96
|
+
*/
|
|
97
|
+
export function listedSync(cwd: string, dirs: readonly string[]): string[] | null {
|
|
98
|
+
try {
|
|
99
|
+
const r = Bun.spawnSync({
|
|
100
|
+
cmd: [
|
|
101
|
+
executablePath('git'),
|
|
102
|
+
'ls-files',
|
|
103
|
+
'-z',
|
|
104
|
+
'--cached',
|
|
105
|
+
'--others',
|
|
106
|
+
'--exclude-standard',
|
|
107
|
+
'--',
|
|
108
|
+
...dirs.map((d) => (d === '' ? '.' : `:(literal)${d}`)),
|
|
109
|
+
],
|
|
110
|
+
env: gitEnv(),
|
|
111
|
+
cwd,
|
|
112
|
+
stdout: 'pipe',
|
|
113
|
+
stderr: 'ignore',
|
|
114
|
+
})
|
|
115
|
+
if (r.exitCode !== 0) return null
|
|
116
|
+
return new TextDecoder()
|
|
117
|
+
.decode(r.stdout)
|
|
118
|
+
.split('\0')
|
|
119
|
+
.filter((p) => p !== '')
|
|
120
|
+
} catch {
|
|
121
|
+
return null
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Of `globs` (root-relative), those git ignores whole: each probe ignored
|
|
127
|
+
* and, for a deep glob, no listed file it matches (`listedMatch`).
|
|
128
|
+
*/
|
|
129
|
+
export function wholeIgnored(
|
|
130
|
+
globs: Iterable<string>,
|
|
131
|
+
ignored: ReadonlySet<string> | null,
|
|
132
|
+
listedMatch: (glob: string) => boolean,
|
|
133
|
+
): Set<string> {
|
|
134
|
+
const out = new Set<string>()
|
|
135
|
+
if (ignored === null) return out
|
|
136
|
+
for (const g of globs) {
|
|
137
|
+
const ask = ignoreProbes(g)
|
|
138
|
+
if (ask === null || !ask.paths.every((p) => ignored.has(p))) continue
|
|
139
|
+
if (ask.deep && listedMatch(g)) continue
|
|
140
|
+
out.add(g)
|
|
141
|
+
}
|
|
142
|
+
return out
|
|
143
|
+
}
|
package/src/util/index.ts
CHANGED
|
@@ -13,6 +13,8 @@ export {
|
|
|
13
13
|
isPermissionError,
|
|
14
14
|
isUserError,
|
|
15
15
|
PERMISSION_HINT,
|
|
16
|
+
refusal,
|
|
17
|
+
type RefusalParts,
|
|
16
18
|
UserError,
|
|
17
19
|
gitSpawnRefusal,
|
|
18
20
|
isExecutableMissing,
|
|
@@ -21,7 +23,21 @@ export {
|
|
|
21
23
|
OUT_OF_FDS_HINT,
|
|
22
24
|
wantsJson,
|
|
23
25
|
} from './errors.js'
|
|
24
|
-
export { configErrorFrame } from './config-frame.js'
|
|
26
|
+
export { configErrorFrame, configRefusal } from './config-frame.js'
|
|
27
|
+
export {
|
|
28
|
+
detectColors,
|
|
29
|
+
dumbTerminal,
|
|
30
|
+
ERROR,
|
|
31
|
+
identityColor,
|
|
32
|
+
paint,
|
|
33
|
+
SUCCESS,
|
|
34
|
+
WARN,
|
|
35
|
+
type ColorSupport,
|
|
36
|
+
} from './colors.js'
|
|
37
|
+
export { barWidth, gradientRule } from './report.js'
|
|
38
|
+
import * as ui from './ui.js'
|
|
39
|
+
export { refusalNotice, sayLine, sayNotice } from './refusal-notice.js'
|
|
40
|
+
export { ui }
|
|
25
41
|
export { xxh3, xxh3hex } from './hash.js'
|
|
26
42
|
export {
|
|
27
43
|
beginRun,
|
|
@@ -60,10 +76,9 @@ export {
|
|
|
60
76
|
claimExitForSignal,
|
|
61
77
|
exitClaimedBySignal,
|
|
62
78
|
killGraceMs,
|
|
63
|
-
setSettleBounds,
|
|
64
|
-
type SettleBound,
|
|
65
79
|
settleWithin,
|
|
66
80
|
teardownTimeoutMs,
|
|
81
|
+
testTimerMs,
|
|
67
82
|
} from './settle.js'
|
|
68
83
|
export { formatBytes, parseDuration, parseSize, treeBytes } from './size.js'
|
|
69
84
|
export {
|
|
@@ -84,6 +99,13 @@ export { procfsIsOwn } from './procfs.js'
|
|
|
84
99
|
export { hangupIgnored } from './hangup.js'
|
|
85
100
|
export { ESSENTIAL_ENV } from './essential-env.js'
|
|
86
101
|
export { gitEnv, helperEnv, hideEnvFromTasks } from './hide-env.js'
|
|
102
|
+
export {
|
|
103
|
+
checkIgnore,
|
|
104
|
+
checkIgnoreSync,
|
|
105
|
+
ignoreProbes,
|
|
106
|
+
listedSync,
|
|
107
|
+
wholeIgnored,
|
|
108
|
+
} from './git-ignored.js'
|
|
87
109
|
export { realPath, realpathOf } from './real-path.js'
|
|
88
110
|
export {
|
|
89
111
|
maskedCommand,
|
|
@@ -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,39 @@
|
|
|
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
|
+
// The bars' label column: 2 indent + 8 label + 2 gap.
|
|
9
|
+
const LABEL = 12
|
|
10
|
+
|
|
11
|
+
/** A meter's cells: 50, or fewer so the bar fits a narrower terminal. */
|
|
12
|
+
export function barWidth(colors: ColorSupport): number {
|
|
13
|
+
const columns = colors.columns
|
|
14
|
+
return columns === undefined ? 50 : Math.max(10, Math.min(50, columns - LABEL))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** `─ <mark> ` + dashes, flush with the bars. Shared by the run summary and every report. */
|
|
18
|
+
export function gradientRule(colors: ColorSupport, mark = 'vx'): string {
|
|
19
|
+
const head = `─ `
|
|
20
|
+
const width = LABEL + barWidth(colors)
|
|
21
|
+
const dashes = '─'.repeat(Math.max(8, width - 3 - mark.length))
|
|
22
|
+
if (!colors.enabled) return `${head}${mark} ${dashes}`
|
|
23
|
+
const chunks = 8
|
|
24
|
+
// The gradient spans the rule as if its mark were `vx`.
|
|
25
|
+
const per = Math.ceil((width - 5) / chunks)
|
|
26
|
+
let out = ''
|
|
27
|
+
for (let i = 0; i < chunks; i++) {
|
|
28
|
+
const seg = dashes.slice(i * per, (i + 1) * per)
|
|
29
|
+
if (seg.length === 0) break
|
|
30
|
+
const f = i / (chunks - 1)
|
|
31
|
+
const hex = `#${GRADIENT_FROM.map((c, j) =>
|
|
32
|
+
Math.round(c + (GRADIENT_TO[j]! - c) * f)
|
|
33
|
+
.toString(16)
|
|
34
|
+
.padStart(2, '0'),
|
|
35
|
+
).join('')}`
|
|
36
|
+
out += paint(hex, seg, colors)
|
|
37
|
+
}
|
|
38
|
+
return `${paint(`#${GRADIENT_FROM.map((c) => c.toString(16).padStart(2, '0')).join('')}`, head, colors)}${paint('', mark, colors, { bold: true })} ${out}`
|
|
39
|
+
}
|
package/src/util/settle.ts
CHANGED
|
@@ -7,54 +7,29 @@ import { parseDuration } from './size.js'
|
|
|
7
7
|
/** Default upper bound on one end-of-run flush/teardown await. */
|
|
8
8
|
const DEFAULT_TIMEOUT_MS = 3000
|
|
9
9
|
|
|
10
|
-
/** A bound the run resolved from its flag or `.vx/workspace`. */
|
|
11
|
-
export interface SettleBound {
|
|
12
|
-
readonly ms: number
|
|
13
|
-
readonly from: 'flag' | 'workspace'
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
let teardownSet: SettleBound | undefined
|
|
17
|
-
let killGraceSet: SettleBound | undefined
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* The run's `run.teardownTimeout` and `run.killGrace` from its flag or
|
|
21
|
-
* workspace (the orchestrator resolves them; util sits below settings).
|
|
22
|
-
* The variable, read per call, sits between the two, so a test drives the
|
|
23
|
-
* deadline instead of waiting it out.
|
|
24
|
-
*/
|
|
25
|
-
export function setSettleBounds(b: { teardown?: SettleBound; killGrace?: SettleBound }): void {
|
|
26
|
-
teardownSet = b.teardown
|
|
27
|
-
killGraceSet = b.killGrace
|
|
28
|
-
}
|
|
29
|
-
|
|
30
10
|
/**
|
|
31
|
-
* A
|
|
32
|
-
*
|
|
33
|
-
*
|
|
11
|
+
* A timer a test shortens: `VX_TEST_<name>`, a duration. Undocumented and
|
|
12
|
+
* in no settings table: the suites that prove a deadline drive it instead
|
|
13
|
+
* of waiting the real one out. Zero only where `zero` allows it.
|
|
34
14
|
*/
|
|
35
|
-
function
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
if (n !== null && (n > 0 || zero) && n <= MAX_TIMEOUT_MS) return n
|
|
41
|
-
}
|
|
42
|
-
return set?.ms
|
|
15
|
+
export function testTimerMs(name: string, defaultMs: number, zero = false): number {
|
|
16
|
+
const raw = process.env[`VX_TEST_${name}`]
|
|
17
|
+
if (raw === undefined || raw === '') return defaultMs
|
|
18
|
+
const n = parseDuration(raw)
|
|
19
|
+
return n !== null && (n > 0 || (zero && n === 0)) && n <= MAX_TIMEOUT_MS ? n : defaultMs
|
|
43
20
|
}
|
|
44
21
|
|
|
45
22
|
/**
|
|
46
|
-
* The bound on one end-of-run flush or teardown
|
|
47
|
-
*
|
|
48
|
-
* hold the run's exit hostage, so a timer past the platform's range (which
|
|
49
|
-
* would fire at once and drop every buffered record) is no value.
|
|
23
|
+
* The bound on one end-of-run flush or teardown: a plugin's flush cannot
|
|
24
|
+
* hold the run's exit hostage.
|
|
50
25
|
*/
|
|
51
26
|
export function teardownTimeoutMs(): number {
|
|
52
|
-
return
|
|
27
|
+
return testTimerMs('TEARDOWN_TIMEOUT', DEFAULT_TIMEOUT_MS, true)
|
|
53
28
|
}
|
|
54
29
|
|
|
55
30
|
/**
|
|
56
|
-
* The SIGTERM→SIGKILL grace a run grants a child that ignores SIGTERM
|
|
57
|
-
*
|
|
31
|
+
* The SIGTERM→SIGKILL grace a run grants a child that ignores SIGTERM:
|
|
32
|
+
* the one-shot timeout escalation (`exec/runner.ts`),
|
|
58
33
|
* the signal teardown (`orchestrator/signals.ts`) and the end-of-run
|
|
59
34
|
* persistent shutdown (`orchestrator/persistent.ts`) share it. Two seconds
|
|
60
35
|
* is generous for a real server's cleanup and is what every run waits when
|
|
@@ -63,7 +38,7 @@ export function teardownTimeoutMs(): number {
|
|
|
63
38
|
* 200 ms grace proves too.
|
|
64
39
|
*/
|
|
65
40
|
export function killGraceMs(defaultMs: number): number {
|
|
66
|
-
return
|
|
41
|
+
return testTimerMs('KILL_GRACE', defaultMs)
|
|
67
42
|
}
|
|
68
43
|
|
|
69
44
|
/**
|
package/src/util/tail.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// needs a cap, and the cap has to be honest — a truncated log that reads as
|
|
7
7
|
// complete is worse than one that says what it lost.
|
|
8
8
|
//
|
|
9
|
-
// `
|
|
9
|
+
// `report/logger.ts` is the sole holder: it registers a tail per
|
|
10
10
|
// persistent task at taskStart and keeps one for the rest of the run, so a
|
|
11
11
|
// single mechanism covers both the pre-ready window and everything after.
|
|
12
12
|
// `exec/runner.ts` used to keep a SECOND pre-ready copy fed from the same
|