@vzn/vx 0.0.591 → 0.0.595
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/launcher.cjs +9 -2
- package/package.json +5 -5
- package/src/bin.ts +26 -11
- package/src/cache/archive.ts +18 -7
- package/src/cache/cache.ts +137 -101
- package/src/cache/config-evals.ts +19 -4
- package/src/cache/git-inputs.ts +33 -17
- package/src/cache/inputs.ts +157 -54
- package/src/cache/layered-cache.ts +25 -2
- package/src/cache/tar-stream.ts +4 -2
- package/src/cli/cache.ts +4 -16
- package/src/cli/completions.ts +42 -6
- package/src/cli/foreign-flags.ts +56 -6
- package/src/cli/help.ts +27 -8
- package/src/cli/info.ts +4 -6
- package/src/cli/init.ts +4 -4
- package/src/cli/last.ts +49 -20
- package/src/cli/lock.ts +3 -3
- package/src/cli/plan-format.ts +9 -0
- package/src/cli/plugin-templates.ts +2 -2
- package/src/cli/run.ts +80 -50
- package/src/cli/select.ts +44 -5
- package/src/cli/show.ts +47 -9
- package/src/cli/upgrade.ts +2 -3
- package/src/cli/watch-filter.ts +21 -7
- package/src/cli/watch-fs.ts +32 -8
- package/src/cli/watch-judge.ts +1 -1
- package/src/cli/watch.ts +40 -15
- package/src/cli/why.ts +4 -13
- package/src/cli/workspace-config.ts +1 -1
- package/src/config.ts +5 -5
- package/src/exec/env.ts +8 -5
- package/src/exec/index.ts +1 -0
- package/src/exec/proc-sample.ts +5 -1
- package/src/exec/runner.ts +126 -47
- package/src/exec/sandbox-deny-scan.ts +2 -2
- package/src/exec/sandbox-paths.ts +11 -5
- package/src/exec/sandbox-runtime.ts +89 -23
- package/src/exec/sandbox-violations.ts +23 -9
- package/src/graph/index.ts +1 -0
- package/src/graph/scheduler.ts +14 -4
- package/src/graph/task-graph.ts +3 -2
- package/src/index.ts +9 -0
- package/src/orchestrator/affected-tasks.ts +34 -10
- package/src/orchestrator/doctor.ts +2 -1
- package/src/orchestrator/execute-task.ts +43 -10
- package/src/orchestrator/hit-restore.ts +9 -5
- package/src/orchestrator/index.ts +1 -0
- package/src/orchestrator/local-shortcircuit.ts +4 -1
- package/src/orchestrator/lockfile-claim.ts +45 -49
- package/src/orchestrator/logger.ts +4 -1
- package/src/orchestrator/miss-save.ts +10 -3
- package/src/orchestrator/placement.ts +17 -3
- package/src/orchestrator/plan.ts +25 -1
- package/src/orchestrator/plugin-host.ts +41 -3
- package/src/orchestrator/plugin.ts +11 -14
- package/src/orchestrator/projects.ts +18 -9
- package/src/orchestrator/run-context.ts +2 -2
- package/src/orchestrator/run-lock.ts +14 -4
- package/src/orchestrator/run.ts +94 -16
- package/src/orchestrator/sandbox-request.ts +6 -10
- package/src/orchestrator/signals.ts +5 -2
- package/src/orchestrator/summary.ts +14 -1
- package/src/util/index.ts +7 -1
- package/src/util/secret-mask.ts +36 -0
- package/src/util/settle.ts +15 -0
- package/src/workspace/affected.ts +47 -19
- package/src/workspace/config-cache.ts +25 -4
- package/src/workspace/config-eval.ts +4 -0
- package/src/workspace/config-imports.ts +2 -2
- package/src/workspace/config-schema.ts +47 -10
- package/src/workspace/filter.ts +5 -4
- package/src/workspace/migrate-scripts.ts +23 -6
- package/src/workspace/package-graph.ts +15 -3
- package/src/workspace/project-loader.ts +89 -2
- package/src/workspace/workspace.ts +121 -14
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
type SandboxViolation,
|
|
25
25
|
} from '../exec/index.js'
|
|
26
26
|
import type { TaskNode } from '../graph/index.js'
|
|
27
|
-
import { grantPrefix, UserError } from '../util/index.js'
|
|
27
|
+
import { grantPrefix, relPosix, UserError } from '../util/index.js'
|
|
28
28
|
import { WORKSPACE_FINGERPRINT_FILES } from '../workspace/index.js'
|
|
29
29
|
|
|
30
30
|
/**
|
|
@@ -350,6 +350,7 @@ async function linkedDeps(
|
|
|
350
350
|
keyed: ReadonlySet<string> | undefined,
|
|
351
351
|
): Promise<{ granted: string[]; withheld: WithheldLink[] }> {
|
|
352
352
|
const [self, root] = await Promise.all([realpath(projectDir), realpath(workspaceRoot)])
|
|
353
|
+
const realDirs = dirs.map(toRealPath)
|
|
353
354
|
const scan = async (dir: string, scope: string): Promise<Array<[string, string, string]>> => {
|
|
354
355
|
const found: Array<[target: string, link: string, name: string]> = []
|
|
355
356
|
const entries = await readdir(dir, { withFileTypes: true }).catch(() => [])
|
|
@@ -359,7 +360,7 @@ async function linkedDeps(
|
|
|
359
360
|
const target = await realpath(full).catch(() => undefined)
|
|
360
361
|
if (
|
|
361
362
|
target !== undefined &&
|
|
362
|
-
!
|
|
363
|
+
!realDirs.some((d) => atOrUnder(target, d)) &&
|
|
363
364
|
!atOrUnder(self, target)
|
|
364
365
|
) {
|
|
365
366
|
found.push([target, full, scope + e.name])
|
|
@@ -388,8 +389,8 @@ async function linkedDeps(
|
|
|
388
389
|
withheld.push({
|
|
389
390
|
dir: target,
|
|
390
391
|
name,
|
|
391
|
-
target:
|
|
392
|
-
link:
|
|
392
|
+
target: relPosix(root, target),
|
|
393
|
+
link: relPosix(workspaceRoot, link),
|
|
393
394
|
})
|
|
394
395
|
}
|
|
395
396
|
return { granted, withheld }
|
|
@@ -421,11 +422,6 @@ export function reachedWithheld(
|
|
|
421
422
|
)
|
|
422
423
|
}
|
|
423
424
|
|
|
424
|
-
/** `p` relative to `from`, with `/` separators. */
|
|
425
|
-
function posixRel(from: string, p: string): string {
|
|
426
|
-
return path.relative(from, p).split(path.sep).join('/')
|
|
427
|
-
}
|
|
428
|
-
|
|
429
425
|
/**
|
|
430
426
|
* Ensure each declared write path exists on the host as either an empty
|
|
431
427
|
* file (a literal path) or a directory (a glob's static prefix, or a
|
|
@@ -565,7 +561,7 @@ export function placeholderSweeper(placeholders: readonly Placeholder[]): () =>
|
|
|
565
561
|
* never reddens a pass and never buries a real denial.
|
|
566
562
|
*/
|
|
567
563
|
export function untouchedPlaceholderLine(projectDir: string, placeholder: string): string {
|
|
568
|
-
const rel =
|
|
564
|
+
const rel = relPosix(projectDir, placeholder)
|
|
569
565
|
return (
|
|
570
566
|
`vx: the sandbox write grant \`${rel}\` named nothing on disk, so vx bound it as an empty ` +
|
|
571
567
|
`file, which the task never wrote (removed again). If the task creates a directory there ` +
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// them to the runner around every spawn.
|
|
15
15
|
|
|
16
16
|
import { signalExitCode } from '../exec/index.js'
|
|
17
|
-
import { killGraceMs, settleWithin } from '../util/index.js'
|
|
17
|
+
import { claimExitForSignal, killGraceMs, settleWithin } from '../util/index.js'
|
|
18
18
|
import { holdGroups, killTree, untilGroupsGone } from '../exec/index.js'
|
|
19
19
|
import type { Logger } from './logger.js'
|
|
20
20
|
|
|
@@ -69,7 +69,9 @@ export async function terminateChildren(
|
|
|
69
69
|
signal: ForwardedSignal = 'SIGTERM',
|
|
70
70
|
graceMs: number = killGraceMs(SIGNAL_SHUTDOWN_GRACE_MS),
|
|
71
71
|
): Promise<void> {
|
|
72
|
-
|
|
72
|
+
// A ready server is in both of run()'s lists: signalled twice, it read
|
|
73
|
+
// the second as a repeated Ctrl-C (force quit).
|
|
74
|
+
const children = [...new Set(live())]
|
|
73
75
|
// On the group guard's list until the sweep below is done: a shell that
|
|
74
76
|
// dies on the signal lets its group go while what it forked runs out
|
|
75
77
|
// the grace (kill-tree.ts, `holdGroups`).
|
|
@@ -131,6 +133,7 @@ export function forwardSignals(args: {
|
|
|
131
133
|
// that ended the run.
|
|
132
134
|
if (stopping !== undefined) exit(stopping)
|
|
133
135
|
stopping = signal
|
|
136
|
+
claimExitForSignal()
|
|
134
137
|
// Clear the live worker/status region BEFORE exiting so a TTY isn't
|
|
135
138
|
// left with a frozen region in the scrollback. runEnd is idempotent
|
|
136
139
|
// and a no-op for non-TTY loggers.
|
|
@@ -119,6 +119,8 @@ export interface SummaryStats {
|
|
|
119
119
|
spread: { maxMs: number; minMs: number; sumMs: number; count: number } | null
|
|
120
120
|
/** Tasks an `admit` policy held with a worker free, and the waits summed. Absent with no hold. */
|
|
121
121
|
held?: { count: number; sumMs: number }
|
|
122
|
+
/** A run of groups only: their names, so `0 tasks` says why. */
|
|
123
|
+
emptyGroups?: readonly string[]
|
|
122
124
|
}
|
|
123
125
|
|
|
124
126
|
/**
|
|
@@ -213,7 +215,14 @@ export function formatSummarySection(
|
|
|
213
215
|
// No legend line until the first bucket lands (live: all-gray bar).
|
|
214
216
|
if (taskParts.length > 0) lines.push(legend(taskParts))
|
|
215
217
|
} else {
|
|
216
|
-
|
|
218
|
+
const groups = stats.emptyGroups ?? []
|
|
219
|
+
// `vx run build` where no project gives build a command runs the default
|
|
220
|
+
// group alone: say so, or it reads like a selection that matched nothing.
|
|
221
|
+
const why =
|
|
222
|
+
groups.length > 0
|
|
223
|
+
? ` ${dim('\u00b7')} ${dim(`${groups.join(', ')} ${groups.length === 1 ? 'has' : 'have'} no command in these projects`)}`
|
|
224
|
+
: ''
|
|
225
|
+
lines.push(row('tasks', dim('0 tasks') + why))
|
|
217
226
|
// A stop before any task finished still names what it took down.
|
|
218
227
|
if (taskParts.length > 0) lines.push(legend(taskParts))
|
|
219
228
|
}
|
|
@@ -368,6 +377,9 @@ export function formatRunSummary(
|
|
|
368
377
|
.map((o) => o.durationMs)
|
|
369
378
|
const heldOutcomes = outcomes.filter((o) => o.admissionHeldMs !== undefined)
|
|
370
379
|
const notRun = outcomes.filter((o) => neverStarted(o) && !isGroupTask(o.node)).length
|
|
380
|
+
const emptyGroups = outcomes.every((o) => isGroupTask(o.node))
|
|
381
|
+
? [...new Set(outcomes.map((o) => o.node.taskName))]
|
|
382
|
+
: []
|
|
371
383
|
return formatSummarySection(
|
|
372
384
|
{
|
|
373
385
|
failed: t.failed,
|
|
@@ -398,6 +410,7 @@ export function formatRunSummary(
|
|
|
398
410
|
},
|
|
399
411
|
}
|
|
400
412
|
: {}),
|
|
413
|
+
...(emptyGroups.length > 0 ? { emptyGroups } : {}),
|
|
401
414
|
},
|
|
402
415
|
totalMs,
|
|
403
416
|
colors,
|
package/src/util/index.ts
CHANGED
|
@@ -49,7 +49,13 @@ export {
|
|
|
49
49
|
wholeSubtreePrefixes,
|
|
50
50
|
slashBraceExpansions,
|
|
51
51
|
} from './paths.js'
|
|
52
|
-
export {
|
|
52
|
+
export {
|
|
53
|
+
claimExitForSignal,
|
|
54
|
+
exitClaimedBySignal,
|
|
55
|
+
killGraceMs,
|
|
56
|
+
settleWithin,
|
|
57
|
+
teardownTimeoutMs,
|
|
58
|
+
} from './settle.js'
|
|
53
59
|
export { formatBytes, parseDuration, parseSize } from './size.js'
|
|
54
60
|
export {
|
|
55
61
|
cgroupCpuQuota,
|
package/src/util/secret-mask.ts
CHANGED
|
@@ -49,6 +49,13 @@ export interface SecretMask {
|
|
|
49
49
|
* the next chunk or `end`.
|
|
50
50
|
*/
|
|
51
51
|
stream(): { push(chunk: string): string; end(): string }
|
|
52
|
+
/**
|
|
53
|
+
* `mask` for the two sides of a cut, where the text between them is
|
|
54
|
+
* gone: a value the cut split leaves a piece at the end of `head` or the
|
|
55
|
+
* start of `tail` that no whole-value match sees, so each piece is
|
|
56
|
+
* masked too.
|
|
57
|
+
*/
|
|
58
|
+
maskCut(head: string, tail: string): [string, string]
|
|
52
59
|
}
|
|
53
60
|
|
|
54
61
|
/**
|
|
@@ -103,8 +110,37 @@ export function secretMask(
|
|
|
103
110
|
}
|
|
104
111
|
return longest
|
|
105
112
|
}
|
|
113
|
+
/** The longest head of `text` that is a proper suffix of a value. */
|
|
114
|
+
const partialHead = (text: string): number => {
|
|
115
|
+
let longest = 0
|
|
116
|
+
for (const v of sorted) {
|
|
117
|
+
for (let k = Math.min(v.length - 1, text.length); k > longest; k--) {
|
|
118
|
+
if (text.startsWith(v.slice(v.length - k))) {
|
|
119
|
+
longest = k
|
|
120
|
+
break
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return longest
|
|
125
|
+
}
|
|
106
126
|
return {
|
|
107
127
|
mask,
|
|
128
|
+
maskCut(head, tail) {
|
|
129
|
+
let h = head.length - partialTail(head)
|
|
130
|
+
pattern.lastIndex = 0
|
|
131
|
+
for (let m = pattern.exec(head); m !== null; m = pattern.exec(head)) {
|
|
132
|
+
if (m.index < h && m.index + m[0].length > h) h = m.index
|
|
133
|
+
}
|
|
134
|
+
let t = partialHead(tail)
|
|
135
|
+
pattern.lastIndex = 0
|
|
136
|
+
for (let m = pattern.exec(tail); m !== null; m = pattern.exec(tail)) {
|
|
137
|
+
if (m.index < t && m.index + m[0].length > t) t = m.index + m[0].length
|
|
138
|
+
}
|
|
139
|
+
return [
|
|
140
|
+
mask(head.slice(0, h)) + (h < head.length ? MASKED : ''),
|
|
141
|
+
(t > 0 ? MASKED : '') + mask(tail.slice(t)),
|
|
142
|
+
]
|
|
143
|
+
},
|
|
108
144
|
stream() {
|
|
109
145
|
let carry = ''
|
|
110
146
|
return {
|
package/src/util/settle.ts
CHANGED
|
@@ -68,3 +68,18 @@ export async function settleWithin(p: Promise<unknown>, ms: number): Promise<boo
|
|
|
68
68
|
void p.catch(() => {})
|
|
69
69
|
}
|
|
70
70
|
}
|
|
71
|
+
|
|
72
|
+
// Who ends the process. bin.ts exits once its verb has settled and the
|
|
73
|
+
// streams have ended; a signal's handler exits by itself, with the
|
|
74
|
+
// signal's code, once the run it stopped is done. The verb settles first,
|
|
75
|
+
// with the run's verdict (1), so bin.ts asks before it exits.
|
|
76
|
+
let signalOwnsExit = false
|
|
77
|
+
|
|
78
|
+
/** A signal's handler will end the process: bin.ts leaves the exit to it. */
|
|
79
|
+
export function claimExitForSignal(): void {
|
|
80
|
+
signalOwnsExit = true
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function exitClaimedBySignal(): boolean {
|
|
84
|
+
return signalOwnsExit
|
|
85
|
+
}
|
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
relPosix,
|
|
20
20
|
} from '../util/index.js'
|
|
21
21
|
import { LOCKFILE_NAME } from './lockfile.js'
|
|
22
|
-
import { configImportOwners } from './config-imports.js'
|
|
22
|
+
import { configImportOwners, realpathOr } from './config-imports.js'
|
|
23
23
|
import { configImports } from './config-cache.js'
|
|
24
24
|
import { WORKSPACE_CONFIG_FILENAMES } from './project-loader.js'
|
|
25
25
|
import { bunPatchFiles, WORKSPACE_FINGERPRINT_FILES } from './fingerprint.js'
|
|
@@ -176,6 +176,14 @@ export async function affectedChanges(
|
|
|
176
176
|
`("${since.slice(0, range) || 'HEAD'}"); vx diffs it against the working tree.`,
|
|
177
177
|
)
|
|
178
178
|
}
|
|
179
|
+
// `^main` is rev-list's exclusion, not a ref: merge-base refused it, it
|
|
180
|
+
// verified, and `git diff ^main` diffed from main itself, so changes
|
|
181
|
+
// only main made were selected (`^` is illegal in a ref name).
|
|
182
|
+
if (since.startsWith('^')) {
|
|
183
|
+
throw new UserError(
|
|
184
|
+
`git ref "${since}" is an exclusion, not a ref: pass the base alone ("${since.replace(/^\^+/, '') || 'HEAD'}").`,
|
|
185
|
+
)
|
|
186
|
+
}
|
|
179
187
|
// Diff from the MERGE BASE of `since` and HEAD, not from `since` itself:
|
|
180
188
|
// on a branch whose base has moved on, `git diff <base>` reports every
|
|
181
189
|
// file OTHER people changed on the base (over-selection that defeats a
|
|
@@ -432,7 +440,17 @@ async function dependentsAtBase(
|
|
|
432
440
|
) {
|
|
433
441
|
continue
|
|
434
442
|
}
|
|
435
|
-
|
|
443
|
+
// Today's catalogs, or both graphs would differ on every `catalog:`
|
|
444
|
+
// entry. A catalog edit is a `pnpm-workspace.yaml` or root manifest
|
|
445
|
+
// edit, which the fingerprint widening and the root's own change answer.
|
|
446
|
+
const catalogs = projects[0]?.catalogs
|
|
447
|
+
byDir.set(dir, {
|
|
448
|
+
name: pkg.name,
|
|
449
|
+
dir,
|
|
450
|
+
packageJson: pkg,
|
|
451
|
+
configPath: null,
|
|
452
|
+
...(catalogs === undefined ? {} : { catalogs }),
|
|
453
|
+
})
|
|
436
454
|
if (nameNow.get(dir) !== pkg.name) gone.add(pkg.name)
|
|
437
455
|
}
|
|
438
456
|
const now = buildPackageGraph([...projects])
|
|
@@ -468,8 +486,10 @@ function parentsOfNewNested(
|
|
|
468
486
|
projects: readonly ProjectMeta[],
|
|
469
487
|
): Set<string> {
|
|
470
488
|
const out = new Set<string>()
|
|
489
|
+
// Keyed NFC: macOS git reports paths NFC (core.precomposeunicode) while a
|
|
490
|
+
// dir discovered by readdir keeps the spelling it was created with.
|
|
471
491
|
const dirToName = new Map<string, string>()
|
|
472
|
-
for (const p of projects) dirToName.set(p.dir, p.name)
|
|
492
|
+
for (const p of projects) dirToName.set(p.dir.normalize('NFC'), p.name)
|
|
473
493
|
for (const [rel, bytes] of atBase) {
|
|
474
494
|
const dir = path.resolve(workspaceRoot, path.posix.dirname(rel))
|
|
475
495
|
if (!dirToName.has(dir)) continue
|
|
@@ -570,7 +590,7 @@ async function workspaceConfigChanged(
|
|
|
570
590
|
if (config === undefined) return false
|
|
571
591
|
const root = realpathSync(workspaceRoot)
|
|
572
592
|
for (const file of await configImports(config)) {
|
|
573
|
-
if (set.has(
|
|
593
|
+
if (set.has(relPosix(root, file))) return true
|
|
574
594
|
}
|
|
575
595
|
return false
|
|
576
596
|
}
|
|
@@ -613,7 +633,10 @@ async function bytesOrNull(file: string): Promise<Uint8Array | null> {
|
|
|
613
633
|
/** Run a NUL-separated path-listing git command from the workspace root. */
|
|
614
634
|
async function gitPaths(workspaceRoot: string, cmd: string[]): Promise<string[]> {
|
|
615
635
|
const proc = spawnGit([...cmd], workspaceRoot)
|
|
616
|
-
|
|
636
|
+
// `Response.text()` strips a leading U+FEFF, the first path's own.
|
|
637
|
+
const stdout = new TextDecoder('utf-8', { ignoreBOM: true }).decode(
|
|
638
|
+
await new Response(proc.stdout).bytes(),
|
|
639
|
+
)
|
|
617
640
|
const stderr = await new Response(proc.stderr).text()
|
|
618
641
|
const exit = await proc.exited
|
|
619
642
|
if (exit !== 0) {
|
|
@@ -755,6 +778,17 @@ async function mergeBase(workspaceRoot: string, ref: string): Promise<string> {
|
|
|
755
778
|
const sha = out.trim()
|
|
756
779
|
if (exit === 0 && sha.length > 0) return sha
|
|
757
780
|
await verifyRef(workspaceRoot, ref)
|
|
781
|
+
// `<rev>:<path>` names the tree (or blob) at <path>: diffed against the
|
|
782
|
+
// working tree, its paths miss the <path>/ prefix, and `develop:pkgs`
|
|
783
|
+
// selected projects nothing had changed, green. A root tree
|
|
784
|
+
// (`develop^{tree}`, the empty tree) diffs right and stays a base.
|
|
785
|
+
const sub = /^([^:]*):(?!\/)(.+)$/s.exec(ref)
|
|
786
|
+
if (sub !== null) {
|
|
787
|
+
throw new UserError(
|
|
788
|
+
`git ref "${ref}" names what is at ${sub[2]}, not a commit: vx diffs the whole ` +
|
|
789
|
+
`workspace, so pass the commit alone ("${sub[1] || 'HEAD'}").`,
|
|
790
|
+
)
|
|
791
|
+
}
|
|
758
792
|
return ref
|
|
759
793
|
}
|
|
760
794
|
|
|
@@ -800,14 +834,6 @@ function isDirectory(abs: string): boolean {
|
|
|
800
834
|
}
|
|
801
835
|
}
|
|
802
836
|
|
|
803
|
-
function realpathOr(p: string): string {
|
|
804
|
-
try {
|
|
805
|
-
return realpathSync(p)
|
|
806
|
-
} catch {
|
|
807
|
-
return p
|
|
808
|
-
}
|
|
809
|
-
}
|
|
810
|
-
|
|
811
837
|
function projectsContaining(
|
|
812
838
|
workspaceRoot: string,
|
|
813
839
|
changedRelPaths: readonly string[],
|
|
@@ -823,8 +849,10 @@ function projectsContaining(
|
|
|
823
849
|
// O(files · path-depth) instead of O(files · projects): independent of the
|
|
824
850
|
// project count, which is what a big --affected diff on a 1000-project repo
|
|
825
851
|
// pays for.
|
|
852
|
+
// Keyed NFC: macOS git reports paths NFC (core.precomposeunicode) while a
|
|
853
|
+
// dir discovered by readdir keeps the spelling it was created with.
|
|
826
854
|
const dirToName = new Map<string, string>()
|
|
827
|
-
for (const p of projects) dirToName.set(p.dir, p.name)
|
|
855
|
+
for (const p of projects) dirToName.set(p.dir.normalize('NFC'), p.name)
|
|
828
856
|
// A member linked in from elsewhere in the tree (`pkgs/b -> ../ext/b`) is
|
|
829
857
|
// indexed by its link, and git reports its files at their real place
|
|
830
858
|
// (`ext/b/src/a.txt`), which resolved to no project: an edit there
|
|
@@ -835,18 +863,18 @@ function projectsContaining(
|
|
|
835
863
|
const real = realDirs.get(p.dir) ?? p.dir
|
|
836
864
|
const rel = path.relative(realRoot, real)
|
|
837
865
|
if (rel.startsWith('..') || path.isAbsolute(rel)) continue
|
|
838
|
-
const spelled = path.resolve(workspaceRoot, rel)
|
|
839
|
-
if (
|
|
866
|
+
const spelled = path.resolve(workspaceRoot, rel).normalize('NFC')
|
|
867
|
+
if (!dirToName.has(spelled)) dirToName.set(spelled, p.name)
|
|
840
868
|
}
|
|
841
869
|
const owned = new Set<string>()
|
|
842
870
|
for (const rel of changedRelPaths) {
|
|
843
|
-
let dir = path.resolve(workspaceRoot, rel)
|
|
871
|
+
let dir = path.resolve(workspaceRoot, rel).normalize('NFC')
|
|
844
872
|
let hit = false
|
|
845
873
|
for (;;) {
|
|
846
874
|
const name = dirToName.get(dir)
|
|
847
875
|
if (name !== undefined) {
|
|
848
876
|
owned.add(name)
|
|
849
|
-
on?.path(name, relPosix(dir, path.resolve(workspaceRoot, rel)))
|
|
877
|
+
on?.path(name, relPosix(dir, path.resolve(workspaceRoot, rel).normalize('NFC')))
|
|
850
878
|
hit = true
|
|
851
879
|
break
|
|
852
880
|
}
|
|
@@ -865,7 +893,7 @@ function projectsContaining(
|
|
|
865
893
|
// this, `--affected` after an edit inside selected none of them.
|
|
866
894
|
const abs = path.resolve(workspaceRoot, rel)
|
|
867
895
|
if (isDirectory(abs)) {
|
|
868
|
-
const prefix = abs + path.sep
|
|
896
|
+
const prefix = abs.normalize('NFC') + path.sep
|
|
869
897
|
for (const [dir, name] of dirToName) {
|
|
870
898
|
if (!dir.startsWith(prefix)) continue
|
|
871
899
|
owned.add(name)
|
|
@@ -409,6 +409,16 @@ function keySeed(workspaceFingerprint: string): bigint {
|
|
|
409
409
|
|
|
410
410
|
const EXPLICIT_EXT = /\.(?:m?[jt]s|cjs|cts)$/
|
|
411
411
|
|
|
412
|
+
/**
|
|
413
|
+
* A file's identity as the key folds it. `hashFile` names an executable
|
|
414
|
+
* `100755:<oid>`, the slow path's `hashBytes` the bare oid; the mode moves
|
|
415
|
+
* no evaluation, and with it the warm key of an executable config never
|
|
416
|
+
* met the stored one, so every load read and re-keyed it.
|
|
417
|
+
*/
|
|
418
|
+
function contentIdentity(identity: string): string {
|
|
419
|
+
return identity.startsWith('100755:') ? identity.slice(7) : identity
|
|
420
|
+
}
|
|
421
|
+
|
|
412
422
|
/**
|
|
413
423
|
* The canonical directory Bun resolves `file`'s imports from: its REAL
|
|
414
424
|
* path's, so a config linked in from elsewhere imports its neighbours
|
|
@@ -482,7 +492,7 @@ export async function configEvalKey(a: ConfigEvalKeyArgs): Promise<ConfigEvalKey
|
|
|
482
492
|
const identity = a.hashBytes
|
|
483
493
|
? a.hashBytes(bytes, file)
|
|
484
494
|
: a.hashFile
|
|
485
|
-
? await a.hashFile(file)
|
|
495
|
+
? contentIdentity(await a.hashFile(file))
|
|
486
496
|
: await hashOf(file, bytes)
|
|
487
497
|
h = xxh3(`${file}\0${identity}`, h)
|
|
488
498
|
closure.push(file)
|
|
@@ -542,6 +552,14 @@ export async function configEvalKey(a: ConfigEvalKeyArgs): Promise<ConfigEvalKey
|
|
|
542
552
|
return { key: h.toString(16).padStart(16, '0'), closure, indexable }
|
|
543
553
|
}
|
|
544
554
|
|
|
555
|
+
// `hashFile` names a symlink by its target string (git's mode 120000), not
|
|
556
|
+
// the bytes the evaluation read, so no stored key can match a fold of it.
|
|
557
|
+
// Only a linked config with no relative import is indexed at all; with no
|
|
558
|
+
// warm key it joins the round lookup on its slow key instead of paying a
|
|
559
|
+
// single lookup and an index rewrite per load. The identity is from the
|
|
560
|
+
// lstat `hashFiles` already took: no syscall is added.
|
|
561
|
+
const LINK_IDENTITY = '120000:'
|
|
562
|
+
|
|
545
563
|
/**
|
|
546
564
|
* The warm path: the key for a config whose ordered closure the store
|
|
547
565
|
* remembers, from per-file identities alone — no read, no scan. The fold is
|
|
@@ -561,7 +579,10 @@ export async function configEvalKeyFromClosure(a: {
|
|
|
561
579
|
} catch {
|
|
562
580
|
return null
|
|
563
581
|
}
|
|
564
|
-
|
|
582
|
+
if (identities.some((id) => id.startsWith(LINK_IDENTITY))) return null
|
|
583
|
+
for (let i = 0; i < a.closure.length; i++) {
|
|
584
|
+
h = xxh3(`${a.closure[i]}\0${contentIdentity(identities[i]!)}`, h)
|
|
585
|
+
}
|
|
565
586
|
return h.toString(16).padStart(16, '0')
|
|
566
587
|
}
|
|
567
588
|
|
|
@@ -574,8 +595,8 @@ export function configEvalKeyFromIdentities(a: {
|
|
|
574
595
|
let h = keySeed(a.workspaceFingerprint)
|
|
575
596
|
for (const file of a.closure) {
|
|
576
597
|
const id = a.identities.get(file)
|
|
577
|
-
if (id === undefined) return null
|
|
578
|
-
h = xxh3(`${file}\0${id}`, h)
|
|
598
|
+
if (id === undefined || id.startsWith(LINK_IDENTITY)) return null
|
|
599
|
+
h = xxh3(`${file}\0${contentIdentity(id)}`, h)
|
|
579
600
|
}
|
|
580
601
|
return h.toString(16).padStart(16, '0')
|
|
581
602
|
}
|
|
@@ -292,7 +292,11 @@ function acquireWorker(): Worker {
|
|
|
292
292
|
// config reports the same text it would from an in-process import.
|
|
293
293
|
const err = new Error(msg.message) as Error & { position?: WorkerReply['position'] }
|
|
294
294
|
err.name = msg.name
|
|
295
|
+
// A stackless throw stays stackless: the rebuilt Error's own stack names
|
|
296
|
+
// this file, and `configLoadError` reads a stack naming no file as Bun's
|
|
297
|
+
// JSON loader's.
|
|
295
298
|
if (msg.stack !== null) err.stack = msg.stack
|
|
299
|
+
else delete err.stack
|
|
296
300
|
if (msg.position !== null) err.position = msg.position
|
|
297
301
|
p.reject(err)
|
|
298
302
|
}
|
|
@@ -281,7 +281,7 @@ function tsconfigTarget(spec: string, fromDir: string, memo?: TsconfigMemo): str
|
|
|
281
281
|
}
|
|
282
282
|
|
|
283
283
|
/** A path as Bun names it: realpath'd when it exists. */
|
|
284
|
-
function realpathOr(file: string): string {
|
|
284
|
+
export function realpathOr(file: string): string {
|
|
285
285
|
try {
|
|
286
286
|
return realpathSync(file)
|
|
287
287
|
} catch {
|
|
@@ -305,7 +305,7 @@ function selfReference(pkg: string, fromDir: string): boolean {
|
|
|
305
305
|
const manifest = path.join(dir, 'package.json')
|
|
306
306
|
if (existsSync(manifest)) {
|
|
307
307
|
try {
|
|
308
|
-
const json = JSON.parse(readFileSync(manifest, 'utf8')) as {
|
|
308
|
+
const json = JSON.parse(readFileSync(manifest, 'utf8').replace(/^\uFEFF/, '')) as {
|
|
309
309
|
name?: unknown
|
|
310
310
|
exports?: unknown
|
|
311
311
|
}
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
asTrees,
|
|
29
29
|
isLiteralPattern,
|
|
30
30
|
} from '../util/index.js'
|
|
31
|
+
import { compileNameGlob, parseFilter } from './filter.js'
|
|
31
32
|
import { nonJsonMessage, nonJsonPaths } from './json-data.js'
|
|
32
33
|
|
|
33
34
|
// Mirrors `WorkspaceConfig` in src/config.ts. Unknown keys are REJECTED for
|
|
@@ -77,6 +78,7 @@ const RETENTION_FIELDS = new Set(['olderThan', 'maxSize'])
|
|
|
77
78
|
|
|
78
79
|
function validateRetention(retention: unknown, configPath: string): void {
|
|
79
80
|
const where = `${configPath}: \`cacheRetention\``
|
|
81
|
+
const field = (key: string) => `${configPath}: \`cacheRetention.${key}\``
|
|
80
82
|
if (retention === null || typeof retention !== 'object' || Array.isArray(retention)) {
|
|
81
83
|
throw new UserError(`${where} must be { olderThan?: '30d', maxSize?: '10G' }`)
|
|
82
84
|
}
|
|
@@ -90,23 +92,27 @@ function validateRetention(retention: unknown, configPath: string): void {
|
|
|
90
92
|
olderThan !== undefined &&
|
|
91
93
|
(typeof olderThan !== 'string' || parseDuration(olderThan) === null)
|
|
92
94
|
) {
|
|
93
|
-
throw new UserError(
|
|
95
|
+
throw new UserError(
|
|
96
|
+
`${field('olderThan')} must be a duration like '30d', '12h', '90m' or '45s' (got ${JSON.stringify(olderThan)})`,
|
|
97
|
+
)
|
|
94
98
|
}
|
|
95
99
|
if (maxSize !== undefined && (typeof maxSize !== 'string' || parseSize(maxSize) === null)) {
|
|
96
|
-
throw new UserError(
|
|
100
|
+
throw new UserError(
|
|
101
|
+
`${field('maxSize')} must be a size like '10G', '500MB' or '64KB' (got ${JSON.stringify(maxSize)})`,
|
|
102
|
+
)
|
|
97
103
|
}
|
|
98
104
|
// The bounds `vx cache prune` refuses, for the same reason: each evicts
|
|
99
105
|
// every entry, and here it would do so at the end of every run, the
|
|
100
106
|
// entries that run just saved included, so nothing ever hits (item 969).
|
|
101
107
|
if (olderThan !== undefined && parseDuration(olderThan) === 0) {
|
|
102
|
-
throw new UserError(`${
|
|
108
|
+
throw new UserError(`${field('olderThan')} of 0 evicts every entry after every run`)
|
|
103
109
|
}
|
|
104
110
|
if (maxSize !== undefined && parseSize(maxSize) === 0) {
|
|
105
|
-
throw new UserError(`${
|
|
111
|
+
throw new UserError(`${field('maxSize')} of 0 evicts every entry after every run`)
|
|
106
112
|
}
|
|
107
113
|
if (maxSize !== undefined && /^\d+$/.test(maxSize)) {
|
|
108
114
|
throw new UserError(
|
|
109
|
-
`${
|
|
115
|
+
`${field('maxSize')} '${maxSize}' reads as ${maxSize} bytes — give a unit (e.g. '${maxSize}M', '${maxSize}G')`,
|
|
110
116
|
)
|
|
111
117
|
}
|
|
112
118
|
}
|
|
@@ -229,6 +235,12 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
|
|
|
229
235
|
if (plug.commands === null || typeof plug.commands !== 'object') {
|
|
230
236
|
throw new UserError(`${configPath}: \`plugins[${i}].commands\` must be an object`)
|
|
231
237
|
}
|
|
238
|
+
// A list of commands loaded as the verbs `0`, `1`, … that help listed.
|
|
239
|
+
if (Array.isArray(plug.commands)) {
|
|
240
|
+
throw new UserError(
|
|
241
|
+
`${configPath}: \`plugins[${i}].commands\` must be an object keyed by verb, not an array`,
|
|
242
|
+
)
|
|
243
|
+
}
|
|
232
244
|
for (const [verb, cmd] of Object.entries(plug.commands as Record<string, unknown>)) {
|
|
233
245
|
const c = cmd as { description?: unknown; run?: unknown } | null
|
|
234
246
|
if (
|
|
@@ -344,6 +356,15 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
|
|
|
344
356
|
) {
|
|
345
357
|
throw new UserError(`${configPath}: \`tags\` must be an array of non-empty strings`)
|
|
346
358
|
}
|
|
359
|
+
for (const tag of (tags ?? []) as string[]) {
|
|
360
|
+
const why = tagProblem(tag)
|
|
361
|
+
if (why !== null) {
|
|
362
|
+
throw new UserError(
|
|
363
|
+
`${configPath}: tag ${JSON.stringify(tag)} ${why} — \`--filter tag:<name>\` could not ` +
|
|
364
|
+
`name it. Rename the tag.`,
|
|
365
|
+
)
|
|
366
|
+
}
|
|
367
|
+
}
|
|
347
368
|
const tasks = config.tasks
|
|
348
369
|
if (tasks === undefined) return
|
|
349
370
|
if (typeof tasks !== 'object' || tasks === null || Array.isArray(tasks)) {
|
|
@@ -1286,7 +1307,7 @@ function assertFilterNamesDeclaredDeps(
|
|
|
1286
1307
|
): void {
|
|
1287
1308
|
const declared = (dependsOn ?? []).map(specForm)
|
|
1288
1309
|
const matches = (pattern: string, name: string): boolean =>
|
|
1289
|
-
pattern.includes('*') ?
|
|
1310
|
+
pattern.includes('*') ? compileNameGlob(pattern).test(name) : pattern === name
|
|
1290
1311
|
const named = (f: SpecForm): boolean =>
|
|
1291
1312
|
declared.some((d) => {
|
|
1292
1313
|
if (!matches(d.task, f.task)) return false
|
|
@@ -1366,10 +1387,20 @@ export function taskNameProblem(name: string): string | null {
|
|
|
1366
1387
|
: null
|
|
1367
1388
|
}
|
|
1368
1389
|
|
|
1369
|
-
/**
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1390
|
+
/**
|
|
1391
|
+
* Why `--filter tag:<tag>` cannot name `tag`, or null. Asked of the filter
|
|
1392
|
+
* parser itself, so a suffix it learns to read is refused here too: `v1...`
|
|
1393
|
+
* read as a dependency walk from `v1`, `v[2]` as `v` changed since ref `2`.
|
|
1394
|
+
*/
|
|
1395
|
+
function tagProblem(tag: string): string | null {
|
|
1396
|
+
if (tag.trim() !== tag) return 'has surrounding whitespace'
|
|
1397
|
+
if (tag.includes('*')) return "holds '*', which makes it a pattern"
|
|
1398
|
+
const f = parseFilter(`tag:${tag}`, '/')
|
|
1399
|
+
if (f.gitSince !== undefined) {
|
|
1400
|
+
return `ends in "[${f.gitSince}]", which a filter reads as a git range`
|
|
1401
|
+
}
|
|
1402
|
+
if (f.withDeps || f.onlyDeps) return "ends in '...', which a filter reads as a dependency walk"
|
|
1403
|
+
return null
|
|
1373
1404
|
}
|
|
1374
1405
|
|
|
1375
1406
|
/**
|
|
@@ -1493,6 +1524,12 @@ function assertStringArray(v: unknown, where: string): void {
|
|
|
1493
1524
|
if (!Array.isArray(v) || v.some((s) => typeof s !== 'string' || s.length === 0)) {
|
|
1494
1525
|
throw new UserError(`${where} must be an array of non-empty strings`)
|
|
1495
1526
|
}
|
|
1527
|
+
// No path, host or name holds a NUL; a write grant with one reached the
|
|
1528
|
+
// placeholder create as an internal error.
|
|
1529
|
+
const nul = (v as string[]).find((s) => s.includes('\0'))
|
|
1530
|
+
if (nul !== undefined) {
|
|
1531
|
+
throw new UserError(`${where}: ${JSON.stringify(nul)} holds a NUL byte`)
|
|
1532
|
+
}
|
|
1496
1533
|
}
|
|
1497
1534
|
|
|
1498
1535
|
/**
|
package/src/workspace/filter.ts
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
// given, the base set is "all projects" and excluded packages are removed.
|
|
21
21
|
|
|
22
22
|
import path from 'node:path'
|
|
23
|
-
import { BUN_GLOB_WILDCARDS, UserError } from '../util/index.js'
|
|
23
|
+
import { BUN_GLOB_WILDCARDS, relPosix, UserError } from '../util/index.js'
|
|
24
24
|
import type { PackageGraph } from './package-graph.js'
|
|
25
25
|
import type { ProjectMeta } from './workspace.js'
|
|
26
26
|
|
|
@@ -180,7 +180,7 @@ export function parseFilter(raw: string, workspaceRoot: string): ParsedFilter {
|
|
|
180
180
|
// against the workspace root like the literal form.
|
|
181
181
|
// A member glob, not a task glob: `Bun.Glob`'s alphabet, the class included.
|
|
182
182
|
if (BUN_GLOB_WILDCARDS.test(pathForm)) {
|
|
183
|
-
const rel =
|
|
183
|
+
const rel = relPosix(workspaceRoot, matcher)
|
|
184
184
|
const glob = rel.replace(/\/+$/, '')
|
|
185
185
|
pathGlob = new Bun.Glob(glob)
|
|
186
186
|
if (glob.endsWith('/**')) pathGlobBase = new Bun.Glob(glob.slice(0, -3))
|
|
@@ -293,9 +293,10 @@ function matchSelector(
|
|
|
293
293
|
* Compile a name pattern where `*` is the sole metacharacter and means "any
|
|
294
294
|
* characters" — pnpm's rule. A path glob would treat `/` as a separator, so
|
|
295
295
|
* `*` could never cross the `@scope/` boundary: `--filter '*'` would select
|
|
296
|
-
* only UNSCOPED packages, and `*core*` would match nothing at all.
|
|
296
|
+
* only UNSCOPED packages, and `*core*` would match nothing at all. Task
|
|
297
|
+
* patterns share it: it mirrors the graph's `compileTaskPattern`.
|
|
297
298
|
*/
|
|
298
|
-
function compileNameGlob(pattern: string): RegExp {
|
|
299
|
+
export function compileNameGlob(pattern: string): RegExp {
|
|
299
300
|
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')
|
|
300
301
|
return new RegExp(`^${escaped}$`)
|
|
301
302
|
}
|