@vzn/vx 0.0.21 → 0.0.22
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/README.md +21 -18
- package/package.json +6 -6
- package/src/bin.ts +52 -3
- package/src/cache/archive.ts +170 -21
- package/src/cache/cache.ts +667 -288
- package/src/cache/chained-cache.ts +14 -2
- package/src/cache/config-evals.ts +29 -6
- package/src/cache/file-hashes.ts +63 -35
- package/src/cache/git-inputs.ts +398 -69
- package/src/cache/index.ts +8 -1
- package/src/cache/inputs.ts +403 -102
- package/src/cache/key-fold.ts +232 -0
- package/src/cache/layer.ts +81 -11
- package/src/cache/layered-cache.ts +199 -32
- package/src/cache/output-index.ts +109 -10
- package/src/cache/run-history.ts +8 -3
- package/src/cache/zstd.ts +53 -30
- package/src/cli/cache.ts +51 -21
- package/src/cli/format.ts +4 -15
- package/src/cli/help.ts +13 -3
- package/src/cli/index.ts +35 -9
- package/src/cli/info.ts +49 -7
- package/src/cli/init.ts +17 -5
- package/src/cli/last.ts +96 -21
- package/src/cli/lock.ts +34 -17
- package/src/cli/plan-format.ts +4 -4
- package/src/cli/plugin-commands.ts +20 -5
- package/src/cli/run.ts +55 -12
- package/src/cli/select.ts +23 -13
- package/src/cli/show.ts +9 -4
- package/src/cli/upgrade.ts +134 -14
- package/src/cli/watch.ts +611 -102
- package/src/cli/why.ts +8 -16
- package/src/cli/workspace-config.ts +21 -9
- package/src/config.ts +46 -9
- package/src/exec/executor.ts +51 -5
- package/src/exec/index.ts +10 -0
- package/src/exec/kill-tree.ts +281 -0
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/runner.ts +327 -82
- package/src/exec/sandbox-binds.ts +34 -5
- package/src/exec/sandbox-paths.ts +22 -2
- package/src/exec/sandbox-runtime.ts +627 -102
- package/src/exec/sandbox-violations.ts +36 -23
- package/src/graph/dependency-spec.ts +14 -14
- package/src/graph/index.ts +12 -2
- package/src/graph/priorities.ts +130 -0
- package/src/graph/scheduler.ts +182 -134
- package/src/graph/task-graph.ts +552 -188
- package/src/index.ts +16 -2
- package/src/orchestrator/admission.ts +41 -17
- package/src/orchestrator/colors.ts +8 -3
- package/src/orchestrator/deferred-outputs.ts +22 -7
- package/src/orchestrator/doctor.ts +122 -14
- package/src/orchestrator/events.ts +71 -2
- package/src/orchestrator/excluded-keys.ts +155 -0
- package/src/orchestrator/execute-task.ts +331 -37
- package/src/orchestrator/failure-recap.ts +121 -0
- package/src/orchestrator/fingerprint-watch.ts +89 -0
- package/src/orchestrator/framed-output.ts +81 -14
- package/src/orchestrator/history.ts +6 -7
- package/src/orchestrator/hit-restore.ts +82 -18
- package/src/orchestrator/index.ts +7 -1
- package/src/orchestrator/keyed-projects.ts +89 -0
- package/src/orchestrator/local-shortcircuit.ts +83 -23
- package/src/orchestrator/lockfile-claim.ts +152 -32
- package/src/orchestrator/logger.ts +116 -20
- package/src/orchestrator/metrics.ts +156 -60
- package/src/orchestrator/miss-save.ts +106 -43
- package/src/orchestrator/options.ts +30 -4
- package/src/orchestrator/persistent.ts +41 -21
- package/src/orchestrator/placement.ts +55 -25
- package/src/orchestrator/plan.ts +24 -11
- package/src/orchestrator/plugin-host.ts +17 -23
- package/src/orchestrator/plugin.ts +31 -8
- package/src/orchestrator/prepare.ts +99 -13
- package/src/orchestrator/projects.ts +19 -8
- package/src/orchestrator/remote-prefetch.ts +21 -1
- package/src/orchestrator/run-artifacts.ts +9 -0
- package/src/orchestrator/run-context.ts +19 -4
- package/src/orchestrator/run-lock.ts +349 -0
- package/src/orchestrator/run-records.ts +6 -23
- package/src/orchestrator/run-report.ts +4 -2
- package/src/orchestrator/run.ts +264 -57
- package/src/orchestrator/sandbox-request.ts +388 -62
- package/src/orchestrator/save-lane.ts +4 -7
- package/src/orchestrator/shell-verdict.ts +117 -0
- package/src/orchestrator/signals.ts +86 -30
- package/src/orchestrator/stable-keys.ts +202 -10
- package/src/orchestrator/status-line.ts +33 -1
- package/src/orchestrator/summary.ts +49 -2
- package/src/orchestrator/task-hash.ts +87 -7
- package/src/orchestrator/task-log-buffer.ts +7 -7
- package/src/orchestrator/telemetry-host.ts +16 -7
- package/src/orchestrator/telemetry.ts +69 -28
- package/src/orchestrator/upstream.ts +91 -45
- package/src/util/bun-version.ts +43 -0
- package/src/util/errors.ts +86 -0
- package/src/util/hash.ts +10 -1
- package/src/util/index.ts +32 -3
- package/src/util/paths.ts +147 -5
- package/src/util/procfs.ts +20 -0
- package/src/util/settle.ts +4 -2
- package/src/util/size.ts +33 -0
- package/src/util/task-id.ts +20 -0
- package/src/util/timing.ts +11 -1
- package/src/util/which.ts +48 -0
- package/src/workspace/affected.ts +272 -64
- package/src/workspace/config-cache.ts +262 -32
- package/src/workspace/config-eval.ts +49 -12
- package/src/workspace/config-imports.ts +126 -5
- package/src/workspace/config-schema.ts +237 -41
- package/src/workspace/filter.ts +33 -18
- package/src/workspace/fingerprint.ts +29 -11
- package/src/workspace/index.ts +7 -0
- package/src/workspace/json-data.ts +90 -0
- package/src/workspace/load-reads.ts +32 -0
- package/src/workspace/lockfile.ts +33 -11
- package/src/workspace/migrate-scripts.ts +60 -7
- package/src/workspace/migration.ts +40 -1
- package/src/workspace/package-graph.ts +154 -16
- package/src/workspace/project-loader.ts +227 -77
- package/src/workspace/workspace.ts +173 -23
package/src/cache/inputs.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Resolve declared cache inputs into the concrete pieces that go into the
|
|
2
2
|
// cache key:
|
|
3
3
|
// - files: absolute paths whose contents are hashed
|
|
4
|
-
// - envValues: [name, value] pairs from parent process.env
|
|
4
|
+
// - envValues: [name, value] pairs from parent process.env (value
|
|
5
|
+
// undefined for an unset name)
|
|
5
6
|
//
|
|
6
7
|
// `cache.inputs.env` is the cache-tracking axis for env vars; it's
|
|
7
8
|
// independent of `exec.env`, which controls what reaches the child.
|
|
@@ -16,12 +17,18 @@
|
|
|
16
17
|
// be a git work tree; non-git environments are not supported.
|
|
17
18
|
|
|
18
19
|
import path from 'node:path'
|
|
19
|
-
import { lstatSync } from 'node:fs'
|
|
20
|
+
import { lstatSync, readdirSync } from 'node:fs'
|
|
20
21
|
import { realpath, rm, rmdir } from 'node:fs/promises'
|
|
21
22
|
import type { CacheInputs } from '../config.js'
|
|
22
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
asTrees,
|
|
25
|
+
isExecutableMissing,
|
|
26
|
+
isLiteralPattern,
|
|
27
|
+
normalizeGlob,
|
|
28
|
+
taskGlob,
|
|
29
|
+
UserError,
|
|
30
|
+
} from '../util/index.js'
|
|
23
31
|
import { GitFilesCache, runGitLsFiles } from './git-inputs.js'
|
|
24
|
-
import { normalizeGlob } from '../util/index.js'
|
|
25
32
|
|
|
26
33
|
// The git side lives in git-inputs.ts; its whole public surface is
|
|
27
34
|
// re-exported here so a reader that reaches the resolver for it (the
|
|
@@ -35,6 +42,7 @@ export {
|
|
|
35
42
|
gitPathspecs,
|
|
36
43
|
parseCheckAttrOutput,
|
|
37
44
|
autocrlfConverts,
|
|
45
|
+
attributeFilesOutsideTree,
|
|
38
46
|
type GitEnumeration,
|
|
39
47
|
} from './git-inputs.js'
|
|
40
48
|
|
|
@@ -45,6 +53,17 @@ export {
|
|
|
45
53
|
// cache is a leaf module and must not import from workspace.)
|
|
46
54
|
const ALWAYS_IGNORE = [
|
|
47
55
|
'**/node_modules/**',
|
|
56
|
+
// Defense in depth, and measured as exactly that (item 497): git never
|
|
57
|
+
// reports a path under `.git`, so dropping this line changes NOTHING on
|
|
58
|
+
// any route into the input set. Probed three ways with the pattern in
|
|
59
|
+
// and out — a tracked `**/*` glob, an UNTRACKED one, and a literal
|
|
60
|
+
// `inputs.files: ['.git/HEAD']` (refused by the git-reports-it check,
|
|
61
|
+
// not by this list) — all identical, while the control in the same
|
|
62
|
+
// fixture showed an untracked ORDINARY file does enter the set. So the
|
|
63
|
+
// enumeration is the live guard and this is the backstop; every other
|
|
64
|
+
// member of this list is load-bearing and has a row that fails without
|
|
65
|
+
// it. Kept: the day an enumeration stops going through git, this is
|
|
66
|
+
// what keeps a repository's object store out of a cache key.
|
|
48
67
|
'**/.git/**',
|
|
49
68
|
'**/.vx/**',
|
|
50
69
|
'**/*.tsbuildinfo',
|
|
@@ -61,7 +80,7 @@ const DEFAULT_FILE_GLOBS: readonly string[] = ['**/*']
|
|
|
61
80
|
|
|
62
81
|
export interface ResolvedInputs {
|
|
63
82
|
files: string[]
|
|
64
|
-
envValues: Array<[name: string, value: string]>
|
|
83
|
+
envValues: Array<[name: string, value: string | undefined]>
|
|
65
84
|
runtimeValues: Array<[command: string, output: string]>
|
|
66
85
|
workspaceRuntimeValues: Array<[command: string, output: string]>
|
|
67
86
|
}
|
|
@@ -109,6 +128,8 @@ export interface ResolveInputsArgs {
|
|
|
109
128
|
* was 930 ms of a 3.0 s warm no-op (159 scans, 2026-09-11).
|
|
110
129
|
*/
|
|
111
130
|
workspaceFilesCache?: WorkspaceFilesCache
|
|
131
|
+
/** Per-run memo for `inputs.files`; see `ProjectFilesCache`. */
|
|
132
|
+
projectFilesCache?: ProjectFilesCache
|
|
112
133
|
}
|
|
113
134
|
|
|
114
135
|
export type WorkspaceFilesCache = Map<
|
|
@@ -116,6 +137,25 @@ export type WorkspaceFilesCache = Map<
|
|
|
116
137
|
{ snapshot: readonly string[]; result: Promise<string[]> }
|
|
117
138
|
>
|
|
118
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Per-run memo of `cache.inputs.files` resolution, keyed by the project and
|
|
142
|
+
* the DECLARATION — tasks of one project that declare the same inputs and
|
|
143
|
+
* the same outputs resolve to the same list, and this repo's own config is
|
|
144
|
+
* the shape that pays for it: twelve shard tasks, each declaring the same
|
|
145
|
+
* whole-tree glob over the same three thousand files (measured 1.2 ms of
|
|
146
|
+
* resolution per task, 2026-09-20).
|
|
147
|
+
*
|
|
148
|
+
* Reuse is gated on the git snapshot being the SAME ARRAY the entry was
|
|
149
|
+
* built from, not on equal contents: a mid-run re-enumeration replaces it
|
|
150
|
+
* (`GitFilesCache.set`), so a task whose inputs a previous task rewrote
|
|
151
|
+
* misses the memo and walks again. Same discipline as WorkspaceFilesCache
|
|
152
|
+
* above.
|
|
153
|
+
*/
|
|
154
|
+
export type ProjectFilesCache = Map<
|
|
155
|
+
string,
|
|
156
|
+
{ snapshot: readonly string[]; result: readonly string[] }
|
|
157
|
+
>
|
|
158
|
+
|
|
119
159
|
export async function resolveInputs(args: ResolveInputsArgs): Promise<ResolvedInputs> {
|
|
120
160
|
const projectFiles = await resolveFiles({
|
|
121
161
|
projectDir: args.projectDir,
|
|
@@ -124,6 +164,7 @@ export async function resolveInputs(args: ResolveInputsArgs): Promise<ResolvedIn
|
|
|
124
164
|
ownOutputs: args.ownOutputs,
|
|
125
165
|
nestedProjectDirs: args.nestedProjectDirs,
|
|
126
166
|
...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
|
|
167
|
+
...(args.projectFilesCache !== undefined ? { projectFilesCache: args.projectFilesCache } : {}),
|
|
127
168
|
})
|
|
128
169
|
let files = projectFiles
|
|
129
170
|
const wsDecl = args.inputs?.workspaceFiles
|
|
@@ -151,10 +192,17 @@ export async function resolveInputs(args: ResolveInputsArgs): Promise<ResolvedIn
|
|
|
151
192
|
resolveRuntimeValues(
|
|
152
193
|
runtimeDecl,
|
|
153
194
|
args.projectDir,
|
|
195
|
+
projectBinDirs(args.projectDir, args.workspaceRoot),
|
|
154
196
|
args.runtimeCache,
|
|
155
197
|
`${args.projectDir}\0`,
|
|
156
198
|
),
|
|
157
|
-
resolveRuntimeValues(
|
|
199
|
+
resolveRuntimeValues(
|
|
200
|
+
wsRuntimeDecl,
|
|
201
|
+
args.workspaceRoot,
|
|
202
|
+
[path.join(args.workspaceRoot, 'node_modules', '.bin')],
|
|
203
|
+
args.workspaceRuntimeCache,
|
|
204
|
+
'',
|
|
205
|
+
),
|
|
158
206
|
])
|
|
159
207
|
return {
|
|
160
208
|
files,
|
|
@@ -201,9 +249,12 @@ async function resolveWorkspaceFiles(args: {
|
|
|
201
249
|
// any loaded task declares workspaceFiles; a missing/invalidated
|
|
202
250
|
// partition re-spawns git at the root on demand.
|
|
203
251
|
let gitFiles = args.gitFilesCache?.snapshotFor(args.workspaceRoot, positiveGlobs)
|
|
252
|
+
let undecodable = args.gitFilesCache?.undecodableNames
|
|
204
253
|
if (gitFiles === undefined) {
|
|
205
|
-
|
|
254
|
+
const ls = runGitLsFiles(args.workspaceRoot)
|
|
255
|
+
gitFiles = ls.files
|
|
206
256
|
args.gitFilesCache?.set(args.workspaceRoot, gitFiles)
|
|
257
|
+
undecodable = noteUndecodable(args.gitFilesCache, args.workspaceRoot, ls.undecodable)
|
|
207
258
|
}
|
|
208
259
|
// The memo is valid for the snapshot it was computed over: a task that
|
|
209
260
|
// wrote workspace outputs mid-run replaces the partition, and the next
|
|
@@ -216,7 +267,14 @@ async function resolveWorkspaceFiles(args: {
|
|
|
216
267
|
const hit = args.memo!.get(memoKey)
|
|
217
268
|
if (hit !== undefined && hit.snapshot === gitFiles) return hit.result
|
|
218
269
|
}
|
|
219
|
-
const result = resolveWorkspaceFilesOver(
|
|
270
|
+
const result = resolveWorkspaceFilesOver(
|
|
271
|
+
args,
|
|
272
|
+
gitFiles,
|
|
273
|
+
positive,
|
|
274
|
+
positiveGlobs,
|
|
275
|
+
excludeGlobs,
|
|
276
|
+
undecodable,
|
|
277
|
+
)
|
|
220
278
|
if (memoKey !== undefined) args.memo!.set(memoKey, { snapshot: gitFiles, result })
|
|
221
279
|
return result
|
|
222
280
|
}
|
|
@@ -227,13 +285,14 @@ async function resolveWorkspaceFilesOver(
|
|
|
227
285
|
positive: readonly string[],
|
|
228
286
|
positiveGlobs: readonly Bun.Glob[],
|
|
229
287
|
excludeGlobs: readonly Bun.Glob[],
|
|
288
|
+
undecodable: ReadonlySet<string> | undefined,
|
|
230
289
|
): Promise<string[]> {
|
|
231
290
|
// Second call site of the literal-input guard. `resolveWorkspaceFiles`
|
|
232
291
|
// carries its own copy of the filter-over-git-set design, so the same
|
|
233
292
|
// silently-folds-nothing hazard exists here — and a fix applied only to the
|
|
234
293
|
// project half would pass that half's tests while leaving this one live.
|
|
235
294
|
const unmatchedLiterals = new Set(
|
|
236
|
-
positive.map(normalizeGlob).filter(
|
|
295
|
+
positive.map(normalizeGlob).filter(isLiteralPattern).map(stripTrailingSlash),
|
|
237
296
|
)
|
|
238
297
|
const candidates: string[] = []
|
|
239
298
|
for (const rel of gitFiles) {
|
|
@@ -245,12 +304,23 @@ async function resolveWorkspaceFilesOver(
|
|
|
245
304
|
if (unmatchedLiterals.size > 0) {
|
|
246
305
|
await assertNoInvisibleLiteralInputs(unmatchedLiterals, args.workspaceRoot, 'workspaceFiles')
|
|
247
306
|
}
|
|
307
|
+
refuseUndecodable(candidates, undecodable, args.workspaceRoot, 'workspaceFiles')
|
|
248
308
|
// Same OID-trust shortcut as project files: a clean-per-status
|
|
249
309
|
// tracked file necessarily exists on disk.
|
|
250
310
|
const oids = args.gitFilesCache?.oidsFor(args.workspaceRoot)
|
|
251
311
|
return candidates.filter((abs) => oids?.has(abs) === true || isInputOnDisk(abs)).sort()
|
|
252
312
|
}
|
|
253
313
|
|
|
314
|
+
/** Anything at the path — file, directory, symlink to anything or to nothing. */
|
|
315
|
+
function existsOnDisk(abs: string): boolean {
|
|
316
|
+
try {
|
|
317
|
+
lstatSync(abs)
|
|
318
|
+
return true
|
|
319
|
+
} catch {
|
|
320
|
+
return false
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
254
324
|
/**
|
|
255
325
|
* What an enumerated path must be to count as an input: a regular file, or
|
|
256
326
|
* a symlink (to anything, or to nothing — its target STRING is what folds,
|
|
@@ -271,11 +341,18 @@ function isInputOnDisk(abs: string): boolean {
|
|
|
271
341
|
}
|
|
272
342
|
}
|
|
273
343
|
|
|
344
|
+
/** The task's `node_modules/.bin` directories, as `taskBinDirs` gives its PATH. */
|
|
345
|
+
function projectBinDirs(projectDir: string, workspaceRoot: string): string[] {
|
|
346
|
+
const own = path.join(projectDir, 'node_modules', '.bin')
|
|
347
|
+
const root = path.join(workspaceRoot, 'node_modules', '.bin')
|
|
348
|
+
return own === root ? [own] : [own, root]
|
|
349
|
+
}
|
|
350
|
+
|
|
274
351
|
function resolveEnvValues(
|
|
275
352
|
names: readonly string[],
|
|
276
353
|
source: NodeJS.ProcessEnv,
|
|
277
|
-
): Array<[string, string]> {
|
|
278
|
-
return [...names].sort().map((name) => [name, source[name]
|
|
354
|
+
): Array<[string, string | undefined]> {
|
|
355
|
+
return [...names].sort().map((name) => [name, source[name]])
|
|
279
356
|
}
|
|
280
357
|
|
|
281
358
|
/**
|
|
@@ -287,17 +364,36 @@ function resolveEnvValues(
|
|
|
287
364
|
* `exec.env.define` / `passThrough`, which describe the command's
|
|
288
365
|
* environment, not the machine's. That is what lets the run-scoped memo
|
|
289
366
|
* key on (projectDir, command) alone; see `runtimeCache`.
|
|
367
|
+
*
|
|
368
|
+
* Its PATH does lead with the task's own `node_modules/.bin` directories
|
|
369
|
+
* (`binDirs`), as the task's does: `tsc --version` otherwise keyed the
|
|
370
|
+
* global tsc while the task ran the workspace's, and a changed local tool
|
|
371
|
+
* replayed the old output (item 996).
|
|
290
372
|
*/
|
|
291
|
-
async function runRuntimeCommand(
|
|
373
|
+
async function runRuntimeCommand(
|
|
374
|
+
command: string,
|
|
375
|
+
cwd: string,
|
|
376
|
+
binDirs: readonly string[],
|
|
377
|
+
): Promise<string> {
|
|
378
|
+
const ambient = process.env['PATH']
|
|
379
|
+
const prefix = binDirs.join(path.delimiter)
|
|
292
380
|
let proc
|
|
293
381
|
try {
|
|
294
382
|
proc = Bun.spawn(['sh', '-c', command], {
|
|
295
383
|
cwd,
|
|
384
|
+
env: { ...process.env, PATH: ambient ? `${prefix}${path.delimiter}${ambient}` : prefix },
|
|
296
385
|
stdin: 'ignore',
|
|
297
386
|
stdout: 'pipe',
|
|
298
387
|
stderr: 'pipe',
|
|
299
388
|
})
|
|
300
|
-
} catch {
|
|
389
|
+
} catch (err) {
|
|
390
|
+
// The probe runs through `sh -c` like a task: a box without sh names
|
|
391
|
+
// the shell, not the command (item 244).
|
|
392
|
+
if (isExecutableMissing(err)) {
|
|
393
|
+
throw new UserError(
|
|
394
|
+
`cache.inputs runtime command could not run: vx runs it with sh -c and 'sh' is not on PATH (command: ${command}, cwd: ${cwd}). Install a POSIX sh and re-run.`,
|
|
395
|
+
)
|
|
396
|
+
}
|
|
301
397
|
throw new UserError(`cache.inputs runtime command failed to spawn: ${command} (cwd: ${cwd})`)
|
|
302
398
|
}
|
|
303
399
|
const [stdout, stderr, exitCode] = await Promise.all([
|
|
@@ -327,6 +423,7 @@ async function runRuntimeCommand(command: string, cwd: string): Promise<string>
|
|
|
327
423
|
async function resolveRuntimeValues(
|
|
328
424
|
commands: readonly string[],
|
|
329
425
|
cwd: string,
|
|
426
|
+
binDirs: readonly string[],
|
|
330
427
|
memo: Map<string, Promise<string>> | undefined,
|
|
331
428
|
memoKeyPrefix: string,
|
|
332
429
|
): Promise<Array<[string, string]>> {
|
|
@@ -337,7 +434,7 @@ async function resolveRuntimeValues(
|
|
|
337
434
|
const key = `${memoKeyPrefix}${cmd}`
|
|
338
435
|
let p = memo?.get(key)
|
|
339
436
|
if (p === undefined) {
|
|
340
|
-
p = runRuntimeCommand(cmd, cwd)
|
|
437
|
+
p = runRuntimeCommand(cmd, cwd, binDirs)
|
|
341
438
|
memo?.set(key, p)
|
|
342
439
|
}
|
|
343
440
|
return [cmd, await p] as [string, string]
|
|
@@ -345,6 +442,15 @@ async function resolveRuntimeValues(
|
|
|
345
442
|
)
|
|
346
443
|
}
|
|
347
444
|
|
|
445
|
+
/**
|
|
446
|
+
* What no output glob reaches. An output is otherwise taken as written —
|
|
447
|
+
* `node_modules/**` IS an install task's output, so `ALWAYS_IGNORE` does not
|
|
448
|
+
* apply here — but these two directories no task produces, and a root
|
|
449
|
+
* project declaring `**` would otherwise wipe its repository and the cache
|
|
450
|
+
* it is restoring from (2026-09-16).
|
|
451
|
+
*/
|
|
452
|
+
const OUTPUT_NEVER = ['**/.git/**', '**/.vx/**']
|
|
453
|
+
|
|
348
454
|
/** Resolve declared output globs (project-relative) to actual produced files. */
|
|
349
455
|
export async function resolveOutputs(args: {
|
|
350
456
|
projectDir: string
|
|
@@ -352,10 +458,11 @@ export async function resolveOutputs(args: {
|
|
|
352
458
|
nestedProjectDirs: string[]
|
|
353
459
|
}): Promise<string[]> {
|
|
354
460
|
if (args.outputs.length === 0) return []
|
|
355
|
-
const excludeGlobs =
|
|
356
|
-
|
|
357
|
-
...(
|
|
358
|
-
]
|
|
461
|
+
const excludeGlobs = [
|
|
462
|
+
...OUTPUT_NEVER,
|
|
463
|
+
...boundaryIgnorePatterns(args.projectDir, args.nestedProjectDirs),
|
|
464
|
+
].map(globFor)
|
|
465
|
+
const scanned = [...(await scanUnion(asTrees(args.outputs), excludeGlobs, args.projectDir))]
|
|
359
466
|
// Containment, enforced HERE and not only at the loader. `cleanOutputs`
|
|
360
467
|
// DELETES whatever this returns, and `Bun.Glob.scan` happily walks `..` out
|
|
361
468
|
// of its cwd — so the loader's `..`/absolute rejection alone was a single
|
|
@@ -440,13 +547,109 @@ export async function cleanOutputs(args: {
|
|
|
440
547
|
// `force: true` makes rm tolerate ENOENT (e.g. when two output
|
|
441
548
|
// globs overlap and a sibling already deleted a path mid-iteration).
|
|
442
549
|
// A symlink is unlinked, never followed.
|
|
443
|
-
await
|
|
550
|
+
await removeAll(files, args.projectDir)
|
|
444
551
|
await pruneEmptiedDirs(args.projectDir, files)
|
|
445
552
|
// Project-relative posix paths of what was removed — the caller
|
|
446
553
|
// feeds these to GitFilesCache.markOutputsChanged after a restore.
|
|
447
554
|
return files.map((f) => path.relative(args.projectDir, f).split(path.sep).join('/'))
|
|
448
555
|
}
|
|
449
556
|
|
|
557
|
+
/**
|
|
558
|
+
* Remove exactly these project-relative paths — the recorded rows of an
|
|
559
|
+
* ADDITIVE task's own artifact (item 588), never a glob: the glob would
|
|
560
|
+
* take the upstream's files the task adds beside. Emptied directories are
|
|
561
|
+
* pruned as `cleanOutputs` prunes them, and stop at one the upstream still
|
|
562
|
+
* fills.
|
|
563
|
+
*/
|
|
564
|
+
export async function cleanOutputPaths(args: {
|
|
565
|
+
projectDir: string
|
|
566
|
+
rels: readonly string[]
|
|
567
|
+
}): Promise<void> {
|
|
568
|
+
const files = args.rels.map((r) => path.resolve(args.projectDir, r))
|
|
569
|
+
await removeAll(files, args.projectDir)
|
|
570
|
+
await pruneEmptiedDirs(args.projectDir, files)
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/** A file's identity for the additive diff: what the hit path's fingerprint trusts too. */
|
|
574
|
+
export interface OutputStamp {
|
|
575
|
+
size: number
|
|
576
|
+
mtimeMs: number
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* The stamps of every file the declared outputs currently select — taken
|
|
581
|
+
* BEFORE an additive task runs, so what it added or changed can be told
|
|
582
|
+
* from what it found (item 588).
|
|
583
|
+
*/
|
|
584
|
+
export async function stampOutputs(args: {
|
|
585
|
+
projectDir: string
|
|
586
|
+
outputs: string[]
|
|
587
|
+
nestedProjectDirs: string[]
|
|
588
|
+
}): Promise<Map<string, OutputStamp>> {
|
|
589
|
+
const files = await resolveOutputs(args)
|
|
590
|
+
const out = new Map<string, OutputStamp>()
|
|
591
|
+
for (const f of files) {
|
|
592
|
+
try {
|
|
593
|
+
const st = lstatSync(f)
|
|
594
|
+
out.set(f, { size: st.size, mtimeMs: st.mtimeMs })
|
|
595
|
+
} catch {
|
|
596
|
+
// Gone between the walk and the stat: not a file the task found.
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
return out
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* The files an additive task's run ADDED or CHANGED under its declared
|
|
604
|
+
* outputs: every selected file that was not in `before`, or whose size or
|
|
605
|
+
* mtime moved. Size + mtime is the proof the hit path already trusts for
|
|
606
|
+
* a current tree, so no new trust is introduced. A file the run rewrote
|
|
607
|
+
* with identical bytes counts as its own (the mtime moved), which is the
|
|
608
|
+
* rewrite-in-place cost the design note records.
|
|
609
|
+
*/
|
|
610
|
+
export async function ownOutputsSince(
|
|
611
|
+
args: { projectDir: string; outputs: string[]; nestedProjectDirs: string[] },
|
|
612
|
+
before: ReadonlyMap<string, OutputStamp>,
|
|
613
|
+
): Promise<string[]> {
|
|
614
|
+
const after = await resolveOutputs(args)
|
|
615
|
+
const own: string[] = []
|
|
616
|
+
for (const f of after) {
|
|
617
|
+
const was = before.get(f)
|
|
618
|
+
if (was === undefined) {
|
|
619
|
+
own.push(f)
|
|
620
|
+
continue
|
|
621
|
+
}
|
|
622
|
+
try {
|
|
623
|
+
const st = lstatSync(f)
|
|
624
|
+
if (st.size !== was.size || st.mtimeMs !== was.mtimeMs) own.push(f)
|
|
625
|
+
} catch {
|
|
626
|
+
// Vanished since the walk: not an output.
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
return own
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* A declared output the process cannot remove (a `dist/` another user
|
|
634
|
+
* wrote, a read-only checkout) is the environment's failure, not vx's:
|
|
635
|
+
* the scheduler prints any other error as an "internal error", which
|
|
636
|
+
* sends the reader to file a bug against a permission bit.
|
|
637
|
+
*/
|
|
638
|
+
async function removeAll(files: readonly string[], root: string): Promise<void> {
|
|
639
|
+
await Promise.all(
|
|
640
|
+
files.map((f) =>
|
|
641
|
+
rm(f, { force: true }).catch((err: NodeJS.ErrnoException) => {
|
|
642
|
+
const rel = path.relative(root, f).split(path.sep).join('/')
|
|
643
|
+
throw new UserError(
|
|
644
|
+
`cannot remove declared output ${rel}: ${err.code ?? err.message} — vx clears a task's ` +
|
|
645
|
+
`declared outputs before it runs and before a restore; make the path removable ` +
|
|
646
|
+
`by this user, or stop declaring it as an output`,
|
|
647
|
+
)
|
|
648
|
+
}),
|
|
649
|
+
),
|
|
650
|
+
)
|
|
651
|
+
}
|
|
652
|
+
|
|
450
653
|
/**
|
|
451
654
|
* Remove the directories a clean emptied, bottom-up, never `root` itself.
|
|
452
655
|
* A restore writes files where the cached entry has them; a directory left
|
|
@@ -458,21 +661,30 @@ export async function cleanOutputs(args: {
|
|
|
458
661
|
*/
|
|
459
662
|
async function pruneEmptiedDirs(root: string, removed: readonly string[]): Promise<void> {
|
|
460
663
|
const rootResolved = path.resolve(root)
|
|
461
|
-
|
|
462
|
-
//
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
664
|
+
// LEVEL ORDER, not a walk-up per directory. A parent is attempted only
|
|
665
|
+
// once every one of its children has had its turn, which is what makes
|
|
666
|
+
// "the last child empties it" work — and it costs ONE rmdir per
|
|
667
|
+
// directory rather than one per directory PER CHILD.
|
|
668
|
+
//
|
|
669
|
+
// Measured (200 dirs x 20 files, min of 7, three interleaved passes):
|
|
670
|
+
// a walk-up with a `tried` memo is 46 ms but leaves an emptied parent
|
|
671
|
+
// standing when a sibling's turn came first; the same walk-up without
|
|
672
|
+
// the memo is correct and 60 ms, because every child re-attempts the
|
|
673
|
+
// shared parent. This is correct at 44 ms.
|
|
674
|
+
let level = new Set(removed.map((f) => path.dirname(f)))
|
|
675
|
+
while (level.size > 0) {
|
|
676
|
+
const parents = new Set<string>()
|
|
677
|
+
await Promise.all(
|
|
678
|
+
[...level].map(async (dir) => {
|
|
679
|
+
if (dir === rootResolved || !dir.startsWith(rootResolved + path.sep)) return
|
|
680
|
+
const gone = await rmdir(dir).then(
|
|
681
|
+
() => true,
|
|
682
|
+
(err: NodeJS.ErrnoException) => err.code === 'ENOENT',
|
|
683
|
+
)
|
|
684
|
+
if (gone) parents.add(path.dirname(dir))
|
|
685
|
+
}),
|
|
686
|
+
)
|
|
687
|
+
level = parents
|
|
476
688
|
}
|
|
477
689
|
}
|
|
478
690
|
|
|
@@ -488,7 +700,7 @@ export async function resolveWorkspaceOutputs(args: {
|
|
|
488
700
|
outputs: string[]
|
|
489
701
|
}): Promise<string[]> {
|
|
490
702
|
if (args.outputs.length === 0) return []
|
|
491
|
-
const scanned = [...(await scanUnion(asTrees(args.outputs), [], args.workspaceRoot
|
|
703
|
+
const scanned = [...(await scanUnion(asTrees(args.outputs), [], args.workspaceRoot))]
|
|
492
704
|
// Same containment as the project twin, anchored one level out. These globs
|
|
493
705
|
// deliberately ignore PROJECT boundaries — that is the escape hatch — but
|
|
494
706
|
// escaping the WORKSPACE was never part of it, and `cleanWorkspaceOutputs`
|
|
@@ -509,43 +721,21 @@ export async function cleanWorkspaceOutputs(args: {
|
|
|
509
721
|
outputs: string[]
|
|
510
722
|
}): Promise<string[]> {
|
|
511
723
|
const files = await resolveWorkspaceOutputs(args)
|
|
512
|
-
await
|
|
724
|
+
await removeAll(files, args.workspaceRoot)
|
|
513
725
|
await pruneEmptiedDirs(args.workspaceRoot, files)
|
|
514
726
|
return files.map((f) => path.relative(args.workspaceRoot, f).split(path.sep).join('/'))
|
|
515
727
|
}
|
|
516
728
|
|
|
517
|
-
function isLiteralPath(glob: string): boolean {
|
|
518
|
-
return !/[*?[\]{}]/.test(glob)
|
|
519
|
-
}
|
|
520
|
-
|
|
521
729
|
function stripTrailingSlash(p: string): string {
|
|
522
730
|
return p.replace(/\/+$/, '')
|
|
523
731
|
}
|
|
524
732
|
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
* literal therefore compiles to itself plus its subtree; a literal that
|
|
532
|
-
* names a file still matches exactly that file, since `x/**` matches
|
|
533
|
-
* nothing under a file.
|
|
534
|
-
*/
|
|
535
|
-
export function asTrees(patterns: readonly string[]): string[] {
|
|
536
|
-
const out: string[] = []
|
|
537
|
-
for (const raw of patterns) {
|
|
538
|
-
const p = normalizeGlob(raw)
|
|
539
|
-
if (!isLiteralPath(p)) {
|
|
540
|
-
out.push(p)
|
|
541
|
-
continue
|
|
542
|
-
}
|
|
543
|
-
const lit = stripTrailingSlash(p)
|
|
544
|
-
if (lit.length === 0) continue
|
|
545
|
-
out.push(lit, `${lit}/**`)
|
|
546
|
-
}
|
|
547
|
-
return out
|
|
548
|
-
}
|
|
733
|
+
// The literal-is-a-tree rule moved to `util/paths.ts` (item 442): it
|
|
734
|
+
// decides what a clean deletes as well as what a key folds, and the
|
|
735
|
+
// graph's overlapping-output refusal — which may not import `cache` —
|
|
736
|
+
// has to read the same one. Re-exported here so the cache contract is
|
|
737
|
+
// unchanged.
|
|
738
|
+
export { asTrees } from '../util/index.js'
|
|
549
739
|
|
|
550
740
|
/** A literal is answered by the path itself or by anything under it. */
|
|
551
741
|
function settleLiterals(unmatched: Set<string>, rel: string): void {
|
|
@@ -575,6 +765,13 @@ function settleLiterals(unmatched: Set<string>, rel: string): void {
|
|
|
575
765
|
* An entry that does NOT exist on disk stays silent: that is an ordinary stale
|
|
576
766
|
* declaration, and refusing it would break every config that lists an
|
|
577
767
|
* optional file.
|
|
768
|
+
*
|
|
769
|
+
* "Exists" is lstat, not `Bun.file(p).exists()`: that answers false for a
|
|
770
|
+
* DIRECTORY, so a literal naming a gitignored `gen/` was never judged and
|
|
771
|
+
* folded nothing in silence — the stale hit this refusal exists to stop, one
|
|
772
|
+
* directory above where it looked (item 565, fixed in 576). A literal naming
|
|
773
|
+
* an EMPTY directory is refused by the same rule, and that is right: git
|
|
774
|
+
* lists no file under it, so it folds nothing whether tracked or not.
|
|
578
775
|
*/
|
|
579
776
|
async function assertNoInvisibleLiteralInputs(
|
|
580
777
|
literals: ReadonlySet<string>,
|
|
@@ -582,7 +779,7 @@ async function assertNoInvisibleLiteralInputs(
|
|
|
582
779
|
field: 'files' | 'workspaceFiles' = 'files',
|
|
583
780
|
): Promise<void> {
|
|
584
781
|
for (const rel of literals) {
|
|
585
|
-
if (!(
|
|
782
|
+
if (!existsOnDisk(path.resolve(base, rel))) continue
|
|
586
783
|
throw new UserError(
|
|
587
784
|
`cache.inputs.${field}: "${rel}" exists in ${base} but git does not report it, ` +
|
|
588
785
|
`so it contributes NOTHING to the cache key — input globs filter the files git ` +
|
|
@@ -601,6 +798,45 @@ interface ResolveFilesArgs {
|
|
|
601
798
|
ownOutputs: string[]
|
|
602
799
|
nestedProjectDirs: string[]
|
|
603
800
|
gitFilesCache?: GitFilesCache
|
|
801
|
+
projectFilesCache?: ProjectFilesCache
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* Record the names a fresh `git ls-files` could not decode, in the run's
|
|
806
|
+
* cache when there is one, and return the set to check against.
|
|
807
|
+
*/
|
|
808
|
+
function noteUndecodable(
|
|
809
|
+
cache: GitFilesCache | undefined,
|
|
810
|
+
dir: string,
|
|
811
|
+
rels: readonly string[],
|
|
812
|
+
): ReadonlySet<string> {
|
|
813
|
+
const abs = rels.map((rel) => path.join(dir, rel))
|
|
814
|
+
if (cache === undefined) return new Set(abs)
|
|
815
|
+
cache.markUndecodable(abs)
|
|
816
|
+
return cache.undecodableNames
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
* An input whose name is not UTF-8 is one vx cannot open (a string cannot
|
|
821
|
+
* spell it), so it could only drop out of the key, where an edit to it is a
|
|
822
|
+
* hit. Named instead, with the way out. One git still lists after it left
|
|
823
|
+
* the disk (a tracked file deleted) drops out as any deleted file does.
|
|
824
|
+
*/
|
|
825
|
+
function refuseUndecodable(
|
|
826
|
+
candidates: readonly string[],
|
|
827
|
+
undecodable: ReadonlySet<string> | undefined,
|
|
828
|
+
root: string,
|
|
829
|
+
field: 'files' | 'workspaceFiles',
|
|
830
|
+
): void {
|
|
831
|
+
if (undecodable === undefined || undecodable.size === 0) return
|
|
832
|
+
const bad = candidates.filter((abs) => undecodable.has(abs) && undecodableOnDisk(abs))
|
|
833
|
+
if (bad.length === 0) return
|
|
834
|
+
const names = bad.map((abs) => JSON.stringify(path.relative(root, abs).split(path.sep).join('/')))
|
|
835
|
+
throw new UserError(
|
|
836
|
+
`cache.inputs.${field} matched ${names.join(', ')} in ${root}: the name is not valid UTF-8 ` +
|
|
837
|
+
`(shown with \ufffd), and vx cannot read a file by it. Rename it, or exclude it with a ` +
|
|
838
|
+
`negated glob.`,
|
|
839
|
+
)
|
|
604
840
|
}
|
|
605
841
|
|
|
606
842
|
async function resolveFiles(args: ResolveFilesArgs): Promise<string[]> {
|
|
@@ -635,16 +871,33 @@ async function resolveFiles(args: ResolveFilesArgs): Promise<string[]> {
|
|
|
635
871
|
// same project per run is wasteful; we cache the result for the
|
|
636
872
|
// duration of one orchestrator run.
|
|
637
873
|
const positiveGlobs = asTrees(positive).map(globFor)
|
|
874
|
+
// Everything below the snapshot that decides the result: the project, what
|
|
875
|
+
// it declares, what it excludes as its own outputs, and the boundaries.
|
|
876
|
+
const memoKey = `${args.projectDir}\0${positive.join('\u0001')}\0${negative.join('\u0001')}\0${args.ownOutputs.join('\u0001')}\0${boundaryIgnores.join('\u0001')}`
|
|
638
877
|
let gitFiles = args.gitFilesCache?.snapshotFor(args.projectDir, positiveGlobs)
|
|
878
|
+
let undecodable = args.gitFilesCache?.undecodableNames
|
|
879
|
+
if (gitFiles !== undefined) {
|
|
880
|
+
const memo = args.projectFilesCache?.get(memoKey)
|
|
881
|
+
// Identity, not equality: a re-enumeration hands back a new array even
|
|
882
|
+
// when the file set is unchanged, and that is exactly when this task's
|
|
883
|
+
// inputs must be walked again.
|
|
884
|
+
if (memo !== undefined && memo.snapshot === gitFiles) return [...memo.result]
|
|
885
|
+
}
|
|
639
886
|
if (gitFiles === undefined) {
|
|
640
|
-
// Mid-run re-enumeration
|
|
887
|
+
// Mid-run re-enumeration — or a project the workspace-wide populate
|
|
888
|
+
// left without a partition because the workspace's git did not see its
|
|
889
|
+
// directory (a submodule, an embedded repository); spawned in the
|
|
890
|
+
// project dir, git answers from the nested repository.
|
|
891
|
+
// The OIDs this spawn could yield are NOT
|
|
641
892
|
// trusted (no fresh `git status` to vouch for them — the project's
|
|
642
893
|
// tree just changed); set() drops the project's OID slot and these
|
|
643
894
|
// files fall back to Cache.hashFile, which computes the identical
|
|
644
895
|
// blob OID from disk.
|
|
645
|
-
|
|
896
|
+
const ls = runGitLsFiles(args.projectDir)
|
|
897
|
+
gitFiles = ls.files
|
|
646
898
|
// set() also clears the project's pending-changed bookkeeping.
|
|
647
899
|
args.gitFilesCache?.set(args.projectDir, gitFiles)
|
|
900
|
+
undecodable = noteUndecodable(args.gitFilesCache, args.projectDir, ls.undecodable)
|
|
648
901
|
}
|
|
649
902
|
// A LITERAL entry — one with no glob metacharacter — names exactly one file,
|
|
650
903
|
// so "this matched nothing" is unambiguous. For a glob it is not: matching
|
|
@@ -658,7 +911,7 @@ async function resolveFiles(args: ResolveFilesArgs): Promise<string[]> {
|
|
|
658
911
|
// claims as an input. See the refusal below for why this is not simply
|
|
659
912
|
// honoured instead.
|
|
660
913
|
const unmatchedLiterals = new Set(
|
|
661
|
-
positive.map(normalizeGlob).filter(
|
|
914
|
+
positive.map(normalizeGlob).filter(isLiteralPattern).map(stripTrailingSlash),
|
|
662
915
|
)
|
|
663
916
|
// First pass: glob-filter to candidate absolute paths (no I/O).
|
|
664
917
|
const candidates: string[] = []
|
|
@@ -678,6 +931,7 @@ async function resolveFiles(args: ResolveFilesArgs): Promise<string[]> {
|
|
|
678
931
|
if (unmatchedLiterals.size > 0) {
|
|
679
932
|
await assertNoInvisibleLiteralInputs(unmatchedLiterals, args.projectDir, 'files')
|
|
680
933
|
}
|
|
934
|
+
refuseUndecodable(candidates, undecodable, args.projectDir, 'files')
|
|
681
935
|
// Second pass: existence check — but ONLY for paths without a
|
|
682
936
|
// trusted index OID. A clean-per-status tracked file necessarily
|
|
683
937
|
// exists on disk, so skipping its probe keeps the warm path free of
|
|
@@ -685,54 +939,101 @@ async function resolveFiles(args: ResolveFilesArgs): Promise<string[]> {
|
|
|
685
939
|
// `git ls-files -s` can surface staged entries whose working-tree
|
|
686
940
|
// file is gone; the hasher would otherwise throw ENOENT.
|
|
687
941
|
const oids = args.gitFilesCache?.oidsFor(args.projectDir)
|
|
688
|
-
|
|
942
|
+
const resolved = candidates.filter((abs) => oids?.has(abs) === true || isInputOnDisk(abs)).sort()
|
|
943
|
+
// Stored only on the way out: a declaration whose literal named an
|
|
944
|
+
// invisible file threw above, and every task sharing it must throw too.
|
|
945
|
+
args.projectFilesCache?.set(memoKey, { snapshot: gitFiles, result: resolved })
|
|
946
|
+
return [...resolved]
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/**
|
|
950
|
+
* `Bun.Glob` decodes a name that is not UTF-8 lossily, so the path it
|
|
951
|
+
* yields names no file and the output dropped out of the artifact without
|
|
952
|
+
* a word: a hit then restored a tree without it (turborepo#9345's output
|
|
953
|
+
* half). A `\ufffd` that does not stat is that case or a file that left
|
|
954
|
+
* mid-scan; `undecodableOnDisk` tells them apart.
|
|
955
|
+
*/
|
|
956
|
+
function refuseUndecodableOutput(abs: string): void {
|
|
957
|
+
if (!undecodableOnDisk(abs)) return
|
|
958
|
+
throw new UserError(
|
|
959
|
+
`cache.outputs matched ${JSON.stringify(abs)}: the name is not valid UTF-8 (shown with ` +
|
|
960
|
+
`\ufffd), and vx cannot save a file by it. Rename it, or keep it out of the outputs.`,
|
|
961
|
+
)
|
|
689
962
|
}
|
|
690
963
|
|
|
691
964
|
/**
|
|
692
|
-
*
|
|
693
|
-
*
|
|
965
|
+
* Is `abs` (a lossy spelling) something on disk whose name, or a
|
|
966
|
+
* directory's above it, is not UTF-8? From the deepest ancestor a string
|
|
967
|
+
* can reach, read the raw names and look for one that decodes lossily to
|
|
968
|
+
* the next segment and fatally not at all. Only a path that failed to
|
|
969
|
+
* stat reaches here, so the reads are off every warm path.
|
|
970
|
+
*/
|
|
971
|
+
function undecodableOnDisk(abs: string): boolean {
|
|
972
|
+
let dir = path.dirname(abs)
|
|
973
|
+
let next = path.basename(abs)
|
|
974
|
+
while (!existsOnDisk(dir)) {
|
|
975
|
+
const parent = path.dirname(dir)
|
|
976
|
+
if (parent === dir) return false
|
|
977
|
+
next = path.basename(dir)
|
|
978
|
+
dir = parent
|
|
979
|
+
}
|
|
980
|
+
let raw: Buffer[]
|
|
981
|
+
try {
|
|
982
|
+
raw = readdirSync(dir, { encoding: 'buffer' })
|
|
983
|
+
} catch {
|
|
984
|
+
return false
|
|
985
|
+
}
|
|
986
|
+
const lossy = new TextDecoder()
|
|
987
|
+
for (const name of raw) {
|
|
988
|
+
if (lossy.decode(name) !== next) continue
|
|
989
|
+
try {
|
|
990
|
+
FATAL_UTF8.decode(name)
|
|
991
|
+
} catch {
|
|
992
|
+
return true
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
return false
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
const FATAL_UTF8 = new TextDecoder('utf-8', { fatal: true })
|
|
999
|
+
|
|
1000
|
+
/**
|
|
1001
|
+
* Union of the OUTPUT files matching any positive pattern in `cwd`, minus
|
|
1002
|
+
* files matching any exclude glob (tested by Bun.Glob.match on the relative
|
|
694
1003
|
* path). Bun.Glob takes a single pattern per instance, so we iterate.
|
|
695
1004
|
*
|
|
696
|
-
*
|
|
697
|
-
*
|
|
698
|
-
*
|
|
699
|
-
*
|
|
700
|
-
*
|
|
701
|
-
*
|
|
1005
|
+
* Yields SYMLINKS (never followed, never descended): a task that emits
|
|
1006
|
+
* `dist/out -> ../src/x` has produced an output, so the save must capture it
|
|
1007
|
+
* (as its target's bytes, `planArtifact`) and the clean must remove it — a
|
|
1008
|
+
* link the clean leaves standing blocks the directory a later entry restores
|
|
1009
|
+
* there. Bun's `onlyFiles` walk drops every symlink, so this lists everything
|
|
1010
|
+
* and sorts by `lstat`.
|
|
1011
|
+
*
|
|
1012
|
+
* Sync scan. The async walk was chosen on 2026-09-02, when a warm HIT globbed
|
|
1013
|
+
* its outputs and the thread pool overlapped a thousand of them; the directory
|
|
1014
|
+
* short-circuit took the glob off the hit path, and what is left runs on the
|
|
1015
|
+
* MISS path twice per task (clean, then resolve), where the walk is CPU-bound
|
|
1016
|
+
* and each async chunk costs a main-thread round trip. Measured 2026-09-09 on
|
|
1017
|
+
* a 1,000-task cold run (see STATUS).
|
|
1018
|
+
*
|
|
1019
|
+
* A second `mode: 'files'` branch lived here until item 566, defaulted to and
|
|
1020
|
+
* reached by NOTHING — both call sites pass `'outputs'`. Its `dot: true` could
|
|
1021
|
+
* be deleted with the whole suite green for the plainest reason there is.
|
|
702
1022
|
*/
|
|
703
1023
|
async function scanUnion(
|
|
704
1024
|
positive: readonly string[],
|
|
705
1025
|
excludeGlobs: readonly Bun.Glob[],
|
|
706
1026
|
cwd: string,
|
|
707
|
-
mode: 'files' | 'outputs' = 'files',
|
|
708
1027
|
): Promise<Set<string>> {
|
|
709
1028
|
const matches = new Set<string>()
|
|
710
1029
|
for (const pattern of positive) {
|
|
711
1030
|
const glob = globFor(pattern)
|
|
712
|
-
|
|
713
|
-
for (const rel of glob.scanSync({
|
|
714
|
-
cwd,
|
|
715
|
-
onlyFiles: false,
|
|
716
|
-
followSymlinks: false,
|
|
717
|
-
dot: true,
|
|
718
|
-
})) {
|
|
719
|
-
if (excludeGlobs.some((g) => g.match(rel))) continue
|
|
720
|
-
const abs = path.resolve(cwd, rel)
|
|
721
|
-
const st = lstatSync(abs, { throwIfNoEntry: false })
|
|
722
|
-
if (st !== undefined && (st.isFile() || st.isSymbolicLink())) matches.add(abs)
|
|
723
|
-
}
|
|
724
|
-
continue
|
|
725
|
-
}
|
|
726
|
-
// Sync scan. The async walk was chosen on 2026-09-02, when a warm HIT
|
|
727
|
-
// globbed its outputs and the thread pool overlapped a thousand of
|
|
728
|
-
// them; the directory short-circuit took the glob off the hit path, and
|
|
729
|
-
// what is left runs on the MISS path twice per task (clean, then
|
|
730
|
-
// resolve), where the walk is CPU-bound and each async chunk costs a
|
|
731
|
-
// main-thread round trip. Measured 2026-09-09 on a 1,000-task cold run
|
|
732
|
-
// (see STATUS).
|
|
733
|
-
for (const rel of glob.scanSync({ cwd, onlyFiles: true, dot: true })) {
|
|
1031
|
+
for (const rel of glob.scanSync({ cwd, onlyFiles: false, followSymlinks: false, dot: true })) {
|
|
734
1032
|
if (excludeGlobs.some((g) => g.match(rel))) continue
|
|
735
|
-
|
|
1033
|
+
const abs = path.resolve(cwd, rel)
|
|
1034
|
+
const st = lstatSync(abs, { throwIfNoEntry: false })
|
|
1035
|
+
if (st !== undefined && (st.isFile() || st.isSymbolicLink())) matches.add(abs)
|
|
1036
|
+
else if (st === undefined && rel.includes('\ufffd')) refuseUndecodableOutput(abs)
|
|
736
1037
|
}
|
|
737
1038
|
}
|
|
738
1039
|
return matches
|
|
@@ -744,7 +1045,7 @@ async function scanUnion(
|
|
|
744
1045
|
const globMemo = new Map<string, Bun.Glob>()
|
|
745
1046
|
function globFor(pattern: string): Bun.Glob {
|
|
746
1047
|
let g = globMemo.get(pattern)
|
|
747
|
-
if (g === undefined) globMemo.set(pattern, (g =
|
|
1048
|
+
if (g === undefined) globMemo.set(pattern, (g = taskGlob(pattern)))
|
|
748
1049
|
return g
|
|
749
1050
|
}
|
|
750
1051
|
|