@vzn/vx 0.0.349 → 0.0.350
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/package.json +5 -5
- package/src/cache/cache.ts +8 -0
- package/src/cache/git-inputs.ts +4 -1
- package/src/cache/index.ts +1 -0
- package/src/cache/inputs.ts +51 -14
- package/src/exec/sandbox-runtime.ts +23 -12
- package/src/orchestrator/framed-output.ts +13 -0
- package/src/orchestrator/logger.ts +41 -2
- package/src/orchestrator/options.ts +4 -3
- package/src/orchestrator/plugin-host.ts +7 -6
- package/src/orchestrator/prepare.ts +9 -4
- package/src/workspace/affected.ts +34 -1
- package/src/workspace/config-schema.ts +10 -0
- package/src/workspace/migrate-scripts.ts +43 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.350",
|
|
4
4
|
"description": "A fast, correct task runner and build cache for JavaScript monorepos.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -21,10 +21,10 @@
|
|
|
21
21
|
"node": ">=18"
|
|
22
22
|
},
|
|
23
23
|
"optionalDependencies": {
|
|
24
|
-
"@vzn/vx-linux-x64": "0.0.
|
|
25
|
-
"@vzn/vx-linux-arm64": "0.0.
|
|
26
|
-
"@vzn/vx-darwin-x64": "0.0.
|
|
27
|
-
"@vzn/vx-darwin-arm64": "0.0.
|
|
24
|
+
"@vzn/vx-linux-x64": "0.0.350",
|
|
25
|
+
"@vzn/vx-linux-arm64": "0.0.350",
|
|
26
|
+
"@vzn/vx-darwin-x64": "0.0.350",
|
|
27
|
+
"@vzn/vx-darwin-arm64": "0.0.350"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@anthropic-ai/sandbox-runtime": "^0.0.76"
|
package/src/cache/cache.ts
CHANGED
|
@@ -605,6 +605,14 @@ export class Cache implements CacheLayer {
|
|
|
605
605
|
// every open there failed `attempt to write a readonly database`
|
|
606
606
|
// before the not-writable refusal could name the directory (O-10).
|
|
607
607
|
if (!absent) this.db.fileControl(SQLITE_FCNTL_PERSIST_WAL, 1)
|
|
608
|
+
// Any non-negative limit makes the last clean close truncate a
|
|
609
|
+
// persistent `-wal` to zero bytes once it is checkpointed (SQLite's
|
|
610
|
+
// walClose). At the default (-1) it kept every frame, so each later
|
|
611
|
+
// connection recovered them at open and copied them into the
|
|
612
|
+
// database again at its close: 738 frames, 4.5 MB, on a 1,000-project
|
|
613
|
+
// cache. The file stays (O-10 above); 64 MiB is far above what a run
|
|
614
|
+
// writes, so the limit never trims a WAL mid-run.
|
|
615
|
+
this.db.exec('PRAGMA journal_size_limit = 67108864')
|
|
608
616
|
this.db.exec('PRAGMA synchronous = NORMAL')
|
|
609
617
|
this.db.exec('PRAGMA foreign_keys = ON')
|
|
610
618
|
this.db.exec(`
|
package/src/cache/git-inputs.ts
CHANGED
|
@@ -853,6 +853,9 @@ export interface GitEnumeration {
|
|
|
853
853
|
startedAtMs: number
|
|
854
854
|
}
|
|
855
855
|
|
|
856
|
+
/** Above this many project dirs the enumeration walks the whole tree. */
|
|
857
|
+
export const MAX_SCOPED_PATHSPECS = 64
|
|
858
|
+
|
|
856
859
|
/**
|
|
857
860
|
* Pathspec scoping: when the run only needs a handful of projects
|
|
858
861
|
* (scoped config loading), let git scan just those dirs — 75 ms →
|
|
@@ -868,7 +871,7 @@ export function gitPathspecs(
|
|
|
868
871
|
const scoped =
|
|
869
872
|
!workspaceWide &&
|
|
870
873
|
rels.length > 0 &&
|
|
871
|
-
rels.length <=
|
|
874
|
+
rels.length <= MAX_SCOPED_PATHSPECS &&
|
|
872
875
|
rels.every((r) => r !== '' && r !== '.')
|
|
873
876
|
return scoped ? rels : ['.']
|
|
874
877
|
}
|
package/src/cache/index.ts
CHANGED
package/src/cache/inputs.ts
CHANGED
|
@@ -1032,27 +1032,67 @@ function refuseOneAlternativeBrace(
|
|
|
1032
1032
|
}
|
|
1033
1033
|
}
|
|
1034
1034
|
|
|
1035
|
-
|
|
1035
|
+
/** What a `files` declaration and its task's outputs compile to, whatever the project. */
|
|
1036
|
+
interface FilesPlan {
|
|
1037
|
+
positive: string[]
|
|
1038
|
+
negative: string[]
|
|
1039
|
+
excludeGlobs: Bun.Glob[]
|
|
1040
|
+
ownOutput: (rel: string) => boolean
|
|
1041
|
+
positiveGlobs: Bun.Glob[]
|
|
1042
|
+
/** The literal entries, each naming one path (see `unmatchedLiterals`). */
|
|
1043
|
+
literals: string[]
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
/**
|
|
1047
|
+
* Compiled once per declaration for the life of the process: a workspace
|
|
1048
|
+
* declares a handful of `files` lists over thousands of tasks, and
|
|
1049
|
+
* splitting, normalizing and compiling them per task was ~6 ms of a
|
|
1050
|
+
* 1,000-task warm run (I-29). A refused declaration is never stored, so it
|
|
1051
|
+
* throws for every task that carries it.
|
|
1052
|
+
*/
|
|
1053
|
+
const filesPlans = new Map<string, FilesPlan | null>()
|
|
1054
|
+
|
|
1055
|
+
function filesPlan(
|
|
1056
|
+
files: readonly string[] | undefined,
|
|
1057
|
+
ownOutputs: readonly string[],
|
|
1058
|
+
): FilesPlan | null {
|
|
1059
|
+
const key = JSON.stringify([files ?? null, ownOutputs])
|
|
1060
|
+
const memo = filesPlans.get(key)
|
|
1061
|
+
if (memo !== undefined) return memo
|
|
1036
1062
|
const positive: string[] = []
|
|
1037
1063
|
const negative: string[] = []
|
|
1038
|
-
|
|
1039
|
-
if (args.files === undefined) {
|
|
1064
|
+
if (files === undefined) {
|
|
1040
1065
|
positive.push(...DEFAULT_FILE_GLOBS)
|
|
1041
1066
|
} else {
|
|
1042
|
-
refuseOneAlternativeBrace(
|
|
1043
|
-
for (const entry of
|
|
1067
|
+
refuseOneAlternativeBrace(files, 'files')
|
|
1068
|
+
for (const entry of files) {
|
|
1044
1069
|
if (entry.startsWith('!')) negative.push(entry.slice(1))
|
|
1045
1070
|
else positive.push(entry)
|
|
1046
1071
|
}
|
|
1047
1072
|
}
|
|
1073
|
+
const plan =
|
|
1074
|
+
positive.length === 0
|
|
1075
|
+
? null
|
|
1076
|
+
: {
|
|
1077
|
+
positive,
|
|
1078
|
+
negative,
|
|
1079
|
+
excludeGlobs: [...ALWAYS_IGNORE, ...asTrees(negative)].map(globFor),
|
|
1080
|
+
// A path the task's own outputs take back with `!` is no output, so it
|
|
1081
|
+
// stays an input: a tracked file under `dist` the build reads (A-44).
|
|
1082
|
+
ownOutput: outputMatcher(ownOutputs, globFor),
|
|
1083
|
+
positiveGlobs: asTrees(positive).map(globFor),
|
|
1084
|
+
literals: positive.map(normalizeGlob).filter(isLiteralPattern).map(stripTrailingSlash),
|
|
1085
|
+
}
|
|
1086
|
+
filesPlans.set(key, plan)
|
|
1087
|
+
return plan
|
|
1088
|
+
}
|
|
1048
1089
|
|
|
1049
|
-
|
|
1090
|
+
async function resolveFiles(args: ResolveFilesArgs): Promise<string[]> {
|
|
1091
|
+
const plan = filesPlan(args.files, args.ownOutputs)
|
|
1092
|
+
if (plan === null) return []
|
|
1093
|
+
const { positive, negative, excludeGlobs, ownOutput, positiveGlobs } = plan
|
|
1050
1094
|
|
|
1051
1095
|
const nested = inNestedProject(args.projectDir, args.nestedProjectDirs)
|
|
1052
|
-
const excludeGlobs = [...ALWAYS_IGNORE, ...asTrees(negative)].map(globFor)
|
|
1053
|
-
// A path the task's own outputs take back with `!` is no output, so it
|
|
1054
|
-
// stays an input: a tracked file under `dist` the build reads (A-44).
|
|
1055
|
-
const ownOutput = outputMatcher(args.ownOutputs, globFor)
|
|
1056
1096
|
|
|
1057
1097
|
// Defer to git for the file set (Turbo / Nx parity). Nested .gitignore
|
|
1058
1098
|
// files, .git/info/exclude, and global excludes all participate
|
|
@@ -1062,7 +1102,6 @@ async function resolveFiles(args: ResolveFilesArgs): Promise<string[]> {
|
|
|
1062
1102
|
// per task (build + test + lint + …). Spawning git N times for the
|
|
1063
1103
|
// same project per run is wasteful; we cache the result for the
|
|
1064
1104
|
// duration of one orchestrator run.
|
|
1065
|
-
const positiveGlobs = asTrees(positive).map(globFor)
|
|
1066
1105
|
// Everything below the snapshot that decides the result: the project, what
|
|
1067
1106
|
// it declares, what it excludes as its own outputs, and the boundaries.
|
|
1068
1107
|
const memoKey = `${args.projectDir}\0${positive.join('\u0001')}\0${negative.join('\u0001')}\0${args.ownOutputs.join('\u0001')}\0${args.nestedProjectDirs.join('\u0001')}`
|
|
@@ -1102,9 +1141,7 @@ async function resolveFiles(args: ResolveFilesArgs): Promise<string[]> {
|
|
|
1102
1141
|
// an artifact built from an older version of a file the config explicitly
|
|
1103
1142
|
// claims as an input. See the refusal below for why this is not simply
|
|
1104
1143
|
// honoured instead.
|
|
1105
|
-
const unmatchedLiterals = new Set(
|
|
1106
|
-
positive.map(normalizeGlob).filter(isLiteralPattern).map(stripTrailingSlash),
|
|
1107
|
-
)
|
|
1144
|
+
const unmatchedLiterals = new Set(plan.literals)
|
|
1108
1145
|
// First pass: glob-filter to candidate absolute paths (no I/O).
|
|
1109
1146
|
const candidates: string[] = []
|
|
1110
1147
|
for (const rel of gitFiles) {
|
|
@@ -742,20 +742,19 @@ function canonicalBaselines(
|
|
|
742
742
|
* only if it exists in the new root, and otherwise `$HOME`, with no word:
|
|
743
743
|
* a project granted no read ran in the home directory, where `cat x.txt`
|
|
744
744
|
* read `~/x.txt`. A grant at or above the cwd holds it, and so does an
|
|
745
|
-
* existing one below (bwrap builds the path to a bind)
|
|
746
|
-
* deny that IS the cwd (a single-package workspace's anchor). When none does,
|
|
745
|
+
* existing one below (bwrap builds the path to a bind). When none does,
|
|
747
746
|
* the cwd is denied instead: an empty directory the task enters, whose
|
|
748
|
-
* reads are refused and reported as the anchor's are.
|
|
747
|
+
* reads are refused and reported as the anchor's are. A single-package
|
|
748
|
+
* workspace's cwd is its anchor, denied already; the runtime takes the
|
|
749
|
+
* second entry as the same mount.
|
|
749
750
|
*/
|
|
750
751
|
function cwdMounted(
|
|
751
752
|
cwd: string,
|
|
752
753
|
fs: {
|
|
753
754
|
allowRead?: readonly string[] | undefined
|
|
754
755
|
allowWrite?: readonly string[] | undefined
|
|
755
|
-
denyRead?: readonly string[] | undefined
|
|
756
756
|
},
|
|
757
757
|
): boolean {
|
|
758
|
-
if (fs.denyRead?.includes(cwd) === true) return true
|
|
759
758
|
return [...(fs.allowRead ?? []), ...(fs.allowWrite ?? [])].some(
|
|
760
759
|
(p) => atOrUnder(cwd, p) || (atOrUnder(p, cwd) && existsSync(p)),
|
|
761
760
|
)
|
|
@@ -2076,12 +2075,20 @@ async function straceState(): Promise<{ form: false | 'plain' | 'seccomp'; why:
|
|
|
2076
2075
|
} else {
|
|
2077
2076
|
const m = /version (\d+)\.(\d+)/.exec(out)
|
|
2078
2077
|
const [major, minor] = m ? [Number(m[1]), Number(m[2])] : [0, 0]
|
|
2079
|
-
|
|
2080
|
-
|
|
2078
|
+
let form: 'plain' | 'seccomp' = major > 5 || (major === 5 && minor >= 3) ? 'seccomp' : 'plain'
|
|
2079
|
+
let refused = await traceRefusal(form)
|
|
2080
|
+
// A strace that cannot check the seccomp filter says so and traces on
|
|
2081
|
+
// without it, exit 0. Inside the sandbox that line read as the trace
|
|
2082
|
+
// cut short, and every sandboxed task ran twice: the plain form, if
|
|
2083
|
+
// it is quiet, is the one that works here.
|
|
2084
|
+
if (refused?.warned === true && form === 'seccomp') {
|
|
2085
|
+
refused = await traceRefusal('plain')
|
|
2086
|
+
if (refused === null) form = 'plain'
|
|
2087
|
+
}
|
|
2081
2088
|
straceAvailableCache =
|
|
2082
2089
|
refused === null
|
|
2083
2090
|
? { form, why: '' }
|
|
2084
|
-
: { form: false, why: `strace cannot trace here (${refused})` }
|
|
2091
|
+
: { form: false, why: `strace cannot trace here (${refused.line})` }
|
|
2085
2092
|
}
|
|
2086
2093
|
} catch {
|
|
2087
2094
|
// Not on PATH. Said as the refused attach is: without it an undeclared
|
|
@@ -2111,14 +2118,17 @@ export async function untracedReason(): Promise<string | null> {
|
|
|
2111
2118
|
|
|
2112
2119
|
/**
|
|
2113
2120
|
* Why strace may not attach here (null when it may), asked once with the flags a task's trace
|
|
2114
|
-
* uses (`ownGroupCommand`).
|
|
2121
|
+
* uses (`ownGroupCommand`). A strace that exits 0 having said something of
|
|
2122
|
+
* its own is refused too (`warned`): in a task, its line is the retry key. `--version` answers on a host that refuses
|
|
2115
2123
|
* ptrace (Yama's `ptrace_scope` 2 or 3, a container's seccomp profile),
|
|
2116
2124
|
* and there every sandboxed task failed twice, the retry included, on
|
|
2117
2125
|
* `attach: ptrace(PTRACE_SEIZE…): Operation not permitted`. Refused, the
|
|
2118
2126
|
* run goes untraced, as without strace: bwrap still enforces, and only the
|
|
2119
2127
|
* read-violation report is lost, which is said once.
|
|
2120
2128
|
*/
|
|
2121
|
-
async function traceRefusal(
|
|
2129
|
+
async function traceRefusal(
|
|
2130
|
+
form: 'plain' | 'seccomp',
|
|
2131
|
+
): Promise<{ line: string; warned: boolean } | null> {
|
|
2122
2132
|
const p = Bun.spawn(
|
|
2123
2133
|
[
|
|
2124
2134
|
executablePath('strace'),
|
|
@@ -2136,8 +2146,9 @@ async function traceRefusal(form: 'plain' | 'seccomp'): Promise<string | null> {
|
|
|
2136
2146
|
{ stdin: 'ignore', stdout: 'ignore', stderr: 'pipe' },
|
|
2137
2147
|
)
|
|
2138
2148
|
const err = await new Response(p.stderr).text()
|
|
2139
|
-
|
|
2140
|
-
|
|
2149
|
+
const line = err.split('\n').find((l) => STRACE_OWN_ERROR.test(l))
|
|
2150
|
+
if ((await p.exited) === 0) return line === undefined ? null : { line, warned: true }
|
|
2151
|
+
return { line: err.trim().split('\n')[0] || `exit ${p.exitCode}`, warned: false }
|
|
2141
2152
|
}
|
|
2142
2153
|
let warnedNoTrace = false
|
|
2143
2154
|
|
|
@@ -468,6 +468,19 @@ export function formatPersistentTailBlock(
|
|
|
468
468
|
return lines.join('\n') + '\n'
|
|
469
469
|
}
|
|
470
470
|
|
|
471
|
+
/**
|
|
472
|
+
* What a kept server writes after the summary, one line at a time under its
|
|
473
|
+
* id (`app#dev │ Local: http://localhost:5173`), so several servers'
|
|
474
|
+
* logs read apart. `text` holds whole lines, its last newline cut off.
|
|
475
|
+
*/
|
|
476
|
+
export function formatKeptLines(node: TaskNode, text: string, colors: ColorSupport): string {
|
|
477
|
+
const prefix = `${paintTaskId(node, colors)} ${paint('', '│', colors, { dim: true })} `
|
|
478
|
+
return text
|
|
479
|
+
.split('\n')
|
|
480
|
+
.map((line) => `${prefix}${line}\n`)
|
|
481
|
+
.join('')
|
|
482
|
+
}
|
|
483
|
+
|
|
471
484
|
/**
|
|
472
485
|
* Between the requested task's frame and the summary footer, list the
|
|
473
486
|
* persistent tasks (dev servers / watchers) the run is keeping alive in
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
formatFailureRecap,
|
|
5
5
|
formatFrameClose,
|
|
6
6
|
formatFrameOpen,
|
|
7
|
+
formatKeptLines,
|
|
7
8
|
formatPersistentTailBlock,
|
|
8
9
|
formatTaskBlock,
|
|
9
10
|
formatTaskExecutedLine,
|
|
@@ -318,6 +319,39 @@ export function defaultLogger(
|
|
|
318
319
|
{ node: TaskNode; outcome?: TaskOutcome; out: Tail; err: Tail }
|
|
319
320
|
>()
|
|
320
321
|
let flushedPersistent = false
|
|
322
|
+
// After that flush nothing prints a tail again, yet a kept server writes
|
|
323
|
+
// for as long as vx holds it: `vx run dev --all` showed none of its
|
|
324
|
+
// servers' logs while it ran (C-56). So a ready server's output streams
|
|
325
|
+
// from then on, a line at a time under its id; the partial line each
|
|
326
|
+
// holds waits for its newline, a second runEnd, or `settle`.
|
|
327
|
+
const KEPT_LINE_CAP = 64 * 1024
|
|
328
|
+
const keptLines = new Map<string, { node: TaskNode; rest: string }>()
|
|
329
|
+
const streamKept = (node: TaskNode, chunk: string): void => {
|
|
330
|
+
const held = keptLines.get(node.id) ?? { node, rest: '' }
|
|
331
|
+
keptLines.set(node.id, held)
|
|
332
|
+
const text = held.rest + chunk
|
|
333
|
+
const cut = text.lastIndexOf('\n')
|
|
334
|
+
if (cut < 0) {
|
|
335
|
+
// A line that never ends (a `\r` progress bar) is printed at the cap
|
|
336
|
+
// rather than held without bound.
|
|
337
|
+
if (text.length < KEPT_LINE_CAP) held.rest = text
|
|
338
|
+
else {
|
|
339
|
+
held.rest = ''
|
|
340
|
+
writer.write(fenced(formatKeptLines(node, text, colors)))
|
|
341
|
+
}
|
|
342
|
+
return
|
|
343
|
+
}
|
|
344
|
+
held.rest = text.slice(cut + 1)
|
|
345
|
+
writer.write(fenced(formatKeptLines(node, text.slice(0, cut), colors)))
|
|
346
|
+
}
|
|
347
|
+
const flushKept = (): void => {
|
|
348
|
+
for (const held of keptLines.values()) {
|
|
349
|
+
if (held.rest.length > 0) writer.write(fenced(formatKeptLines(held.node, held.rest, colors)))
|
|
350
|
+
held.rest = ''
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
const printsKept = (tail: { outcome?: TaskOutcome }): boolean =>
|
|
354
|
+
flushedPersistent && tail.outcome !== undefined && view.mode !== 'errors-only'
|
|
321
355
|
// The recap's material, decided at each failure so nothing is held for a
|
|
322
356
|
// task that passed. A buffered task's tail is read off the buffers the
|
|
323
357
|
// frame drains; a live-streamed one keeps no buffer, so a bounded ring
|
|
@@ -411,6 +445,8 @@ export function defaultLogger(
|
|
|
411
445
|
|
|
412
446
|
return {
|
|
413
447
|
settle() {
|
|
448
|
+
// The bus delivers one `run:end`, so the CLI's last word reaches here.
|
|
449
|
+
flushKept()
|
|
414
450
|
writer.settle()
|
|
415
451
|
},
|
|
416
452
|
failureRecap() {
|
|
@@ -483,6 +519,7 @@ export function defaultLogger(
|
|
|
483
519
|
// like the failures below: run() calls runEnd twice on the success
|
|
484
520
|
// path (once before the summary, once in its finally), and a
|
|
485
521
|
// kept-alive child keeps writing between the two.
|
|
522
|
+
if (flushedPersistent) flushKept()
|
|
486
523
|
if (!flushedPersistent) {
|
|
487
524
|
flushedPersistent = true
|
|
488
525
|
if (view.mode !== 'none' && view.mode !== 'errors-only') {
|
|
@@ -533,7 +570,8 @@ export function defaultLogger(
|
|
|
533
570
|
}
|
|
534
571
|
const tail = persistentTails.get(node.id)
|
|
535
572
|
if (tail !== undefined) {
|
|
536
|
-
|
|
573
|
+
if (printsKept(tail)) streamKept(node, chunk)
|
|
574
|
+
else appendTail(tail.out, chunk)
|
|
537
575
|
return
|
|
538
576
|
}
|
|
539
577
|
pushChunk(stdoutBuffers, node.id, chunk)
|
|
@@ -550,7 +588,8 @@ export function defaultLogger(
|
|
|
550
588
|
}
|
|
551
589
|
const tail = persistentTails.get(node.id)
|
|
552
590
|
if (tail !== undefined) {
|
|
553
|
-
|
|
591
|
+
if (printsKept(tail)) streamKept(node, chunk)
|
|
592
|
+
else appendTail(tail.err, chunk)
|
|
554
593
|
return
|
|
555
594
|
}
|
|
556
595
|
pushChunk(stderrBuffers, node.id, chunk)
|
|
@@ -180,9 +180,10 @@ export interface RunOptions {
|
|
|
180
180
|
*/
|
|
181
181
|
signal?: AbortSignal
|
|
182
182
|
/**
|
|
183
|
-
* Hand the requested persistent tasks still running,
|
|
184
|
-
*
|
|
185
|
-
* of stopping them when
|
|
183
|
+
* Hand the requested persistent tasks still running, those a requested
|
|
184
|
+
* group stands for (C-52), and the persistent tasks they depend on
|
|
185
|
+
* (C-46), back on `RunSummary.persistent` instead of stopping them when
|
|
186
|
+
* the graph ends.
|
|
186
187
|
* The caller owns them from then on and stops them with its `stop()`.
|
|
187
188
|
* The watch loop sets this: a dev server stays up while watch idles and
|
|
188
189
|
* is replaced only when the next cycle starts.
|
|
@@ -386,12 +386,13 @@ export function buildAdmission(
|
|
|
386
386
|
}
|
|
387
387
|
|
|
388
388
|
/**
|
|
389
|
-
* Collect every plugin's `cache` layer in declaration order
|
|
390
|
-
*
|
|
391
|
-
* see ChainedCache).
|
|
392
|
-
*
|
|
393
|
-
*
|
|
394
|
-
*
|
|
389
|
+
* Collect every plugin's `cache` layer in declaration order, with the local
|
|
390
|
+
* store at the tail, and chain them (lookup walks them, save reaches all;
|
|
391
|
+
* see ChainedCache). The local store is dropped when a declared layer wraps
|
|
392
|
+
* it (`layer.local === ctx.localCache`), so a remote plugin that layers over
|
|
393
|
+
* the local handle does not also write it directly. Only a single layer
|
|
394
|
+
* left is used as is: the local store when no plugin declares one, or the
|
|
395
|
+
* one layer that wraps it.
|
|
395
396
|
*/
|
|
396
397
|
export async function resolveCache(
|
|
397
398
|
plugins: readonly VxPlugin[],
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
LayeredCache,
|
|
21
21
|
applyGitEnumeration,
|
|
22
22
|
gitPathspecs,
|
|
23
|
+
MAX_SCOPED_PATHSPECS,
|
|
23
24
|
startGitEnumeration,
|
|
24
25
|
lazyGitEnumeration,
|
|
25
26
|
} from '../cache/index.js'
|
|
@@ -181,9 +182,13 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
181
182
|
// config, discovery, the cache open and the config evaluation. Its
|
|
182
183
|
// ~60 ms is the warm run's wall floor on a 1000-project tree; it used to
|
|
183
184
|
// start after discovery and the cache open, ~25 ms later. A scoped run
|
|
184
|
-
// waits: its pathspecs depend on which projects the configs pull in
|
|
185
|
-
|
|
186
|
-
|
|
185
|
+
// waits: its pathspecs depend on which projects the configs pull in —
|
|
186
|
+
// unless it already names more projects than pathspecs scope, where the
|
|
187
|
+
// walk is the whole tree either way (an `--affected` run on 1,000
|
|
188
|
+
// projects waited ~60 ms for it after the configs loaded).
|
|
189
|
+
const wholeTree =
|
|
190
|
+
(options.projects === undefined || options.projects.length > MAX_SCOPED_PATHSPECS) &&
|
|
191
|
+
options.tasks.some((spec) => spec.indexOf('#') <= 0)
|
|
187
192
|
// A `discover` hook that reads the worktree (nx()'s graph key) starts the
|
|
188
193
|
// whole-tree enumeration a scoped run would otherwise scope later, here or
|
|
189
194
|
// in the CLI's selection pass; the run reuses it instead of walking the
|
|
@@ -192,7 +197,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
192
197
|
const git =
|
|
193
198
|
(reused !== undefined ? gitOfDiscovery(reused.projects) : undefined) ??
|
|
194
199
|
lazyGitEnumeration(workspaceRoot)
|
|
195
|
-
if (
|
|
200
|
+
if (wholeTree) void git.start()
|
|
196
201
|
const workspace = await loadWorkspace(workspaceRoot, reads)
|
|
197
202
|
const { workspaceConfig, plugins } = await loadWorkspacePlugins(workspaceRoot, (m) =>
|
|
198
203
|
log.status(m),
|
|
@@ -592,7 +592,8 @@ async function gitPaths(workspaceRoot: string, cmd: string[]): Promise<string[]>
|
|
|
592
592
|
/**
|
|
593
593
|
* Resolve the default base for `--affected` with no explicit value.
|
|
594
594
|
* Tries the remote's HEAD branch first (`origin/main`, `origin/master`,
|
|
595
|
-
* etc.), then
|
|
595
|
+
* etc.), then `origin/main`, `origin/master`, `main`, `master`, then
|
|
596
|
+
* falls back to `HEAD~1`. A clone with no `origin/HEAD` and
|
|
596
597
|
* no parent commit — a CI checkout at `fetch-depth: 1` — has no base at
|
|
597
598
|
* all, and says so here rather than failing on a ref nobody typed.
|
|
598
599
|
*/
|
|
@@ -609,6 +610,12 @@ export async function defaultAffectedBase(workspaceRoot: string): Promise<string
|
|
|
609
610
|
if (probe.exitCode === 0 && out.length > 0 && revParse(workspaceRoot, out) !== undefined) {
|
|
610
611
|
return out
|
|
611
612
|
}
|
|
613
|
+
// No origin/HEAD: `git remote add` + fetch, as actions/checkout does, sets
|
|
614
|
+
// none, and `HEAD~1` saw only a feature branch's last commit where Turbo
|
|
615
|
+
// and Nx compare with `main` (D-93). The usual trunk names, remote first;
|
|
616
|
+
// one that IS HEAD (a push to main) falls through to `HEAD~1` as before.
|
|
617
|
+
const trunk = trunkBase(workspaceRoot)
|
|
618
|
+
if (trunk !== undefined) return trunk
|
|
612
619
|
if (revParse(workspaceRoot, 'HEAD~1') === undefined) {
|
|
613
620
|
throw new UserError(
|
|
614
621
|
'--affected has no base here: origin/HEAD is not set (or names a branch that is gone) and HEAD has no parent to compare ' +
|
|
@@ -619,6 +626,32 @@ export async function defaultAffectedBase(workspaceRoot: string): Promise<string
|
|
|
619
626
|
return 'HEAD~1'
|
|
620
627
|
}
|
|
621
628
|
|
|
629
|
+
const TRUNKS = ['origin/main', 'origin/master', 'main', 'master']
|
|
630
|
+
|
|
631
|
+
/** The first of `TRUNKS` that exists and is not HEAD's commit, in one spawn. */
|
|
632
|
+
function trunkBase(workspaceRoot: string): string | undefined {
|
|
633
|
+
const proc = spawnGitSync(
|
|
634
|
+
[
|
|
635
|
+
'for-each-ref',
|
|
636
|
+
'--format=%(refname:short) %(objectname)',
|
|
637
|
+
'--end-of-options',
|
|
638
|
+
...TRUNKS.map((t) => (t.startsWith('origin/') ? `refs/remotes/${t}` : `refs/heads/${t}`)),
|
|
639
|
+
],
|
|
640
|
+
workspaceRoot,
|
|
641
|
+
'ignore',
|
|
642
|
+
)
|
|
643
|
+
if (proc.exitCode !== 0) return undefined
|
|
644
|
+
const at = new Map(
|
|
645
|
+
new TextDecoder()
|
|
646
|
+
.decode(proc.stdout)
|
|
647
|
+
.split('\n')
|
|
648
|
+
.filter((l) => l !== '')
|
|
649
|
+
.map((l) => l.split(' ') as [string, string]),
|
|
650
|
+
)
|
|
651
|
+
const head = revParse(workspaceRoot, 'HEAD')
|
|
652
|
+
return TRUNKS.find((t) => at.has(t) && at.get(t) !== head)
|
|
653
|
+
}
|
|
654
|
+
|
|
622
655
|
/**
|
|
623
656
|
* Does `ref` name the commit HEAD is on? A base that IS HEAD can never
|
|
624
657
|
* mark anything affected — the shape of a single-branch clone whose
|
|
@@ -742,6 +742,16 @@ const FOREIGN_FIELDS: ReadonlyMap<ReadonlySet<string>, Readonly<Record<string, s
|
|
|
742
742
|
'`exec.command`: vx runs one shell command (`nx()` from `@vzn/vx-migrate` runs an Nx executor as one)',
|
|
743
743
|
options: '`exec.command` (a run-commands `options.command`) and `exec.env`',
|
|
744
744
|
outputLogs: 'the `--output-logs` flag of `vx run`',
|
|
745
|
+
// Turbo's `interactive` / `with`, Nx's `cwd` / `parallelism` /
|
|
746
|
+
// `configurations`: refused with no word on where they went (D-89).
|
|
747
|
+
interactive:
|
|
748
|
+
'a command run outside vx: a task never reads the terminal (its stdin is EOF, or a pipe vx holds under `exec.persistent`)',
|
|
749
|
+
with: '`dependsOn` on each task it runs beside: a persistent one stays up, and this task starts once it is ready',
|
|
750
|
+
cwd: '`cd <dir> && …` in `exec.command`: a task runs in its project directory',
|
|
751
|
+
parallelism:
|
|
752
|
+
'`concurrency: 1` in vx.workspace or `--concurrency 1` on the run: no task runs alone beside others',
|
|
753
|
+
configurations:
|
|
754
|
+
'one task per configuration (`build:production`, with its own `exec.command`)',
|
|
745
755
|
},
|
|
746
756
|
],
|
|
747
757
|
[EXEC_FIELDS, { cmd: '`command`', script: '`command`' }],
|
|
@@ -155,7 +155,10 @@ function ownerOf(dir: string, memo: Map<string, Owner>): Owner {
|
|
|
155
155
|
try {
|
|
156
156
|
const pm = (JSON.parse(read('package.json') ?? '') as { packageManager?: unknown })
|
|
157
157
|
.packageManager
|
|
158
|
-
|
|
158
|
+
// A manager vx knows nothing of (zod's `nub@0.8.3`) says nothing about
|
|
159
|
+
// hooks: the lockfile beside it does, and "nub ran `postbuild`" was a
|
|
160
|
+
// claim nothing had checked (D-96).
|
|
161
|
+
if (typeof pm === 'string' && /^(npm|pnpm|yarn|bun)@/.test(pm))
|
|
159
162
|
manager = /^yarn@([2-9]|\d{2,})/.test(pm) ? 'berry' : pm.split('@')[0]
|
|
160
163
|
} catch {}
|
|
161
164
|
if (manager === undefined) {
|
|
@@ -342,6 +345,44 @@ function cdsToMembers(script: string, rootDir: string, memberDirs: readonly stri
|
|
|
342
345
|
const RUNS_SCRIPT =
|
|
343
346
|
/(?:^|[\s;&|(])(?:pnpm|npm|yarn|bun)\s+(?:run(?:-script)?\s+)?([^\s;&|()'"-][^\s;&|()'"]*)/g
|
|
344
347
|
|
|
348
|
+
/**
|
|
349
|
+
* The scripts a command runs by name: a package manager's (`pnpm x`, `npm
|
|
350
|
+
* run x`), `run-s` / `run-p` / `npm-run-all` (`build:*` is one segment,
|
|
351
|
+
* `build:**` any), and `concurrently "npm:x"`. A root `ci: run-s build:all
|
|
352
|
+
* lint` over `build:all: pnpm -r build` mapped as a root task (D-95).
|
|
353
|
+
*/
|
|
354
|
+
function scriptRefs(command: string, scripts: readonly (readonly [string, string])[]): string[] {
|
|
355
|
+
const refs = [...command.matchAll(RUNS_SCRIPT)].map((m) => m[1]!)
|
|
356
|
+
for (const m of command.matchAll(/["']?\b(?:npm|pnpm|yarn|bun):([^\s"']+)/g)) refs.push(m[1]!)
|
|
357
|
+
for (const segment of command.split(/&&|\|\||[;|()]/)) {
|
|
358
|
+
const words = segment.trim().split(/\s+/)
|
|
359
|
+
const at = words.findIndex((w) => /^(run-s|run-p|npm-run-all)$/.test(w))
|
|
360
|
+
if (at < 0) continue
|
|
361
|
+
for (const w of words.slice(at + 1)) {
|
|
362
|
+
if (w.startsWith('-')) continue
|
|
363
|
+
const name = w.replace(/^(["'])(.*)\1$/, '$2')
|
|
364
|
+
if (!name.includes('*')) {
|
|
365
|
+
refs.push(name)
|
|
366
|
+
continue
|
|
367
|
+
}
|
|
368
|
+
const re = new RegExp(
|
|
369
|
+
`^${name
|
|
370
|
+
.split(/(\*\*|\*)/)
|
|
371
|
+
.map((part) =>
|
|
372
|
+
part === '**'
|
|
373
|
+
? '.*'
|
|
374
|
+
: part === '*'
|
|
375
|
+
? '[^:]*'
|
|
376
|
+
: part.replace(/[.+?^${}()|[\]\\]/g, '\\$&'),
|
|
377
|
+
)
|
|
378
|
+
.join('')}$`,
|
|
379
|
+
)
|
|
380
|
+
for (const [n] of scripts) if (re.test(n)) refs.push(n)
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return refs
|
|
384
|
+
}
|
|
385
|
+
|
|
345
386
|
/**
|
|
346
387
|
* The scripts that run the members, directly or through another of these
|
|
347
388
|
* scripts: vite's `ci-docs` (`pnpm build && pnpm docs-build`) runs each
|
|
@@ -367,7 +408,7 @@ function runningMembers(
|
|
|
367
408
|
grew = false
|
|
368
409
|
for (const [n, v] of text) {
|
|
369
410
|
if (out.has(n)) continue
|
|
370
|
-
if (
|
|
411
|
+
if (scriptRefs(v, text).some((r) => out.has(r))) {
|
|
371
412
|
out.add(n)
|
|
372
413
|
grew = true
|
|
373
414
|
}
|