@vzn/vx 0.0.646 → 0.0.649
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 +29 -22
- package/launcher.cjs +9 -2
- package/launcher.sh +2 -2
- package/package.json +5 -5
- package/schemas/cache.json +5 -5
- package/schemas/error.json +1 -1
- package/schemas/info.json +16 -2
- package/schemas/last.json +112 -5
- package/schemas/plan.json +28 -2
- package/schemas/show.json +10 -1
- package/skills/vx/SKILL.md +18 -2
- package/src/bin.ts +53 -12
- package/src/cache/archive.ts +324 -23
- package/src/cache/cache.ts +268 -53
- package/src/cache/git-inputs.ts +219 -87
- package/src/cache/index.ts +3 -2
- package/src/cache/inputs.ts +460 -78
- package/src/cache/key-fold.ts +15 -6
- package/src/cache/layer.ts +49 -9
- package/src/cache/layered-cache.ts +123 -58
- package/src/cache/linked-inputs.ts +1 -1
- package/src/cache/output-index.ts +19 -3
- package/src/cache/plugin-cache.ts +49 -23
- package/src/cache/remote-hits.ts +72 -0
- package/src/cache/remote-lane.ts +90 -14
- package/src/cache/run-history.ts +70 -23
- package/src/cache/run-store.ts +11 -10
- package/src/cache/schema.ts +25 -6
- package/src/cache/tar-stream.ts +5 -0
- package/src/cache/umask.ts +1 -2
- package/src/cli/cache.ts +58 -27
- package/src/cli/completions.ts +12 -5
- package/src/cli/docs.ts +42 -16
- package/src/cli/help.ts +58 -19
- package/src/cli/index.ts +79 -33
- package/src/cli/info.ts +12 -6
- package/src/cli/init.ts +186 -31
- package/src/cli/last.ts +439 -45
- package/src/cli/lock.ts +197 -52
- package/src/cli/plan-format.ts +91 -49
- package/src/cli/plugin-commands.ts +70 -14
- package/src/cli/plugin-templates.ts +13 -13
- package/src/cli/registry.ts +209 -0
- package/src/cli/run.ts +109 -34
- package/src/cli/select.ts +143 -46
- package/src/cli/show.ts +59 -16
- package/src/cli/task-verb.ts +16 -6
- package/src/cli/upgrade.ts +151 -49
- package/src/cli/watch-cycle.ts +10 -5
- package/src/cli/watch-filter.ts +71 -3
- package/src/cli/watch-fs.ts +22 -6
- package/src/cli/watch-judge.ts +48 -6
- package/src/cli/watch.ts +163 -42
- package/src/cli/why.ts +211 -73
- package/src/cli/workspace-config.ts +54 -15
- package/src/config.ts +13 -1
- package/src/exec/env.ts +7 -2
- package/src/exec/executor.ts +154 -10
- package/src/exec/index.ts +6 -2
- package/src/exec/kill-tree.ts +15 -0
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/proc-sample.ts +3 -1
- package/src/exec/runner.ts +253 -54
- package/src/exec/sandbox-binds.ts +7 -3
- package/src/exec/sandbox-paths.ts +1 -1
- package/src/exec/sandbox-runtime.ts +211 -24
- package/src/exec/sandbox-violations.ts +52 -20
- package/src/graph/critical-path.ts +79 -0
- package/src/graph/default-build.ts +19 -0
- package/src/graph/definition.ts +38 -0
- package/src/graph/folds.ts +38 -5
- package/src/graph/index.ts +4 -1
- package/src/graph/priorities.ts +74 -8
- package/src/graph/scheduler.ts +270 -60
- package/src/graph/task-graph.ts +228 -35
- package/src/handoff.ts +97 -9
- package/src/history/failure-mode.ts +6 -3
- package/src/history/history.ts +167 -61
- package/src/history/index.ts +2 -0
- package/src/history/metrics.ts +90 -30
- package/src/index.ts +30 -13
- package/src/orchestrator/admission.ts +10 -6
- package/src/orchestrator/affected-tasks.ts +11 -6
- package/src/orchestrator/deferred-outputs.ts +2 -0
- package/src/orchestrator/doctor.ts +134 -42
- package/src/orchestrator/execute-args.ts +4 -1
- package/src/orchestrator/execute-task.ts +130 -38
- package/src/orchestrator/fingerprint-watch.ts +1 -1
- package/src/orchestrator/graph-memo.ts +140 -0
- package/src/orchestrator/graph-stage.ts +18 -1
- package/src/orchestrator/held-servers.ts +1 -1
- package/src/orchestrator/hit-restore.ts +217 -119
- package/src/orchestrator/index.ts +4 -3
- package/src/orchestrator/local-floor.ts +119 -25
- package/src/orchestrator/local-shortcircuit.ts +28 -4
- package/src/orchestrator/lockfile-claim.ts +96 -16
- package/src/orchestrator/miss-save.ts +42 -8
- package/src/orchestrator/options.ts +8 -6
- package/src/orchestrator/output-host.ts +43 -14
- package/src/orchestrator/output-log.ts +90 -10
- package/src/orchestrator/persistent-task.ts +84 -29
- package/src/orchestrator/persistent.ts +16 -4
- package/src/orchestrator/placement.ts +185 -17
- package/src/orchestrator/plan.ts +64 -7
- package/src/orchestrator/plugin-host.ts +214 -42
- package/src/orchestrator/plugin.ts +113 -37
- package/src/orchestrator/prepare.ts +168 -30
- package/src/orchestrator/projects.ts +65 -40
- package/src/orchestrator/remote-prefetch.ts +119 -25
- package/src/orchestrator/run-artifacts.ts +95 -32
- package/src/orchestrator/run-context.ts +39 -41
- package/src/orchestrator/run-failures.ts +65 -23
- package/src/orchestrator/run-id.ts +6 -3
- package/src/orchestrator/run-keys.ts +149 -13
- package/src/orchestrator/run-live.ts +294 -0
- package/src/orchestrator/run-lock.ts +13 -7
- package/src/orchestrator/run-records.ts +35 -29
- package/src/orchestrator/run-report.ts +3 -3
- package/src/orchestrator/run-steps.ts +58 -22
- package/src/orchestrator/run-stop.ts +22 -5
- package/src/orchestrator/run.ts +331 -88
- package/src/orchestrator/sandbox-fix.ts +7 -5
- package/src/orchestrator/sandbox-request.ts +70 -20
- package/src/orchestrator/shell-verdict.ts +5 -5
- package/src/orchestrator/signals.ts +26 -1
- package/src/orchestrator/stable-keys.ts +92 -8
- package/src/orchestrator/steps.ts +2 -2
- package/src/orchestrator/task-artifacts.ts +90 -26
- package/src/orchestrator/task-hash.ts +78 -13
- package/src/orchestrator/task-log-buffer.ts +72 -36
- package/src/orchestrator/task-process.ts +7 -1
- package/src/orchestrator/telemetry-host.ts +22 -2
- package/src/orchestrator/telemetry.ts +53 -21
- package/src/orchestrator/upstream.ts +15 -0
- package/src/report/events.ts +32 -13
- package/src/report/forecast.ts +31 -9
- package/src/report/framed-output.ts +141 -50
- package/src/report/index.ts +1 -0
- package/src/report/logger.ts +170 -179
- package/src/report/path-links.ts +12 -4
- package/src/report/plain-output.ts +13 -5
- package/src/report/status-line.ts +81 -53
- package/src/report/summary.ts +114 -32
- package/src/report/terminal-signals.ts +13 -2
- package/src/settings.ts +7 -6
- package/src/util/bun-version.ts +4 -1
- package/src/util/colors.ts +4 -2
- package/src/util/config-frame.ts +41 -4
- package/src/{graph → util}/dependency-spec.ts +22 -13
- package/src/util/edit-distance.ts +13 -1
- package/src/util/env-flag.ts +9 -0
- package/src/util/errors.ts +59 -3
- package/src/util/essential-env.ts +57 -0
- package/src/util/git-ignored.ts +1 -1
- package/src/util/hash.ts +35 -0
- package/src/util/hide-env.ts +45 -5
- package/src/util/index.ts +21 -5
- package/src/util/num.ts +8 -0
- package/src/util/paths.ts +91 -21
- package/src/util/report.ts +6 -1
- package/src/util/secret-mask.ts +62 -1
- package/src/util/secrets.ts +7 -2
- package/src/util/size.ts +11 -3
- package/src/util/tail.ts +6 -1
- package/src/util/timing.ts +13 -1
- package/src/util/ui.ts +61 -3
- package/src/util/which.ts +1 -1
- package/src/util/write-file.ts +67 -0
- package/src/workspace/affected.ts +710 -107
- package/src/workspace/bracket-literal.ts +84 -0
- package/src/workspace/canonical-dir.ts +37 -0
- package/src/workspace/config-bytecode.ts +17 -11
- package/src/workspace/config-eval.ts +29 -8
- package/src/workspace/config-imports.ts +199 -58
- package/src/workspace/config-schema.ts +242 -39
- package/src/workspace/filter.ts +37 -8
- package/src/workspace/index.ts +11 -1
- package/src/workspace/json-data.ts +3 -4
- package/src/workspace/lockfile.ts +57 -8
- package/src/workspace/migration.ts +69 -18
- package/src/workspace/package-graph.ts +5 -4
- package/src/workspace/project-loader.ts +226 -37
- package/src/workspace/repo-id.ts +1 -1
- package/src/workspace/workspace.ts +176 -33
package/src/cache/inputs.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { lstatSync, readdirSync, realpathSync, rmdirSync, rmSync, statSync } fro
|
|
|
21
21
|
import { rm, rmdir } from 'node:fs/promises'
|
|
22
22
|
import type { InputsConfig, TaskConfig } from '../config.js'
|
|
23
23
|
import {
|
|
24
|
+
literalPath,
|
|
24
25
|
anyTaskGlob,
|
|
25
26
|
asTrees,
|
|
26
27
|
isExecutableMissing,
|
|
@@ -35,13 +36,16 @@ import {
|
|
|
35
36
|
shellArgv,
|
|
36
37
|
slashBraceExpansions,
|
|
37
38
|
braceAlternatives,
|
|
39
|
+
ESSENTIAL_ENV,
|
|
38
40
|
splitNegations,
|
|
39
41
|
staticPrefix,
|
|
40
42
|
stripTrailingSlash,
|
|
41
43
|
taskGlob,
|
|
42
44
|
UserError,
|
|
45
|
+
writableUnder,
|
|
46
|
+
withoutWorkspaceBins,
|
|
43
47
|
} from '../util/index.js'
|
|
44
|
-
import { GitFilesCache, runGitLsFiles } from './git-inputs.js'
|
|
48
|
+
import { GitFilesCache, listingEnv, runGitLsFiles } from './git-inputs.js'
|
|
45
49
|
import { FILE_HASH_RACY_MS, racyWindowMs } from './layer.js'
|
|
46
50
|
|
|
47
51
|
// The git side lives in git-inputs.ts; its whole public surface is
|
|
@@ -142,8 +146,8 @@ export interface ResolveInputsArgs {
|
|
|
142
146
|
* by `projectDir + '\0' + command`. Shared across a run's tasks so a
|
|
143
147
|
* project's command runs once even across build/test/lint and across
|
|
144
148
|
* the hash + sandbox-baseline resolveInputs calls. The key is sound
|
|
145
|
-
* ONLY because
|
|
146
|
-
* task's `exec.env` (`runRuntimeCommand`): two tasks with different
|
|
149
|
+
* ONLY because every probe runs in one environment, vx's essentials,
|
|
150
|
+
* never a task's `exec.env` (`runRuntimeCommand`): two tasks with different
|
|
147
151
|
* `define`s share one value. Threading the task env in would have to
|
|
148
152
|
* widen this key too, or task A's probe becomes task B's key component.
|
|
149
153
|
*/
|
|
@@ -287,6 +291,7 @@ export async function resolveInputs(args: ResolveInputsArgs): Promise<ResolvedIn
|
|
|
287
291
|
runtimeDecl,
|
|
288
292
|
args.projectDir,
|
|
289
293
|
projectBinDirs(args.projectDir, args.workspaceRoot),
|
|
294
|
+
args.workspaceRoot,
|
|
290
295
|
args.runtimeCache,
|
|
291
296
|
args.runtimeScope === undefined
|
|
292
297
|
? `${args.projectDir}\0`
|
|
@@ -296,6 +301,7 @@ export async function resolveInputs(args: ResolveInputsArgs): Promise<ResolvedIn
|
|
|
296
301
|
wsRuntimeDecl,
|
|
297
302
|
args.workspaceRoot,
|
|
298
303
|
[path.join(args.workspaceRoot, 'node_modules', '.bin')],
|
|
304
|
+
args.workspaceRoot,
|
|
299
305
|
args.workspaceRuntimeCache,
|
|
300
306
|
args.runtimeScope === undefined ? '' : `\0${args.runtimeScope}\0`,
|
|
301
307
|
),
|
|
@@ -364,6 +370,7 @@ function resolveWorkspaceFiles(args: {
|
|
|
364
370
|
// caller sees a different array and scans again.
|
|
365
371
|
let isExcluded: ((rel: string) => boolean) | undefined
|
|
366
372
|
let isPositive: ((rel: string) => boolean) | undefined
|
|
373
|
+
let mayHold: ((rel: string) => boolean) | undefined
|
|
367
374
|
const excludedByDeclaration = (rel: string): boolean =>
|
|
368
375
|
(isExcluded ??= anyTaskGlob([...WORKSPACE_ALWAYS_IGNORE, ...asTrees(negative)]))(rel)
|
|
369
376
|
// A committed file stays an input whatever output reaches it (`committedFiles`).
|
|
@@ -375,11 +382,14 @@ function resolveWorkspaceFiles(args: {
|
|
|
375
382
|
)
|
|
376
383
|
const listing: InputListing = {
|
|
377
384
|
root: args.workspaceRoot,
|
|
385
|
+
workspaceRoot: args.workspaceRoot,
|
|
378
386
|
listed: gitFiles,
|
|
379
387
|
isInput: (rel) =>
|
|
380
|
-
(isPositive ??= anyTaskGlob(asTrees(positive)))(rel) &&
|
|
388
|
+
(isPositive ??= throughLinks(positive, anyTaskGlob(asTrees(positive))))(rel) &&
|
|
381
389
|
!excludedByDeclaration(rel) &&
|
|
382
390
|
!own(rel),
|
|
391
|
+
linked: (rel) =>
|
|
392
|
+
(mayHold ??= mayHoldMatch(positive))(rel) && !excludedByDeclaration(rel) && !own(rel),
|
|
383
393
|
nested: () => false,
|
|
384
394
|
...reachOf(positive),
|
|
385
395
|
}
|
|
@@ -393,7 +403,7 @@ function resolveWorkspaceFiles(args: {
|
|
|
393
403
|
const hit = args.memo?.get(declared)
|
|
394
404
|
if (hit !== undefined && hit.snapshot === gitFiles) base = hit.result
|
|
395
405
|
if (base === undefined) {
|
|
396
|
-
isPositive ??= anyTaskGlob(asTrees(positive))
|
|
406
|
+
isPositive ??= throughLinks(positive, anyTaskGlob(asTrees(positive)))
|
|
397
407
|
base = resolveWorkspaceFilesOver(args, gitFiles, positive, isPositive, excludedByDeclaration)
|
|
398
408
|
args.memo?.set(declared, { snapshot: gitFiles, result: base })
|
|
399
409
|
}
|
|
@@ -410,12 +420,14 @@ function resolveWorkspaceFiles(args: {
|
|
|
410
420
|
if (reach.length === 0) return kept
|
|
411
421
|
// Outside the memo: an ignored file a task writes mid-run leaves the
|
|
412
422
|
// snapshot as it was.
|
|
413
|
-
isPositive ??= anyTaskGlob(asTrees(positive))
|
|
423
|
+
isPositive ??= throughLinks(positive, anyTaskGlob(asTrees(positive)))
|
|
414
424
|
const accept = isPositive
|
|
415
425
|
const extra = ignoredInputs(
|
|
416
426
|
args.workspaceRoot,
|
|
417
427
|
reach,
|
|
418
428
|
(rel) => accept(rel) && !excludedByDeclaration(rel) && !own(rel),
|
|
429
|
+
listing.linked,
|
|
430
|
+
args.gitFilesCache?.vxDirs,
|
|
419
431
|
)
|
|
420
432
|
return extra.length === 0 ? kept : mergeSorted(kept, extra)
|
|
421
433
|
})
|
|
@@ -430,14 +442,18 @@ async function resolveWorkspaceFilesOver(
|
|
|
430
442
|
excluded: (rel: string) => boolean,
|
|
431
443
|
): Promise<string[]> {
|
|
432
444
|
const candidates: string[] = []
|
|
433
|
-
for (const rel of gitFiles) {
|
|
434
|
-
if (!isPositive(rel)) continue
|
|
435
|
-
if (excluded(rel)) continue
|
|
436
|
-
candidates.push(path.resolve(args.workspaceRoot, rel))
|
|
437
|
-
}
|
|
438
445
|
// Same OID-trust shortcut as project files: a clean-per-status
|
|
439
446
|
// tracked file necessarily exists on disk.
|
|
440
447
|
const oids = args.gitFilesCache?.oidsFor(args.workspaceRoot)
|
|
448
|
+
const mayHold = mayHoldMatch(positive)
|
|
449
|
+
for (const rel of gitFiles) {
|
|
450
|
+
if (isPositive(rel)) {
|
|
451
|
+
if (!excluded(rel)) candidates.push(path.resolve(args.workspaceRoot, rel))
|
|
452
|
+
} else if (mayHold(rel) && !excluded(rel)) {
|
|
453
|
+
const abs = path.resolve(args.workspaceRoot, rel)
|
|
454
|
+
if (dirLinkInput(abs, oids)) candidates.push(abs)
|
|
455
|
+
}
|
|
456
|
+
}
|
|
441
457
|
return candidates.filter((abs) => oids?.has(abs) === true || isInputOnDisk(abs)).sort()
|
|
442
458
|
}
|
|
443
459
|
|
|
@@ -524,6 +540,16 @@ function isInputOnDisk(abs: string): boolean {
|
|
|
524
540
|
}
|
|
525
541
|
}
|
|
526
542
|
|
|
543
|
+
/** vx's values of `ESSENTIAL_ENV`: the one environment every probe gets. */
|
|
544
|
+
function probeEnv(): Record<string, string> {
|
|
545
|
+
const env: Record<string, string> = {}
|
|
546
|
+
for (const name of ESSENTIAL_ENV) {
|
|
547
|
+
const value = process.env[name]
|
|
548
|
+
if (value !== undefined) env[name] = value
|
|
549
|
+
}
|
|
550
|
+
return env
|
|
551
|
+
}
|
|
552
|
+
|
|
527
553
|
/** The task's `node_modules/.bin` directories, as `taskBinDirs` gives its PATH. */
|
|
528
554
|
function projectBinDirs(projectDir: string, workspaceRoot: string): string[] {
|
|
529
555
|
const own = path.join(projectDir, 'node_modules', '.bin')
|
|
@@ -548,9 +574,16 @@ export function answerRuntime(
|
|
|
548
574
|
command,
|
|
549
575
|
workspaceRoot,
|
|
550
576
|
[path.join(workspaceRoot, 'node_modules', '.bin')],
|
|
577
|
+
workspaceRoot,
|
|
578
|
+
undefined,
|
|
579
|
+
)
|
|
580
|
+
: runRuntimeCommand(
|
|
581
|
+
command,
|
|
582
|
+
projectDir,
|
|
583
|
+
projectBinDirs(projectDir, workspaceRoot),
|
|
584
|
+
workspaceRoot,
|
|
551
585
|
undefined,
|
|
552
586
|
)
|
|
553
|
-
: runRuntimeCommand(command, projectDir, projectBinDirs(projectDir, workspaceRoot), undefined)
|
|
554
587
|
}
|
|
555
588
|
|
|
556
589
|
function resolveEnvValues(
|
|
@@ -565,10 +598,13 @@ function resolveEnvValues(
|
|
|
565
598
|
* work — "shell is the API"). Returns trimmed stdout+stderr. A non-zero
|
|
566
599
|
* exit is a hard UserError naming the command (fail-loud, like git).
|
|
567
600
|
*
|
|
568
|
-
* The probe
|
|
569
|
-
* `
|
|
570
|
-
*
|
|
571
|
-
*
|
|
601
|
+
* The probe sees the essential environment every task sees
|
|
602
|
+
* (`ESSENTIAL_ENV`), from vx's own — never the task's `exec.env`, which
|
|
603
|
+
* describes the command, not the machine, and never the rest of vx's: a
|
|
604
|
+
* child is dumpable after its exec, so an unsandboxed task read every
|
|
605
|
+
* secret vx holds from the probe's `/proc/<pid>/environ`. One env for
|
|
606
|
+
* every probe is what lets the run-scoped memo key on (projectDir,
|
|
607
|
+
* command) alone; see `runtimeCache`.
|
|
572
608
|
*
|
|
573
609
|
* Its PATH does lead with the task's own `node_modules/.bin` directories
|
|
574
610
|
* (`binDirs`), as the task's does: `tsc --version` otherwise keyed the
|
|
@@ -579,9 +615,11 @@ async function runRuntimeCommand(
|
|
|
579
615
|
command: string,
|
|
580
616
|
cwd: string,
|
|
581
617
|
binDirs: readonly string[],
|
|
618
|
+
workspaceRoot: string,
|
|
582
619
|
owner: RuntimeMemo | undefined,
|
|
583
620
|
): Promise<string> {
|
|
584
|
-
|
|
621
|
+
// As a task's (exec/env.ts): no member's bin inherited from `npm run`.
|
|
622
|
+
const ambient = withoutWorkspaceBins(process.env['PATH'] ?? '', workspaceRoot)
|
|
585
623
|
// As the task's PATH does (exec/env.ts): a dir holding the delimiter
|
|
586
624
|
// splits into an entry relative to the probe's cwd.
|
|
587
625
|
const PATH = [...binDirs.filter((dir) => !dir.includes(path.delimiter)), ambient]
|
|
@@ -594,7 +632,7 @@ async function runRuntimeCommand(
|
|
|
594
632
|
// `node_modules/.bin`, so a dependency's `sh` bin ran every probe (J-69).
|
|
595
633
|
proc = Bun.spawn(shellArgv(command), {
|
|
596
634
|
cwd,
|
|
597
|
-
env: { ...
|
|
635
|
+
env: { ...probeEnv(), PATH },
|
|
598
636
|
stdin: 'ignore',
|
|
599
637
|
stdout: 'pipe',
|
|
600
638
|
stderr: 'pipe',
|
|
@@ -730,6 +768,7 @@ async function resolveRuntimeValues(
|
|
|
730
768
|
commands: readonly string[],
|
|
731
769
|
cwd: string,
|
|
732
770
|
binDirs: readonly string[],
|
|
771
|
+
workspaceRoot: string,
|
|
733
772
|
memo: Map<string, Promise<string>> | undefined,
|
|
734
773
|
memoKeyPrefix: string,
|
|
735
774
|
): Promise<Array<[string, string]>> {
|
|
@@ -740,7 +779,7 @@ async function resolveRuntimeValues(
|
|
|
740
779
|
const key = `${memoKeyPrefix}${cmd}`
|
|
741
780
|
let p = memo?.get(key)
|
|
742
781
|
if (p === undefined) {
|
|
743
|
-
p = runRuntimeCommand(cmd, cwd, binDirs, memo)
|
|
782
|
+
p = runRuntimeCommand(cmd, cwd, binDirs, workspaceRoot, memo)
|
|
744
783
|
memo?.set(key, p)
|
|
745
784
|
}
|
|
746
785
|
return [cmd, await p] as [string, string]
|
|
@@ -809,7 +848,7 @@ async function scanOutputs(
|
|
|
809
848
|
if (positive.length === 0) return { files: [] }
|
|
810
849
|
const excludeGlobs = [...outputExcludes(positive, 'project'), ...asTrees(negative).map(globFor)]
|
|
811
850
|
const trees = asTrees(positive)
|
|
812
|
-
refuseDirLinkedOut(args.projectDir, trees)
|
|
851
|
+
refuseDirLinkedOut(args.projectDir, trees, 'project')
|
|
813
852
|
const scanned = [
|
|
814
853
|
...(await scanUnion(
|
|
815
854
|
trees,
|
|
@@ -826,12 +865,13 @@ async function scanOutputs(
|
|
|
826
865
|
// outside the project, so any future caller reaching it by another route (a
|
|
827
866
|
// programmatic embedder, a config source that skips the loader) is contained
|
|
828
867
|
// by construction.
|
|
829
|
-
|
|
868
|
+
const contained = containedIn(args.projectDir, scanned)
|
|
869
|
+
return { files: outsideNested(args.nestedProjectDirs, contained).sort() }
|
|
830
870
|
}
|
|
831
871
|
|
|
832
872
|
/**
|
|
833
873
|
* Whether git tracks a file under `dir`. No clean deletes a committed (or
|
|
834
|
-
* `git add`ed) file an output glob reaches (
|
|
874
|
+
* `git add`ed) file an output glob reaches (2026-10-14: "never
|
|
835
875
|
* delete committed file"); the task may rewrite it and a restore writes the
|
|
836
876
|
* bytes it left. It stays in the task's key, so those bytes follow from
|
|
837
877
|
* the key. Turbo and Nx never clean an output at all.
|
|
@@ -846,16 +886,24 @@ export function committedFiles(
|
|
|
846
886
|
/**
|
|
847
887
|
* The literal head of each output glob, walked: the scan follows a link
|
|
848
888
|
* there (`dist -> ../elsewhere` for `dist/**`) and only there. A link
|
|
849
|
-
* below it is an output, kept as the link (`archive.ts`).
|
|
889
|
+
* below it is an output, kept as the link (`archive.ts`). One into `.git`
|
|
890
|
+
* or `.vx` is refused in either scope: for the root project and for
|
|
891
|
+
* workspace outputs those are inside, and the clean deleted the
|
|
892
|
+
* repository through `dist -> .git`. One out of a workspace output's root
|
|
893
|
+
* is left to `containedIn`.
|
|
850
894
|
*/
|
|
851
|
-
function refuseDirLinkedOut(
|
|
895
|
+
function refuseDirLinkedOut(
|
|
896
|
+
root: string,
|
|
897
|
+
trees: readonly string[],
|
|
898
|
+
scope: 'project' | 'workspace',
|
|
899
|
+
): void {
|
|
852
900
|
let realRoot: string | undefined
|
|
853
901
|
for (const tree of trees) {
|
|
854
|
-
let at =
|
|
902
|
+
let at = root
|
|
855
903
|
for (const part of staticPrefix(tree).split('/')) {
|
|
856
904
|
if (part === '' || part === '.') continue
|
|
857
905
|
at = path.join(at, part)
|
|
858
|
-
if (!isInside(
|
|
906
|
+
if (!isInside(root, at)) break
|
|
859
907
|
let st
|
|
860
908
|
try {
|
|
861
909
|
st = lstatSync(at, { throwIfNoEntry: false })
|
|
@@ -871,18 +919,41 @@ function refuseDirLinkedOut(projectDir: string, trees: readonly string[]): void
|
|
|
871
919
|
} catch {
|
|
872
920
|
break
|
|
873
921
|
}
|
|
874
|
-
realRoot ??= realOr(
|
|
922
|
+
realRoot ??= realOr(root)
|
|
875
923
|
if (!isInside(realRoot, real)) {
|
|
924
|
+
if (scope === 'workspace') break
|
|
876
925
|
throw new UserError(
|
|
877
|
-
`${at} is a symbolic link to ${real}, outside ${
|
|
926
|
+
`${at} is a symbolic link to ${real}, outside ${root} — vx never cleans, saves ` +
|
|
878
927
|
'or restores declared outputs through a link that leaves the project. Remove the ' +
|
|
879
928
|
'link and re-run, or stop declaring outputs under it.',
|
|
880
929
|
)
|
|
881
930
|
}
|
|
931
|
+
if (inMetadata(real, realRoot)) {
|
|
932
|
+
throw new UserError(
|
|
933
|
+
`${at} is a symbolic link to ${real}, Git's or vx's metadata — vx never cleans, ` +
|
|
934
|
+
'saves or restores declared outputs through a link into .git or .vx. Remove the ' +
|
|
935
|
+
'link and re-run, or stop declaring outputs under it.',
|
|
936
|
+
)
|
|
937
|
+
}
|
|
882
938
|
}
|
|
883
939
|
}
|
|
884
940
|
}
|
|
885
941
|
|
|
942
|
+
/**
|
|
943
|
+
* Whether `real`, under `realRoot`, is in a `.git` there (`writableUnder`'s
|
|
944
|
+
* rule) or in its `.vx`. Asked only of where a link landed: a build's own
|
|
945
|
+
* `dist/.git/**` is an output (2026-10-17).
|
|
946
|
+
*/
|
|
947
|
+
function inMetadata(real: string, realRoot: string): boolean {
|
|
948
|
+
if (!writableUnder(real, realRoot)) return true
|
|
949
|
+
return (
|
|
950
|
+
real
|
|
951
|
+
.slice(realRoot.length + 1)
|
|
952
|
+
.split(path.sep, 1)[0]!
|
|
953
|
+
.toLowerCase() === '.vx'
|
|
954
|
+
)
|
|
955
|
+
}
|
|
956
|
+
|
|
886
957
|
function realOr(p: string): string {
|
|
887
958
|
try {
|
|
888
959
|
return realpathSync(p)
|
|
@@ -940,6 +1011,57 @@ function containedIn(root: string, paths: readonly string[]): string[] {
|
|
|
940
1011
|
return lexical.filter((_p, i) => contained.has(lexDirs[i]!))
|
|
941
1012
|
}
|
|
942
1013
|
|
|
1014
|
+
/**
|
|
1015
|
+
* `committed`, also asked by each path's real directory: the output walk
|
|
1016
|
+
* follows a link at a glob's head that stays inside `root` (X-88), so
|
|
1017
|
+
* `public/logo.txt` may be `static/logo.txt`, the only name git lists.
|
|
1018
|
+
* Asked by the lexical name alone, an untracked `public -> static` link
|
|
1019
|
+
* let the clean delete a committed file. One realpath per directory.
|
|
1020
|
+
*/
|
|
1021
|
+
function committedPastLinks(
|
|
1022
|
+
root: string,
|
|
1023
|
+
committed: (abs: string) => boolean,
|
|
1024
|
+
): (abs: string) => boolean {
|
|
1025
|
+
let realRoot: string | undefined
|
|
1026
|
+
const canonical = new Map<string, string | null>()
|
|
1027
|
+
return (abs) => {
|
|
1028
|
+
if (committed(abs)) return true
|
|
1029
|
+
const dir = path.dirname(abs)
|
|
1030
|
+
let canon = canonical.get(dir)
|
|
1031
|
+
if (canon === undefined) {
|
|
1032
|
+
const real = realOrNull(dir)
|
|
1033
|
+
realRoot ??= realOrNull(root) ?? root
|
|
1034
|
+
canon =
|
|
1035
|
+
real === null || !isInside(realRoot, real)
|
|
1036
|
+
? null
|
|
1037
|
+
: path.join(root, path.relative(realRoot, real))
|
|
1038
|
+
canonical.set(dir, canon)
|
|
1039
|
+
}
|
|
1040
|
+
return canon !== null && canon !== dir && committed(path.join(canon, path.basename(abs)))
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
/**
|
|
1045
|
+
* Drop the files whose real directory is in a nested project: a link at a
|
|
1046
|
+
* glob's head that stays in the project is followed (X-88), and the
|
|
1047
|
+
* lexical nested-project filter cannot see where it lands.
|
|
1048
|
+
*/
|
|
1049
|
+
function outsideNested(nestedDirs: readonly string[], files: string[]): string[] {
|
|
1050
|
+
if (nestedDirs.length === 0 || files.length === 0) return files
|
|
1051
|
+
const realNested = nestedDirs.map((d) => realOrNull(d) ?? d)
|
|
1052
|
+
const verdict = new Map<string, boolean>()
|
|
1053
|
+
return files.filter((f) => {
|
|
1054
|
+
const dir = path.dirname(f)
|
|
1055
|
+
let ok = verdict.get(dir)
|
|
1056
|
+
if (ok === undefined) {
|
|
1057
|
+
const real = realOrNull(dir) ?? dir
|
|
1058
|
+
ok = !realNested.some((n) => isInside(n, real))
|
|
1059
|
+
verdict.set(dir, ok)
|
|
1060
|
+
}
|
|
1061
|
+
return ok
|
|
1062
|
+
})
|
|
1063
|
+
}
|
|
1064
|
+
|
|
943
1065
|
/** Is `abs` the directory `dir` itself or something beneath it? */
|
|
944
1066
|
function isInside(dir: string, abs: string): boolean {
|
|
945
1067
|
if (abs === dir) return true
|
|
@@ -972,9 +1094,18 @@ export async function cleanOutputs(args: {
|
|
|
972
1094
|
*/
|
|
973
1095
|
resolved?: readonly string[] | undefined
|
|
974
1096
|
}): Promise<string[]> {
|
|
975
|
-
const committed =
|
|
976
|
-
|
|
977
|
-
|
|
1097
|
+
const committed = committedPastLinks(
|
|
1098
|
+
args.projectDir,
|
|
1099
|
+
args.committed ?? committedFiles(undefined, args.projectDir),
|
|
1100
|
+
)
|
|
1101
|
+
sweepRestoreTemps(args.projectDir, args.outputs, committed)
|
|
1102
|
+
// An empty directory under the outputs is one too (a save keeps it): one
|
|
1103
|
+
// an older build left would otherwise be saved by this run.
|
|
1104
|
+
const tree =
|
|
1105
|
+
args.resolved === undefined
|
|
1106
|
+
? await resolveOutputTree(args)
|
|
1107
|
+
: { files: args.resolved, emptyDirs: [] }
|
|
1108
|
+
const matched = tree.files
|
|
978
1109
|
// `force: true` makes rm tolerate ENOENT (e.g. when two output
|
|
979
1110
|
// globs overlap and a sibling already deleted a path mid-iteration).
|
|
980
1111
|
// A symlink is unlinked, never followed.
|
|
@@ -982,7 +1113,13 @@ export async function cleanOutputs(args: {
|
|
|
982
1113
|
matched.filter((f) => !committed(f)),
|
|
983
1114
|
args.projectDir,
|
|
984
1115
|
)
|
|
985
|
-
await pruneEmptiedDirs(
|
|
1116
|
+
await pruneEmptiedDirs(
|
|
1117
|
+
args.projectDir,
|
|
1118
|
+
removed,
|
|
1119
|
+
pruneScope(args.projectDir, args.outputs),
|
|
1120
|
+
undefined,
|
|
1121
|
+
tree.emptyDirs,
|
|
1122
|
+
)
|
|
986
1123
|
// Project-relative posix paths the outputs reach, for the caller's
|
|
987
1124
|
// GitFilesCache.markOutputsChanged: a committed one kept here is the
|
|
988
1125
|
// task's to rewrite or remove, so its index OID no longer vouches for it.
|
|
@@ -999,13 +1136,22 @@ export async function cleanOutputs(args: {
|
|
|
999
1136
|
* whose writer is alive, this process included, is left: it may be
|
|
1000
1137
|
* mid-restore.
|
|
1001
1138
|
*/
|
|
1002
|
-
export function sweepRestoreTemps(
|
|
1139
|
+
export function sweepRestoreTemps(
|
|
1140
|
+
base: string,
|
|
1141
|
+
outputs: readonly string[],
|
|
1142
|
+
committed: (abs: string) => boolean = () => false,
|
|
1143
|
+
): void {
|
|
1003
1144
|
const dirs = new Set<string>()
|
|
1004
1145
|
for (const g of outputs) {
|
|
1005
1146
|
if (g.startsWith('!') || !isLiteralPattern(g)) continue
|
|
1006
1147
|
dirs.add(path.dirname(path.resolve(base, stripTrailingSlash(normalizeGlob(g)))))
|
|
1007
1148
|
}
|
|
1149
|
+
// A directory that resolves out of `base` is not the outputs': the link
|
|
1150
|
+
// refusal runs after this sweep, and a delete never follows a link out.
|
|
1151
|
+
let realBase: string | undefined
|
|
1008
1152
|
for (const dir of dirs) {
|
|
1153
|
+
const real = realOrNull(dir)
|
|
1154
|
+
if (real === null || !isInside((realBase ??= realOrNull(base) ?? base), real)) continue
|
|
1009
1155
|
let names: string[]
|
|
1010
1156
|
try {
|
|
1011
1157
|
names = readdirSync(dir)
|
|
@@ -1017,6 +1163,8 @@ export function sweepRestoreTemps(base: string, outputs: readonly string[]): voi
|
|
|
1017
1163
|
if (m === null) continue
|
|
1018
1164
|
const pid = Number.parseInt(m[1]!, 36)
|
|
1019
1165
|
if (pid === process.pid || writerAlive(pid)) continue
|
|
1166
|
+
// vx never deletes a committed file, whatever its name.
|
|
1167
|
+
if (committed(path.join(dir, name))) continue
|
|
1020
1168
|
rmSync(path.join(dir, name), { force: true })
|
|
1021
1169
|
}
|
|
1022
1170
|
}
|
|
@@ -1067,6 +1215,8 @@ export async function cleanOutputPaths(args: {
|
|
|
1067
1215
|
committed?: (abs: string) => boolean
|
|
1068
1216
|
/** Directories the prune leaves standing even when emptied (absolute). */
|
|
1069
1217
|
keep?: ReadonlySet<string>
|
|
1218
|
+
/** Empty directories to remove too (project-relative). */
|
|
1219
|
+
emptyDirs?: readonly string[]
|
|
1070
1220
|
}): Promise<void> {
|
|
1071
1221
|
const committed = args.committed ?? committedFiles(undefined, args.projectDir)
|
|
1072
1222
|
const files = args.rels.map((r) => path.resolve(args.projectDir, r)).filter((f) => !committed(f))
|
|
@@ -1075,6 +1225,7 @@ export async function cleanOutputPaths(args: {
|
|
|
1075
1225
|
await removeAll(files, args.projectDir),
|
|
1076
1226
|
pruneScope(args.projectDir, args.outputs),
|
|
1077
1227
|
args.keep,
|
|
1228
|
+
args.emptyDirs?.map((r) => path.resolve(args.projectDir, r)),
|
|
1078
1229
|
)
|
|
1079
1230
|
}
|
|
1080
1231
|
|
|
@@ -1231,7 +1382,8 @@ async function removeAll(all: readonly string[], root: string): Promise<string[]
|
|
|
1231
1382
|
* that skipped `dist -> real-out` left the last run's files for the next
|
|
1232
1383
|
* entry to save (X-88). One that leaves the project is never deleted
|
|
1233
1384
|
* through (X-5); `cleanOutputPaths` takes recorded rows, not the resolver's
|
|
1234
|
-
* contained set, so the check is here too
|
|
1385
|
+
* contained set, so the check is here too, and so is the one into
|
|
1386
|
+
* `.git` or `.vx`.
|
|
1235
1387
|
*/
|
|
1236
1388
|
function insideRoot(files: readonly string[], root: string): string[] {
|
|
1237
1389
|
// Resolved only once a directory exists to compare: after a clean pruned
|
|
@@ -1246,7 +1398,14 @@ function insideRoot(files: readonly string[], root: string): string[] {
|
|
|
1246
1398
|
// A directory already gone has nothing to delete through; its path
|
|
1247
1399
|
// stays so the prune still reaches the parents it emptied.
|
|
1248
1400
|
const r = realOrNull(dir)
|
|
1249
|
-
|
|
1401
|
+
if (r === null) ok = true
|
|
1402
|
+
else {
|
|
1403
|
+
realRoot ??= realOrNull(root) ?? root
|
|
1404
|
+
// Where no link moved it, a `.git` is the build's own (inMetadata).
|
|
1405
|
+
ok =
|
|
1406
|
+
isInside(realRoot, r) &&
|
|
1407
|
+
(path.relative(realRoot, r) === path.relative(root, dir) || !inMetadata(r, realRoot))
|
|
1408
|
+
}
|
|
1250
1409
|
own.set(dir, ok)
|
|
1251
1410
|
}
|
|
1252
1411
|
return ok
|
|
@@ -1254,11 +1413,11 @@ function insideRoot(files: readonly string[], root: string): string[] {
|
|
|
1254
1413
|
}
|
|
1255
1414
|
|
|
1256
1415
|
/**
|
|
1257
|
-
* Remove the directories a clean emptied,
|
|
1416
|
+
* Remove the directories a clean emptied, and the `emptyDirs` it found
|
|
1417
|
+
* (an older build's, which a save would keep), bottom-up, never `root` itself.
|
|
1258
1418
|
* A restore writes files where the cached entry has them; a directory left
|
|
1259
1419
|
* standing where the entry holds a FILE of the same name (a task that once
|
|
1260
|
-
* wrote `dist/out/…` and now writes `dist/out`) blocks the rename
|
|
1261
|
-
* empty directory is not an output anyone declared. A directory that still
|
|
1420
|
+
* wrote `dist/out/…` and now writes `dist/out`) blocks the rename. A directory that still
|
|
1262
1421
|
* holds something — a stray the globs do not cover — stays, and the restore
|
|
1263
1422
|
* says so if it is in the way. With a `scope`, only a directory inside it
|
|
1264
1423
|
* goes (`pruneScope`).
|
|
@@ -1268,6 +1427,7 @@ async function pruneEmptiedDirs(
|
|
|
1268
1427
|
removed: readonly string[],
|
|
1269
1428
|
scope?: PruneScope,
|
|
1270
1429
|
keep?: ReadonlySet<string>,
|
|
1430
|
+
emptyDirs: readonly string[] = [],
|
|
1271
1431
|
): Promise<void> {
|
|
1272
1432
|
const rootResolved = path.resolve(root)
|
|
1273
1433
|
// LEVEL ORDER, not a walk-up per directory. A parent is attempted only
|
|
@@ -1280,7 +1440,7 @@ async function pruneEmptiedDirs(
|
|
|
1280
1440
|
// standing when a sibling's turn came first; the same walk-up without
|
|
1281
1441
|
// the memo is correct and 60 ms, because every child re-attempts the
|
|
1282
1442
|
// shared parent. This is correct at 44 ms.
|
|
1283
|
-
let level = new Set(removed.map((f) => path.dirname(f)))
|
|
1443
|
+
let level = new Set([...removed.map((f) => path.dirname(f)), ...emptyDirs])
|
|
1284
1444
|
while (level.size > 0) {
|
|
1285
1445
|
const parents = new Set<string>()
|
|
1286
1446
|
const dirs = [...level].filter(
|
|
@@ -1332,8 +1492,10 @@ function pruneScope(base: string, outputs: readonly string[]): PruneScope {
|
|
|
1332
1492
|
const scope: PruneScope = { below: [], atOrBelow: [] }
|
|
1333
1493
|
for (const g of outputs) {
|
|
1334
1494
|
if (g.startsWith('!')) continue
|
|
1335
|
-
|
|
1336
|
-
|
|
1495
|
+
// `literalPath`, as `asTrees` reads it: an escaped `out/\[x\]` is one path.
|
|
1496
|
+
const lit = literalPath(normalizeGlob(g))
|
|
1497
|
+
if (lit !== null) {
|
|
1498
|
+
scope.atOrBelow.push(path.resolve(base, stripTrailingSlash(lit)))
|
|
1337
1499
|
continue
|
|
1338
1500
|
}
|
|
1339
1501
|
const prefix = staticPrefix(g)
|
|
@@ -1356,14 +1518,21 @@ function inScope(dir: string, scope: PruneScope): boolean {
|
|
|
1356
1518
|
* with NO project-dir exclusion: workspace outputs are the documented
|
|
1357
1519
|
* boundary escape hatch.
|
|
1358
1520
|
*/
|
|
1359
|
-
export async function resolveWorkspaceOutputs(
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1521
|
+
export async function resolveWorkspaceOutputs(
|
|
1522
|
+
args: {
|
|
1523
|
+
workspaceRoot: string
|
|
1524
|
+
outputs: readonly string[]
|
|
1525
|
+
},
|
|
1526
|
+
emptyDirs?: Set<string>,
|
|
1527
|
+
): Promise<string[]> {
|
|
1363
1528
|
const { positive, negative } = splitNegations(args.outputs)
|
|
1364
1529
|
if (positive.length === 0) return []
|
|
1365
1530
|
const excludeGlobs = [...outputExcludes(positive, 'workspace'), ...asTrees(negative).map(globFor)]
|
|
1366
|
-
const
|
|
1531
|
+
const trees = asTrees(positive)
|
|
1532
|
+
refuseDirLinkedOut(args.workspaceRoot, trees, 'workspace')
|
|
1533
|
+
const scanned = [
|
|
1534
|
+
...(await scanUnion(trees, excludeGlobs, args.workspaceRoot, undefined, emptyDirs)),
|
|
1535
|
+
]
|
|
1367
1536
|
// Same containment as the project twin, anchored one level out. These globs
|
|
1368
1537
|
// deliberately ignore PROJECT boundaries — that is the escape hatch — but
|
|
1369
1538
|
// escaping the WORKSPACE was never part of it, and `cleanWorkspaceOutputs`
|
|
@@ -1371,6 +1540,16 @@ export async function resolveWorkspaceOutputs(args: {
|
|
|
1371
1540
|
return containedIn(args.workspaceRoot, scanned).sort()
|
|
1372
1541
|
}
|
|
1373
1542
|
|
|
1543
|
+
/** `resolveOutputTree` for `outputs.workspaceFiles`: the files and the empty directories. */
|
|
1544
|
+
export async function resolveWorkspaceOutputTree(args: {
|
|
1545
|
+
workspaceRoot: string
|
|
1546
|
+
outputs: readonly string[]
|
|
1547
|
+
}): Promise<{ files: string[]; emptyDirs: string[] }> {
|
|
1548
|
+
const emptyDirs = new Set<string>()
|
|
1549
|
+
const files = await resolveWorkspaceOutputs(args, emptyDirs)
|
|
1550
|
+
return { files, emptyDirs: containedIn(args.workspaceRoot, [...emptyDirs]).sort() }
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1374
1553
|
/**
|
|
1375
1554
|
* `cleanOutputs` for the workspace-output namespace: wipe every file
|
|
1376
1555
|
* currently matching the declared root-relative globs. Same contract
|
|
@@ -1386,9 +1565,16 @@ export async function cleanWorkspaceOutputs(args: {
|
|
|
1386
1565
|
/** `cleanOutputs`'s `resolved`, from `resolveWorkspaceOutputs`. */
|
|
1387
1566
|
resolved?: readonly string[] | undefined
|
|
1388
1567
|
}): Promise<string[]> {
|
|
1389
|
-
const committed =
|
|
1390
|
-
|
|
1391
|
-
|
|
1568
|
+
const committed = committedPastLinks(
|
|
1569
|
+
args.workspaceRoot,
|
|
1570
|
+
args.committed ?? committedFiles(undefined, args.workspaceRoot),
|
|
1571
|
+
)
|
|
1572
|
+
sweepRestoreTemps(args.workspaceRoot, args.outputs, committed)
|
|
1573
|
+
const tree =
|
|
1574
|
+
args.resolved === undefined
|
|
1575
|
+
? await resolveWorkspaceOutputTree(args)
|
|
1576
|
+
: { files: args.resolved, emptyDirs: [] }
|
|
1577
|
+
const matched = tree.files
|
|
1392
1578
|
await pruneEmptiedDirs(
|
|
1393
1579
|
args.workspaceRoot,
|
|
1394
1580
|
await removeAll(
|
|
@@ -1396,6 +1582,8 @@ export async function cleanWorkspaceOutputs(args: {
|
|
|
1396
1582
|
args.workspaceRoot,
|
|
1397
1583
|
),
|
|
1398
1584
|
pruneScope(args.workspaceRoot, args.outputs),
|
|
1585
|
+
undefined,
|
|
1586
|
+
tree.emptyDirs,
|
|
1399
1587
|
)
|
|
1400
1588
|
return matched.map((f) => relPosix(args.workspaceRoot, f))
|
|
1401
1589
|
}
|
|
@@ -1456,7 +1644,7 @@ function lowerBound(files: readonly string[], key: string): number {
|
|
|
1456
1644
|
* The entries a declaration names outright that git answers nothing for: a
|
|
1457
1645
|
* literal, or a glob's fixed prefix (`dist` of `dist/**`), with no listed
|
|
1458
1646
|
* path at or under it. Such a path is gitignored or absent, and naming it
|
|
1459
|
-
* is asking for it (
|
|
1647
|
+
* is asking for it (2026-10-16), so {@link ignoredInputs} reads it
|
|
1460
1648
|
* from disk. A broad glob (`**`, or `src/**` over a listed `src`) reaches
|
|
1461
1649
|
* no ignored file: `node_modules` is never walked.
|
|
1462
1650
|
*/
|
|
@@ -1510,20 +1698,28 @@ function outright(reach: { prefixes: readonly string[]; literals: readonly strin
|
|
|
1510
1698
|
* The files on disk under the {@link ignoredReach} entries that `accept`
|
|
1511
1699
|
* keeps, sorted and absolute. A file is hashed from disk like any untracked
|
|
1512
1700
|
* one; nothing here is stored or restored. `node_modules` is no special
|
|
1513
|
-
* case (
|
|
1701
|
+
* case (2026-10-16): an ignored path named outright is an input,
|
|
1514
1702
|
* a generated client (`node_modules/.prisma/client/**`) too; skipping
|
|
1515
1703
|
* installs here folded such an entry to nothing, silently. lstat, not
|
|
1516
1704
|
* `exists()`: that answers false for a directory. Never memoized: a
|
|
1517
|
-
* dependency writes these mid-run.
|
|
1705
|
+
* dependency writes these mid-run. Nothing under `vxDirs` (the run's cache
|
|
1706
|
+
* directory) is read: an ignored prefix over a `cache.dir` there walked the
|
|
1707
|
+
* index each run writes, and the task never hit. The walk does not follow
|
|
1708
|
+
* links, so a link to a directory a glob may reach below (`linked`) is an
|
|
1709
|
+
* input itself, as a listed one is (`dirLinkInput`).
|
|
1518
1710
|
*/
|
|
1519
1711
|
function ignoredInputs(
|
|
1520
1712
|
root: string,
|
|
1521
1713
|
reach: readonly string[],
|
|
1522
1714
|
accept: (rel: string) => boolean,
|
|
1715
|
+
linked: (rel: string) => boolean,
|
|
1716
|
+
vxDirs: readonly string[] = [],
|
|
1523
1717
|
): string[] {
|
|
1524
1718
|
const found = new Set<string>()
|
|
1719
|
+
const vxOwn = (abs: string) => vxDirs.some((d) => abs === d || abs.startsWith(d + path.sep))
|
|
1525
1720
|
for (const entry of reach) {
|
|
1526
1721
|
const abs = path.resolve(root, entry)
|
|
1722
|
+
if (vxOwn(abs)) continue
|
|
1527
1723
|
const st = lstatSync(abs, { throwIfNoEntry: false })
|
|
1528
1724
|
if (st === undefined) continue
|
|
1529
1725
|
if (!st.isDirectory()) {
|
|
@@ -1537,9 +1733,11 @@ function ignoredInputs(
|
|
|
1537
1733
|
dot: true,
|
|
1538
1734
|
})) {
|
|
1539
1735
|
const full = `${entry}/${rel.split(path.sep).join('/')}`
|
|
1540
|
-
if (!accept(full)) continue
|
|
1541
1736
|
const file = path.join(abs, rel)
|
|
1542
|
-
if (
|
|
1737
|
+
if (vxOwn(file)) continue
|
|
1738
|
+
if (accept(full)) {
|
|
1739
|
+
if (isInputOnDisk(file)) found.add(file)
|
|
1740
|
+
} else if (linked(full) && dirLinkInput(file, undefined)) found.add(file)
|
|
1543
1741
|
}
|
|
1544
1742
|
}
|
|
1545
1743
|
return [...found].sort()
|
|
@@ -1606,6 +1804,8 @@ interface FilesPlan {
|
|
|
1606
1804
|
ownOutput: (rel: string) => boolean
|
|
1607
1805
|
positiveGlobs: Bun.Glob[]
|
|
1608
1806
|
isPositive: (rel: string) => boolean
|
|
1807
|
+
/** Whether a positive glob may match a path under `rel` (`mayHoldMatch`). */
|
|
1808
|
+
mayHold: (rel: string) => boolean
|
|
1609
1809
|
/** The literal entries, each naming one path (see `unanswered`). */
|
|
1610
1810
|
literals: string[]
|
|
1611
1811
|
/** The directories a positive entry reaches; see `InputListing`. */
|
|
@@ -1618,9 +1818,9 @@ interface FilesPlan {
|
|
|
1618
1818
|
*/
|
|
1619
1819
|
verdicts: Map<string, boolean>
|
|
1620
1820
|
/**
|
|
1621
|
-
* Whether a path is
|
|
1622
|
-
* tracks
|
|
1623
|
-
*
|
|
1821
|
+
* Whether a path is under the task's own outputs, and no exclude takes it
|
|
1822
|
+
* back: one git tracks is an input (`committedFiles`) whether or not a
|
|
1823
|
+
* positive glob reaches it, since a hit writes the artifact's copy over it.
|
|
1624
1824
|
*/
|
|
1625
1825
|
ownOnly: Map<string, boolean>
|
|
1626
1826
|
}
|
|
@@ -1651,8 +1851,9 @@ function filesPlan(
|
|
|
1651
1851
|
else positive.push(entry)
|
|
1652
1852
|
}
|
|
1653
1853
|
}
|
|
1854
|
+
// Outputs alone still make a plan: a committed file under them is keyed.
|
|
1654
1855
|
const plan =
|
|
1655
|
-
positive.length === 0
|
|
1856
|
+
positive.length === 0 && splitNegations(ownOutputs).positive.length === 0
|
|
1656
1857
|
? null
|
|
1657
1858
|
: {
|
|
1658
1859
|
positive,
|
|
@@ -1662,7 +1863,8 @@ function filesPlan(
|
|
|
1662
1863
|
// stays an input: a tracked file under `dist` the build reads (A-44).
|
|
1663
1864
|
ownOutput: outputMatcher(ownOutputs),
|
|
1664
1865
|
positiveGlobs: asTrees(positive).map(globFor),
|
|
1665
|
-
isPositive: anyTaskGlob(asTrees(positive)),
|
|
1866
|
+
isPositive: throughLinks(positive, anyTaskGlob(asTrees(positive))),
|
|
1867
|
+
mayHold: mayHoldMatch(positive),
|
|
1666
1868
|
...reachOf(positive),
|
|
1667
1869
|
verdicts: new Map<string, boolean>(),
|
|
1668
1870
|
ownOnly: new Map<string, boolean>(),
|
|
@@ -1671,17 +1873,88 @@ function filesPlan(
|
|
|
1671
1873
|
return plan
|
|
1672
1874
|
}
|
|
1673
1875
|
|
|
1876
|
+
/**
|
|
1877
|
+
* `isPositive`, and each path a glob's static directory passes through:
|
|
1878
|
+
* git lists a linked directory as one path, the link, so `lib/**` over
|
|
1879
|
+
* `lib -> ../shared` matched nothing, and an edit behind it kept the key
|
|
1880
|
+
* the task's last bytes were saved under (2026-10-25). The link is then
|
|
1881
|
+
* an input, and its key includes every file behind it.
|
|
1882
|
+
*/
|
|
1883
|
+
function throughLinks(
|
|
1884
|
+
positive: readonly string[],
|
|
1885
|
+
isPositive: (rel: string) => boolean,
|
|
1886
|
+
): (rel: string) => boolean {
|
|
1887
|
+
const through = new Set<string>()
|
|
1888
|
+
// Each brace alternative by its own prefix: whole, `{lib,src}/**` has
|
|
1889
|
+
// none, and a file behind `lib -> ../shared` was no input.
|
|
1890
|
+
for (const g of positive.flatMap(braceAlternatives)) {
|
|
1891
|
+
const prefix = staticPrefix(g)
|
|
1892
|
+
if (prefix === '.' || prefix === '' || path.isAbsolute(prefix)) continue
|
|
1893
|
+
const parts = prefix.split('/')
|
|
1894
|
+
for (let i = 1; i <= parts.length; i++) through.add(parts.slice(0, i).join('/'))
|
|
1895
|
+
}
|
|
1896
|
+
return through.size === 0 ? isPositive : (rel) => isPositive(rel) || through.has(rel)
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
/**
|
|
1900
|
+
* Whether a positive glob may match a path under `rel`, by each
|
|
1901
|
+
* alternative's fixed prefix and depth (a `**` reaches any depth). A link
|
|
1902
|
+
* below a glob's fixed prefix is listed as one path too: `src/**\/*.txt`
|
|
1903
|
+
* over `src/v -> ../shared` matched the link's name, not what the task
|
|
1904
|
+
* read through it, and an edit there was a hit (2026-10-28). Such a path
|
|
1905
|
+
* that is a link to a directory is an input (`dirLinkInput`). Generous on
|
|
1906
|
+
* purpose: a wrong yes costs one lookup, and folds a linked directory.
|
|
1907
|
+
*/
|
|
1908
|
+
function mayHoldMatch(positive: readonly string[]): (rel: string) => boolean {
|
|
1909
|
+
const reach = asTrees(positive.flatMap(braceAlternatives)).map((g) => {
|
|
1910
|
+
const prefix = staticPrefix(g)
|
|
1911
|
+
return {
|
|
1912
|
+
prefix: prefix === '.' ? '' : `${prefix}/`,
|
|
1913
|
+
deep: g.includes('**'),
|
|
1914
|
+
depth: g.split('/').length,
|
|
1915
|
+
}
|
|
1916
|
+
})
|
|
1917
|
+
return (rel) => {
|
|
1918
|
+
let depth: number | undefined
|
|
1919
|
+
for (const r of reach) {
|
|
1920
|
+
if (!rel.startsWith(r.prefix) || rel.length === r.prefix.length) continue
|
|
1921
|
+
if (r.deep || (depth ??= rel.split('/').length) < r.depth) return true
|
|
1922
|
+
}
|
|
1923
|
+
return false
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
/**
|
|
1928
|
+
* Whether `abs`, a path a glob may reach below, is a link to a directory.
|
|
1929
|
+
* A trusted index OID answers for a tracked file with no syscall: only a
|
|
1930
|
+
* link's (`120000:`) or an untracked path's is looked at.
|
|
1931
|
+
*/
|
|
1932
|
+
function dirLinkInput(abs: string, oids: ReadonlyMap<string, string> | undefined): boolean {
|
|
1933
|
+
const oid = oids?.get(abs)
|
|
1934
|
+
if (oid !== undefined && !oid.startsWith('120000:')) return false
|
|
1935
|
+
try {
|
|
1936
|
+
return lstatSync(abs).isSymbolicLink() && statSync(abs).isDirectory()
|
|
1937
|
+
} catch {
|
|
1938
|
+
return false
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1674
1942
|
/**
|
|
1675
1943
|
* Whether a changed path is one of a task's declared file inputs:
|
|
1676
1944
|
* `projectRel` (the path relative to the project that owns it) against
|
|
1677
1945
|
* `files`, `workspaceRel` (relative to the workspace root) against
|
|
1678
1946
|
* `workspaceFiles`; null skips that half, by the globs and `!` exclusions (a path under
|
|
1679
|
-
* the task's own outputs counts: a committed one is keyed). `--affected` seeds a task with it (
|
|
1947
|
+
* the task's own outputs counts: a committed one is keyed). `--affected` seeds a task with it (2026-10-04).
|
|
1680
1948
|
*/
|
|
1681
1949
|
export function declaresInput(
|
|
1682
1950
|
task: TaskConfig,
|
|
1683
1951
|
projectRel: string | null,
|
|
1684
1952
|
workspaceRel: string | null,
|
|
1953
|
+
/**
|
|
1954
|
+
* The path is a link to a directory (or was one): a glob that may reach
|
|
1955
|
+
* below it keys what is behind it, as `mayHoldMatch` says at key time.
|
|
1956
|
+
*/
|
|
1957
|
+
dirLink = false,
|
|
1685
1958
|
): boolean {
|
|
1686
1959
|
const inputs = task.inputs
|
|
1687
1960
|
if (inputs === undefined) return false
|
|
@@ -1691,10 +1964,13 @@ export function declaresInput(
|
|
|
1691
1964
|
// lists: either way it may be an input, so it counts.
|
|
1692
1965
|
const plan = filesPlan(inputs.files, task.cache?.outputs.files ?? [])
|
|
1693
1966
|
if (plan !== null && (inPlan(plan, projectRel) || ownOnly(plan, projectRel))) return true
|
|
1967
|
+
if (plan !== null && dirLink && plan.mayHold(projectRel) && !plan.isExcluded(projectRel)) {
|
|
1968
|
+
return true
|
|
1969
|
+
}
|
|
1694
1970
|
}
|
|
1695
1971
|
const ws = inputs.workspaceFiles
|
|
1696
1972
|
if (workspaceRel === null || ws === undefined || ws.length === 0) return false
|
|
1697
|
-
return workspaceMatcher(ws)(workspaceRel)
|
|
1973
|
+
return workspaceMatcher(ws)(workspaceRel, dirLink)
|
|
1698
1974
|
}
|
|
1699
1975
|
|
|
1700
1976
|
/**
|
|
@@ -1738,22 +2014,23 @@ function inPlan(plan: FilesPlan, rel: string): boolean {
|
|
|
1738
2014
|
function ownOnly(plan: FilesPlan, rel: string): boolean {
|
|
1739
2015
|
let own = plan.ownOnly.get(rel)
|
|
1740
2016
|
if (own === undefined) {
|
|
1741
|
-
own = plan.ownOutput(rel) &&
|
|
2017
|
+
own = plan.ownOutput(rel) && !plan.isExcluded(rel)
|
|
1742
2018
|
plan.ownOnly.set(rel, own)
|
|
1743
2019
|
}
|
|
1744
2020
|
return own
|
|
1745
2021
|
}
|
|
1746
2022
|
|
|
1747
|
-
const workspaceMatchers = new Map<string, (rel: string) => boolean>()
|
|
2023
|
+
const workspaceMatchers = new Map<string, (rel: string, dirLink: boolean) => boolean>()
|
|
1748
2024
|
|
|
1749
|
-
function workspaceMatcher(decl: readonly string[]): (rel: string) => boolean {
|
|
2025
|
+
function workspaceMatcher(decl: readonly string[]): (rel: string, dirLink: boolean) => boolean {
|
|
1750
2026
|
const key = JSON.stringify(decl)
|
|
1751
2027
|
let m = workspaceMatchers.get(key)
|
|
1752
2028
|
if (m !== undefined) return m
|
|
1753
2029
|
const { positive, negative } = splitNegations(decl)
|
|
1754
|
-
const isPositive = anyTaskGlob(asTrees(positive))
|
|
2030
|
+
const isPositive = throughLinks(positive, anyTaskGlob(asTrees(positive)))
|
|
2031
|
+
const mayHold = mayHoldMatch(positive)
|
|
1755
2032
|
const isExcluded = anyTaskGlob([...WORKSPACE_ALWAYS_IGNORE, ...asTrees(negative)])
|
|
1756
|
-
m = (rel) => isPositive(rel) && !isExcluded(rel)
|
|
2033
|
+
m = (rel, dirLink) => (isPositive(rel) || (dirLink && mayHold(rel))) && !isExcluded(rel)
|
|
1757
2034
|
workspaceMatchers.set(key, m)
|
|
1758
2035
|
return m
|
|
1759
2036
|
}
|
|
@@ -1802,6 +2079,7 @@ async function resolveFiles(
|
|
|
1802
2079
|
plan,
|
|
1803
2080
|
nested,
|
|
1804
2081
|
ownWsOutput,
|
|
2082
|
+
args.gitFilesCache,
|
|
1805
2083
|
),
|
|
1806
2084
|
listing: listingFor(args, gitFiles, plan, nested, ownWsOutput),
|
|
1807
2085
|
}
|
|
@@ -1817,7 +2095,7 @@ async function resolveFiles(
|
|
|
1817
2095
|
// tree just changed); set() drops the project's OID slot and these
|
|
1818
2096
|
// files fall back to Cache.hashFile, which computes the identical
|
|
1819
2097
|
// blob OID from disk.
|
|
1820
|
-
const ls = runGitLsFiles(args.projectDir)
|
|
2098
|
+
const ls = runGitLsFiles(args.projectDir, listingEnv(args.projectDir, args.workspaceRoot))
|
|
1821
2099
|
gitFiles = ls.files
|
|
1822
2100
|
// set() also clears the project's pending-changed bookkeeping.
|
|
1823
2101
|
args.gitFilesCache?.set(args.projectDir, gitFiles)
|
|
@@ -1849,14 +2127,21 @@ async function resolveFiles(
|
|
|
1849
2127
|
const candidates: string[] = []
|
|
1850
2128
|
const verdicts = plan.verdicts
|
|
1851
2129
|
const committed = committedFiles(args.gitFilesCache, args.projectDir)
|
|
2130
|
+
const oids = args.gitFilesCache?.oidsFor(args.projectDir)
|
|
1852
2131
|
for (const rel of gitFiles) {
|
|
1853
2132
|
let input = verdicts.get(rel)
|
|
1854
2133
|
if (input === undefined) {
|
|
1855
2134
|
input = isPositive(rel) && !isExcluded(rel) && !ownOutput(rel)
|
|
1856
2135
|
verdicts.set(rel, input)
|
|
1857
2136
|
}
|
|
1858
|
-
if (
|
|
2137
|
+
if (nested(rel) || ownWsOutput(rel)) continue
|
|
1859
2138
|
const abs = base === undefined ? path.resolve(args.projectDir, rel) : base + rel
|
|
2139
|
+
if (!input && !ownOnly(plan, rel)) {
|
|
2140
|
+
if (plan.mayHold(rel) && !isExcluded(rel) && !ownOutput(rel) && dirLinkInput(abs, oids)) {
|
|
2141
|
+
candidates.push(abs)
|
|
2142
|
+
}
|
|
2143
|
+
continue
|
|
2144
|
+
}
|
|
1860
2145
|
if (!input && !committed(abs)) continue
|
|
1861
2146
|
candidates.push(abs)
|
|
1862
2147
|
}
|
|
@@ -1868,7 +2153,6 @@ async function resolveFiles(
|
|
|
1868
2153
|
// per-file syscalls. Paths without an OID keep the probe:
|
|
1869
2154
|
// `git ls-files -s` can surface staged entries whose working-tree
|
|
1870
2155
|
// file is gone; the hasher would otherwise throw ENOENT.
|
|
1871
|
-
const oids = args.gitFilesCache?.oidsFor(args.projectDir)
|
|
1872
2156
|
const resolved = candidates.filter((abs) => oids?.has(abs) === true || isInputOnDisk(abs))
|
|
1873
2157
|
// Git's slice comes sorted and a common prefix keeps it so: one pass
|
|
1874
2158
|
// proves it instead of a sort per task.
|
|
@@ -1880,7 +2164,15 @@ async function resolveFiles(
|
|
|
1880
2164
|
}
|
|
1881
2165
|
args.projectFilesCache?.set(memoKey, { snapshot: gitFiles, result: resolved })
|
|
1882
2166
|
return {
|
|
1883
|
-
files: withIgnored(
|
|
2167
|
+
files: withIgnored(
|
|
2168
|
+
args.projectDir,
|
|
2169
|
+
resolved,
|
|
2170
|
+
reach,
|
|
2171
|
+
plan,
|
|
2172
|
+
nested,
|
|
2173
|
+
ownWsOutput,
|
|
2174
|
+
args.gitFilesCache,
|
|
2175
|
+
),
|
|
1884
2176
|
listing: listingFor(args, gitFiles, plan, nested, ownWsOutput),
|
|
1885
2177
|
}
|
|
1886
2178
|
}
|
|
@@ -1893,18 +2185,26 @@ function withIgnored(
|
|
|
1893
2185
|
plan: FilesPlan,
|
|
1894
2186
|
nested: (rel: string) => boolean,
|
|
1895
2187
|
ownWsOutput: (rel: string) => boolean,
|
|
2188
|
+
cache: GitFilesCache | undefined,
|
|
1896
2189
|
): string[] {
|
|
1897
2190
|
if (reach.length === 0) return [...resolved]
|
|
1898
2191
|
const extra = ignoredInputs(
|
|
1899
2192
|
projectDir,
|
|
1900
2193
|
reach,
|
|
1901
2194
|
(rel) => inPlan(plan, rel) && !nested(rel) && !ownWsOutput(rel),
|
|
2195
|
+
(rel) =>
|
|
2196
|
+
plan.mayHold(rel) &&
|
|
2197
|
+
!plan.isExcluded(rel) &&
|
|
2198
|
+
!plan.ownOutput(rel) &&
|
|
2199
|
+
!nested(rel) &&
|
|
2200
|
+
!ownWsOutput(rel),
|
|
2201
|
+
cache?.vxDirs,
|
|
1902
2202
|
)
|
|
1903
2203
|
return extra.length === 0 ? [...resolved] : mergeSorted(resolved, extra)
|
|
1904
2204
|
}
|
|
1905
2205
|
|
|
1906
2206
|
function listingFor(
|
|
1907
|
-
args: { projectDir: string; gitFilesCache?: GitFilesCache | undefined },
|
|
2207
|
+
args: { projectDir: string; workspaceRoot: string; gitFilesCache?: GitFilesCache | undefined },
|
|
1908
2208
|
listed: readonly string[],
|
|
1909
2209
|
plan: FilesPlan,
|
|
1910
2210
|
nested: (rel: string) => boolean,
|
|
@@ -1914,12 +2214,15 @@ function listingFor(
|
|
|
1914
2214
|
let committed: ((abs: string) => boolean) | undefined
|
|
1915
2215
|
return {
|
|
1916
2216
|
root,
|
|
2217
|
+
workspaceRoot: args.workspaceRoot,
|
|
1917
2218
|
listed,
|
|
1918
2219
|
isInput: (rel) =>
|
|
1919
2220
|
(inPlan(plan, rel) ||
|
|
1920
2221
|
(ownOnly(plan, rel) &&
|
|
1921
2222
|
(committed ??= committedFiles(args.gitFilesCache, root))(path.join(root, rel)))) &&
|
|
1922
2223
|
!ownWsOutput(rel),
|
|
2224
|
+
linked: (rel) =>
|
|
2225
|
+
plan.mayHold(rel) && !plan.isExcluded(rel) && !plan.ownOutput(rel) && !ownWsOutput(rel),
|
|
1923
2226
|
nested,
|
|
1924
2227
|
prefixes: plan.prefixes,
|
|
1925
2228
|
literals: plan.literals,
|
|
@@ -1981,9 +2284,16 @@ function undecodableOnDisk(abs: string): boolean {
|
|
|
1981
2284
|
*/
|
|
1982
2285
|
export interface InputListing {
|
|
1983
2286
|
root: string
|
|
2287
|
+
/** Where git's location variables point; `root` in a repository below it is listed without them. */
|
|
2288
|
+
workspaceRoot: string
|
|
1984
2289
|
/** Root-relative paths, as git listed them. */
|
|
1985
2290
|
listed: readonly string[]
|
|
1986
2291
|
isInput: (rel: string) => boolean
|
|
2292
|
+
/**
|
|
2293
|
+
* Whether a link to a directory at `rel` is an input: a glob may match
|
|
2294
|
+
* below it (`mayHoldMatch`), though its name fits none (`dirLinkInput`).
|
|
2295
|
+
*/
|
|
2296
|
+
linked: (rel: string) => boolean
|
|
1987
2297
|
nested: (rel: string) => boolean
|
|
1988
2298
|
/** Root-relative directories a positive entry reaches, `''` for the root. */
|
|
1989
2299
|
prefixes: readonly string[]
|
|
@@ -2089,7 +2399,10 @@ function addedTo(
|
|
|
2089
2399
|
if (known.has(rel) || e.name === 'node_modules' || l.nested(`${rel}/`)) continue
|
|
2090
2400
|
if (reaches(l.prefixes, rel)) candidates.push(rel)
|
|
2091
2401
|
} else if (
|
|
2092
|
-
l.isInput(rel)
|
|
2402
|
+
(l.isInput(rel) ||
|
|
2403
|
+
(e.isSymbolicLink() &&
|
|
2404
|
+
l.linked(rel) &&
|
|
2405
|
+
dirLinkInput(path.resolve(abs, e.name), undefined))) &&
|
|
2093
2406
|
!l.nested(rel) &&
|
|
2094
2407
|
!isInstalledPath(rel) &&
|
|
2095
2408
|
!keyFiles.has(path.resolve(abs, e.name))
|
|
@@ -2099,10 +2412,11 @@ function addedTo(
|
|
|
2099
2412
|
}
|
|
2100
2413
|
}
|
|
2101
2414
|
if (candidates.length === 0) return undefined
|
|
2102
|
-
for (const rel of runGitLsFiles(l.root).files) {
|
|
2415
|
+
for (const rel of runGitLsFiles(l.root, listingEnv(l.root, l.workspaceRoot)).files) {
|
|
2103
2416
|
if (!candidates.some((c) => c === '' || rel === c || rel.startsWith(`${c}/`))) continue
|
|
2104
|
-
if (isInstalledPath(rel) || l.nested(rel)
|
|
2417
|
+
if (isInstalledPath(rel) || l.nested(rel)) continue
|
|
2105
2418
|
const abs = path.resolve(l.root, rel)
|
|
2419
|
+
if (!l.isInput(rel) && !(l.linked(rel) && dirLinkInput(abs, undefined))) continue
|
|
2106
2420
|
if (!keyFiles.has(abs) && isInputOnDisk(abs)) return abs
|
|
2107
2421
|
}
|
|
2108
2422
|
return undefined
|
|
@@ -2141,12 +2455,15 @@ async function scanUnion(
|
|
|
2141
2455
|
emptyDirs?: Set<string>,
|
|
2142
2456
|
): Promise<Set<string>> {
|
|
2143
2457
|
const matches = new Set<string>()
|
|
2458
|
+
const links: string[] = []
|
|
2144
2459
|
const take = (rel: string): void => {
|
|
2145
2460
|
if (nested(rel) || excludeGlobs.some((g) => g.match(rel))) return
|
|
2146
2461
|
const abs = path.resolve(cwd, rel)
|
|
2147
2462
|
const st = lstatSync(abs, { throwIfNoEntry: false })
|
|
2148
|
-
if (st !== undefined && (st.isFile() || st.isSymbolicLink()))
|
|
2149
|
-
|
|
2463
|
+
if (st !== undefined && (st.isFile() || st.isSymbolicLink())) {
|
|
2464
|
+
matches.add(abs)
|
|
2465
|
+
if (st.isSymbolicLink()) links.push(abs)
|
|
2466
|
+
} else if (emptyDirs !== undefined && st?.isDirectory() === true) {
|
|
2150
2467
|
if (readdirSync(abs).length === 0) emptyDirs.add(abs)
|
|
2151
2468
|
} else if (st === undefined && rel.includes('\ufffd')) refuseUndecodableOutput(abs)
|
|
2152
2469
|
}
|
|
@@ -2158,18 +2475,83 @@ async function scanUnion(
|
|
|
2158
2475
|
// A leading `**` walks the whole project whatever else is listed, so the
|
|
2159
2476
|
// list is one walk: Turbo's `dist/**`, `**/dist/**`, `**/dist` was three,
|
|
2160
2477
|
// ~2 ms of a warm hit on solid.
|
|
2161
|
-
|
|
2478
|
+
// Not where a static head passes through a link: the one walk does not
|
|
2479
|
+
// follow links, and the per-pattern scan enters them (X-88).
|
|
2480
|
+
if (
|
|
2481
|
+
patterns.length > 1 &&
|
|
2482
|
+
patterns.some((p) => p.startsWith('**')) &&
|
|
2483
|
+
!patterns.some((p) => headLinked(cwd, p))
|
|
2484
|
+
) {
|
|
2162
2485
|
const any = anyTaskGlob(patterns)
|
|
2163
2486
|
for (const rel of WHOLE_TREE.scanSync(opts)) if (any(rel)) take(rel)
|
|
2164
2487
|
return matches
|
|
2165
2488
|
}
|
|
2166
2489
|
for (const pattern of patterns) {
|
|
2167
2490
|
if (absentPrefix(cwd, pattern)) continue
|
|
2168
|
-
|
|
2491
|
+
const from = scanRoot(cwd, pattern)
|
|
2492
|
+
if (from === null) continue
|
|
2493
|
+
if (from === undefined) for (const rel of globFor(pattern).scanSync(opts)) take(rel)
|
|
2494
|
+
else
|
|
2495
|
+
for (const rel of globFor(from.rest).scanSync({ ...opts, cwd: from.dir }))
|
|
2496
|
+
take(`${from.prefix}/${rel}`)
|
|
2497
|
+
}
|
|
2498
|
+
// A literal names the path and its tree (`dist`, `dist/**`), and a tree
|
|
2499
|
+
// whose head is a directory link is walked through it: the link and the
|
|
2500
|
+
// files under it were one artifact holding `dist` as a file and a
|
|
2501
|
+
// directory, refused, so the task never cached. The walk is the output.
|
|
2502
|
+
if (links.length > 0) {
|
|
2503
|
+
const sorted = [...matches].sort()
|
|
2504
|
+
for (const link of links) {
|
|
2505
|
+
const under = `${link}/`
|
|
2506
|
+
const at = sorted.findIndex((m) => m >= under)
|
|
2507
|
+
if (at !== -1 && sorted[at]!.startsWith(under)) matches.delete(link)
|
|
2508
|
+
}
|
|
2169
2509
|
}
|
|
2170
2510
|
return matches
|
|
2171
2511
|
}
|
|
2172
2512
|
|
|
2513
|
+
/** `pattern`'s static directory passes through a symlink under `cwd`. */
|
|
2514
|
+
function headLinked(cwd: string, pattern: string): boolean {
|
|
2515
|
+
const prefix = staticPrefix(pattern)
|
|
2516
|
+
if (prefix === '.' || path.isAbsolute(prefix)) return false
|
|
2517
|
+
let at = cwd
|
|
2518
|
+
for (const part of prefix.split('/')) {
|
|
2519
|
+
at = path.join(at, part)
|
|
2520
|
+
const st = lstatSync(at, { throwIfNoEntry: false })
|
|
2521
|
+
if (st === undefined || !st.isDirectory()) return st?.isSymbolicLink() === true
|
|
2522
|
+
}
|
|
2523
|
+
return false
|
|
2524
|
+
}
|
|
2525
|
+
|
|
2526
|
+
/**
|
|
2527
|
+
* Where `pattern`'s scan can start: its static directory, with the rest of
|
|
2528
|
+
* the pattern to match there. `Bun.Glob` walks the whole of `cwd` whatever
|
|
2529
|
+
* the prefix: `out/d1/**` beside 9000 sibling dirs cost 2.3 ms a scan, twice
|
|
2530
|
+
* per miss (2026-10-25). Null when the prefix is a file, which nothing lies
|
|
2531
|
+
* under (a literal's `<file>/**` tree). Undefined (scan from `cwd`) for no
|
|
2532
|
+
* prefix, an escape, or a prefix that does not stat (a dangling link, a
|
|
2533
|
+
* cycle), whose refusals the walk from `cwd` names. A prefix through a
|
|
2534
|
+
* link is entered either way.
|
|
2535
|
+
*/
|
|
2536
|
+
function scanRoot(
|
|
2537
|
+
cwd: string,
|
|
2538
|
+
pattern: string,
|
|
2539
|
+
): { prefix: string; rest: string; dir: string } | null | undefined {
|
|
2540
|
+
const prefix = staticPrefix(pattern)
|
|
2541
|
+
if (prefix === '.' || prefix.includes('\\') || path.isAbsolute(prefix)) return undefined
|
|
2542
|
+
if (!pattern.startsWith(`${prefix}/`) || prefix.split('/').includes('..')) return undefined
|
|
2543
|
+
const dir = path.join(cwd, prefix)
|
|
2544
|
+
let st
|
|
2545
|
+
try {
|
|
2546
|
+
st = statSync(dir, { throwIfNoEntry: false })
|
|
2547
|
+
} catch {
|
|
2548
|
+
return undefined
|
|
2549
|
+
}
|
|
2550
|
+
if (st === undefined) return undefined
|
|
2551
|
+
if (!st.isDirectory()) return null
|
|
2552
|
+
return { prefix, rest: pattern.slice(prefix.length + 1), dir }
|
|
2553
|
+
}
|
|
2554
|
+
|
|
2173
2555
|
/**
|
|
2174
2556
|
* True when `pattern`'s static directory is not on disk, so nothing can
|
|
2175
2557
|
* match under it. A restore into a tree without its outputs scans each
|