@vzn/vx 0.0.641 → 0.0.642
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -12
- package/skills/vx/SKILL.md +4 -4
- package/src/cache/archive.ts +525 -104
- package/src/cache/cache.ts +92 -12
- package/src/cache/fold-memo.ts +58 -0
- package/src/cache/git-inputs.ts +213 -31
- package/src/cache/index.ts +6 -2
- package/src/cache/inputs.ts +169 -79
- package/src/cache/key-fold.ts +90 -69
- package/src/cache/layer.ts +2 -6
- package/src/cache/layered-cache.ts +68 -4
- package/src/cache/output-index.ts +25 -4
- package/src/cache/remote-lane.ts +27 -10
- package/src/cache/schema.ts +13 -3
- package/src/cache/scoped-cache.ts +3 -1
- package/src/cache/umask.ts +29 -0
- package/src/cli/docs.ts +71 -7
- package/src/cli/help.ts +4 -4
- package/src/cli/plan-format.ts +4 -1
- package/src/cli/run.ts +10 -3
- package/src/cli/select.ts +2 -0
- package/src/exec/runner.ts +5 -0
- package/src/exec/sandbox-runtime.ts +81 -1
- package/src/graph/priorities.ts +14 -1
- package/src/history/history.ts +43 -33
- package/src/index.ts +10 -0
- package/src/orchestrator/deferred-outputs.ts +3 -2
- package/src/orchestrator/execute-task.ts +20 -14
- package/src/orchestrator/hit-restore.ts +10 -2
- package/src/orchestrator/index.ts +1 -0
- package/src/orchestrator/lockfile-claim.ts +54 -8
- package/src/orchestrator/miss-save.ts +27 -17
- package/src/orchestrator/output-host.ts +9 -1
- package/src/orchestrator/plan.ts +4 -0
- package/src/orchestrator/plugin-host.ts +31 -0
- package/src/orchestrator/plugin.ts +29 -3
- package/src/orchestrator/prepare.ts +74 -5
- package/src/orchestrator/projects.ts +6 -1
- package/src/orchestrator/run.ts +37 -15
- package/src/orchestrator/sandbox-request.ts +1 -48
- package/src/orchestrator/save-lane.ts +17 -10
- package/src/orchestrator/signals.ts +7 -10
- package/src/orchestrator/stable-keys.ts +83 -25
- package/src/orchestrator/task-hash.ts +46 -12
- package/src/report/events.ts +12 -0
- package/src/report/framed-output.ts +2 -1
- package/src/report/logger.ts +53 -13
- package/src/report/status-line.ts +45 -4
- package/src/report/summary.ts +5 -2
- package/src/util/errors.ts +1 -1
- package/src/util/hidden-prompt.ts +74 -0
- package/src/util/index.ts +12 -2
- package/src/util/paths.ts +33 -14
- package/src/util/secrets.ts +150 -0
- package/src/workspace/affected.ts +63 -13
- package/src/workspace/config-schema.ts +37 -3
- package/src/workspace/project-loader.ts +1 -0
- package/src/workspace/workspace.ts +17 -11
- package/docs/caching.md +0 -2376
- package/docs/cli.md +0 -3285
- package/docs/execution.md +0 -676
- package/docs/features.md +0 -178
- package/docs/patterns.md +0 -219
- package/docs/schema.md +0 -2468
- package/docs/security.md +0 -253
- package/src/cli/docs-corpus.ts +0 -21
- package/src/cli/docs-text.d.ts +0 -5
package/src/cache/inputs.ts
CHANGED
|
@@ -27,13 +27,13 @@ import {
|
|
|
27
27
|
isInstalledPath,
|
|
28
28
|
isLiteralPattern,
|
|
29
29
|
normalizeGlob,
|
|
30
|
-
namesNodeModules,
|
|
31
|
-
OUTPUT_NEVER,
|
|
32
30
|
outputMatcher,
|
|
31
|
+
outputNever,
|
|
33
32
|
realpathOf,
|
|
34
33
|
relPosix,
|
|
35
34
|
shellArgv,
|
|
36
35
|
slashBraceExpansions,
|
|
36
|
+
braceAlternatives,
|
|
37
37
|
splitNegations,
|
|
38
38
|
staticPrefix,
|
|
39
39
|
stripTrailingSlash,
|
|
@@ -66,6 +66,11 @@ export {
|
|
|
66
66
|
// would. (Literal pattern, not the workspace `LOCKFILE_NAME` constant:
|
|
67
67
|
// cache is a leaf module and must not import from workspace.)
|
|
68
68
|
//
|
|
69
|
+
// Relative to the project, so only where vx writes them: its root's `.vx`
|
|
70
|
+
// and the `vx.lock.json` beside its config. Matched at any depth, a
|
|
71
|
+
// project's own `src/.vx/tmp/*` or `src/sub/vx.lock.json` keyed nothing,
|
|
72
|
+
// and an edit to one was a hit (2026-10-17).
|
|
73
|
+
//
|
|
69
74
|
// `node_modules` is not here: the enumeration drops an untracked file
|
|
70
75
|
// under one (`isInstalledPath`), and a tracked one is a source.
|
|
71
76
|
const ALWAYS_IGNORE = [
|
|
@@ -80,21 +85,27 @@ const ALWAYS_IGNORE = [
|
|
|
80
85
|
// member of this list is load-bearing and has a row that fails without
|
|
81
86
|
// it. Kept: the day an enumeration stops going through git, this is
|
|
82
87
|
// what keeps a repository's object store out of a cache key.
|
|
83
|
-
'
|
|
88
|
+
'.git/**',
|
|
84
89
|
// vx's machine-local state; the workspace config beside it is a source.
|
|
85
|
-
'
|
|
86
|
-
'
|
|
90
|
+
'.vx/cache/**',
|
|
91
|
+
'.vx/projects.lock.json',
|
|
87
92
|
// `vx lock`'s review copy of a project's resolved config: the config is
|
|
88
93
|
// keyed already, and a re-lock must not move every `**` reader's key.
|
|
89
|
-
'
|
|
94
|
+
'vx.lock.json',
|
|
90
95
|
// What `vx lock` and `vx init` write beside the lock: a re-lock or init
|
|
91
96
|
// must not select every `**` reader under --affected.
|
|
92
|
-
'
|
|
93
|
-
'
|
|
97
|
+
'.vx/.gitattributes',
|
|
98
|
+
'.vx/.gitignore',
|
|
94
99
|
// Where restores stage (archive.ts): vx's own, never an input.
|
|
95
|
-
'
|
|
100
|
+
'.vx/tmp/**',
|
|
96
101
|
]
|
|
97
102
|
|
|
103
|
+
/**
|
|
104
|
+
* `ALWAYS_IGNORE` relative to the workspace root, for `inputs.workspaceFiles`:
|
|
105
|
+
* those globs cross into other projects, and each one's `vx.lock.json` is vx's.
|
|
106
|
+
*/
|
|
107
|
+
const WORKSPACE_ALWAYS_IGNORE = [...ALWAYS_IGNORE, '**/vx.lock.json']
|
|
108
|
+
|
|
98
109
|
const DEFAULT_FILE_GLOBS: readonly string[] = ['**/*']
|
|
99
110
|
|
|
100
111
|
export interface ResolvedInputs {
|
|
@@ -158,6 +169,8 @@ export interface ResolveInputsArgs {
|
|
|
158
169
|
workspaceFilesCache?: WorkspaceFilesCache
|
|
159
170
|
/** Per-run memo for `inputs.files`; see `ProjectFilesCache`. */
|
|
160
171
|
projectFilesCache?: ProjectFilesCache
|
|
172
|
+
/** Resolve no files: the caller folds them from a `filesToken` memo. */
|
|
173
|
+
skipFiles?: boolean
|
|
161
174
|
}
|
|
162
175
|
|
|
163
176
|
export type WorkspaceFilesCache = Map<
|
|
@@ -184,23 +197,67 @@ export type ProjectFilesCache = Map<
|
|
|
184
197
|
{ snapshot: readonly string[]; result: readonly string[] }
|
|
185
198
|
>
|
|
186
199
|
|
|
200
|
+
/**
|
|
201
|
+
* What a task's input files fold to is a function of this token when it is
|
|
202
|
+
* one of the git records alone: the project's partition holds only files
|
|
203
|
+
* the enumeration trusted an index OID for (`GitFilesCache.filesDigest`),
|
|
204
|
+
* and the declaration reaches no gitignored path and no workspace file.
|
|
205
|
+
* The token is the digest, the workspace root (the fold folds paths
|
|
206
|
+
* relative to it) and everything `resolveFiles` keys its memo on.
|
|
207
|
+
* Undefined when only resolving the files can say.
|
|
208
|
+
*/
|
|
209
|
+
export function filesToken(args: ResolveInputsArgs): string | undefined {
|
|
210
|
+
if ((args.inputs?.workspaceFiles?.length ?? 0) > 0) return undefined
|
|
211
|
+
const plan = filesPlan(args.inputs?.files, args.ownOutputs)
|
|
212
|
+
const cache = args.gitFilesCache
|
|
213
|
+
if (plan === null || cache === undefined) return undefined
|
|
214
|
+
const snapshot = cache.snapshotFor(args.projectDir, plan.positiveGlobs)
|
|
215
|
+
if (snapshot === undefined) return undefined
|
|
216
|
+
const digest = cache.filesDigest(args.projectDir, snapshot)
|
|
217
|
+
if (
|
|
218
|
+
digest === undefined ||
|
|
219
|
+
ignoredReach(plan, snapshot, { dir: args.projectDir, cache }).length > 0
|
|
220
|
+
)
|
|
221
|
+
return undefined
|
|
222
|
+
return `${args.workspaceRoot}\0${filesMemoKey(args.projectDir, plan, args.ownOutputs, args.ownWorkspaceOutputs ?? [], args.nestedProjectDirs)}\0${digest}`
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Everything below the git snapshot that decides `resolveFiles`' result. */
|
|
226
|
+
function filesMemoKey(
|
|
227
|
+
projectDir: string,
|
|
228
|
+
plan: FilesPlan,
|
|
229
|
+
ownOutputs: readonly string[],
|
|
230
|
+
ownWorkspaceOutputs: readonly string[],
|
|
231
|
+
nestedProjectDirs: readonly string[],
|
|
232
|
+
): string {
|
|
233
|
+
return `${projectDir}\0${plan.positive.join('\u0001')}\0${plan.negative.join('\u0001')}\0${ownOutputs.join('\u0001')}\0${ownWorkspaceOutputs.join('\u0001')}\0${nestedProjectDirs.join('\u0001')}`
|
|
234
|
+
}
|
|
235
|
+
|
|
187
236
|
export async function resolveInputs(args: ResolveInputsArgs): Promise<ResolvedInputs> {
|
|
188
|
-
const projectRel =
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
237
|
+
const projectRel =
|
|
238
|
+
args.skipFiles === true
|
|
239
|
+
? ''
|
|
240
|
+
: path.relative(args.workspaceRoot, args.projectDir).split(path.sep).join('/')
|
|
241
|
+
const { files: projectFiles, listing } =
|
|
242
|
+
args.skipFiles === true
|
|
243
|
+
? { files: [], listing: undefined }
|
|
244
|
+
: await resolveFiles({
|
|
245
|
+
projectDir: args.projectDir,
|
|
246
|
+
workspaceRoot: args.workspaceRoot,
|
|
247
|
+
files: args.inputs?.files,
|
|
248
|
+
ownOutputs: args.ownOutputs,
|
|
249
|
+
ownWorkspaceOutputs: args.ownWorkspaceOutputs ?? [],
|
|
250
|
+
projectRel,
|
|
251
|
+
nestedProjectDirs: args.nestedProjectDirs,
|
|
252
|
+
...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
|
|
253
|
+
...(args.projectFilesCache !== undefined
|
|
254
|
+
? { projectFilesCache: args.projectFilesCache }
|
|
255
|
+
: {}),
|
|
256
|
+
})
|
|
200
257
|
let files = projectFiles
|
|
201
258
|
const listings = listing === undefined ? [] : [listing]
|
|
202
259
|
const wsDecl = args.inputs?.workspaceFiles
|
|
203
|
-
if (wsDecl !== undefined && wsDecl.length > 0) {
|
|
260
|
+
if (wsDecl !== undefined && wsDecl.length > 0 && args.skipFiles !== true) {
|
|
204
261
|
const ws = resolveWorkspaceFiles({
|
|
205
262
|
workspaceRoot: args.workspaceRoot,
|
|
206
263
|
workspaceFiles: wsDecl,
|
|
@@ -274,7 +331,6 @@ function resolveWorkspaceFiles(args: {
|
|
|
274
331
|
}): { files: Promise<string[]>; listing: InputListing } | undefined {
|
|
275
332
|
const positive: string[] = []
|
|
276
333
|
const negative: string[] = []
|
|
277
|
-
refuseOneAlternativeBrace(args.workspaceFiles, 'workspaceFiles')
|
|
278
334
|
for (const entry of args.workspaceFiles) {
|
|
279
335
|
if (entry.startsWith('!')) negative.push(entry.slice(1))
|
|
280
336
|
else positive.push(entry)
|
|
@@ -308,7 +364,7 @@ function resolveWorkspaceFiles(args: {
|
|
|
308
364
|
let isExcluded: ((rel: string) => boolean) | undefined
|
|
309
365
|
let isPositive: ((rel: string) => boolean) | undefined
|
|
310
366
|
const excludedByDeclaration = (rel: string): boolean =>
|
|
311
|
-
(isExcluded ??= anyTaskGlob([...
|
|
367
|
+
(isExcluded ??= anyTaskGlob([...WORKSPACE_ALWAYS_IGNORE, ...asTrees(negative)]))(rel)
|
|
312
368
|
// A committed file stays an input whatever output reaches it (`committedFiles`).
|
|
313
369
|
let committed: ((abs: string) => boolean) | undefined
|
|
314
370
|
const own = (rel: string): boolean =>
|
|
@@ -343,7 +399,10 @@ function resolveWorkspaceFiles(args: {
|
|
|
343
399
|
const ownsAny =
|
|
344
400
|
splitNegations(args.ownWorkspaceOutputs).positive.length > 0 ||
|
|
345
401
|
splitNegations(args.ownOutputs).positive.length > 0
|
|
346
|
-
const reach = ignoredReach(reachOf(positive), gitFiles
|
|
402
|
+
const reach = ignoredReach(reachOf(positive), gitFiles, {
|
|
403
|
+
dir: args.workspaceRoot,
|
|
404
|
+
cache: args.gitFilesCache,
|
|
405
|
+
})
|
|
347
406
|
const files = base.then((all) => {
|
|
348
407
|
const kept = ownsAny ? all.filter((abs) => !own(relPosix(args.workspaceRoot, abs))) : all
|
|
349
408
|
refuseUndecodable(kept, undecodable, args.workspaceRoot, 'workspaceFiles')
|
|
@@ -667,17 +726,13 @@ async function resolveRuntimeValues(
|
|
|
667
726
|
}
|
|
668
727
|
|
|
669
728
|
/**
|
|
670
|
-
* What a set of output globs may not reach
|
|
671
|
-
*
|
|
672
|
-
*
|
|
673
|
-
*
|
|
674
|
-
* `node_modules/**` and keeps it. Workspace outputs took none of this, not
|
|
675
|
-
* even `.git`.
|
|
729
|
+
* What a set of output globs may not reach (`outputNever`). `**\/*.js`
|
|
730
|
+
* meant the build's files, and the clean before each run deleted every
|
|
731
|
+
* installed `.js` under `node_modules` with them (A-13); an install task
|
|
732
|
+
* declares `node_modules/**` and keeps it.
|
|
676
733
|
*/
|
|
677
|
-
function outputExcludes(outputs: readonly string[]): Bun.Glob[] {
|
|
678
|
-
return (
|
|
679
|
-
globFor,
|
|
680
|
-
)
|
|
734
|
+
function outputExcludes(outputs: readonly string[], scope: 'project' | 'workspace'): Bun.Glob[] {
|
|
735
|
+
return outputNever(outputs, scope).map(globFor)
|
|
681
736
|
}
|
|
682
737
|
|
|
683
738
|
/**
|
|
@@ -690,9 +745,31 @@ export async function resolveOutputs(args: {
|
|
|
690
745
|
outputs: readonly string[]
|
|
691
746
|
nestedProjectDirs: string[]
|
|
692
747
|
}): Promise<string[]> {
|
|
748
|
+
return (await scanOutputs(args)).files
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* `resolveOutputs`, plus the empty directories the globs match: a save
|
|
753
|
+
* keeps them, so a hit leaves the tree a miss left (Turbo and Nx restore
|
|
754
|
+
* them too). Only a save asks; a hit's walk compares files.
|
|
755
|
+
*/
|
|
756
|
+
export async function resolveOutputTree(args: {
|
|
757
|
+
projectDir: string
|
|
758
|
+
outputs: readonly string[]
|
|
759
|
+
nestedProjectDirs: string[]
|
|
760
|
+
}): Promise<{ files: string[]; emptyDirs: string[] }> {
|
|
761
|
+
const emptyDirs = new Set<string>()
|
|
762
|
+
const { files } = await scanOutputs(args, emptyDirs)
|
|
763
|
+
return { files, emptyDirs: containedIn(args.projectDir, [...emptyDirs]).sort() }
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
async function scanOutputs(
|
|
767
|
+
args: { projectDir: string; outputs: readonly string[]; nestedProjectDirs: string[] },
|
|
768
|
+
emptyDirs?: Set<string>,
|
|
769
|
+
): Promise<{ files: string[] }> {
|
|
693
770
|
const { positive, negative } = splitNegations(args.outputs)
|
|
694
|
-
if (positive.length === 0) return []
|
|
695
|
-
const excludeGlobs = [...outputExcludes(positive), ...asTrees(negative).map(globFor)]
|
|
771
|
+
if (positive.length === 0) return { files: [] }
|
|
772
|
+
const excludeGlobs = [...outputExcludes(positive, 'project'), ...asTrees(negative).map(globFor)]
|
|
696
773
|
const trees = asTrees(positive)
|
|
697
774
|
refuseDirLinkedOut(args.projectDir, trees)
|
|
698
775
|
const scanned = [
|
|
@@ -701,6 +778,7 @@ export async function resolveOutputs(args: {
|
|
|
701
778
|
excludeGlobs,
|
|
702
779
|
args.projectDir,
|
|
703
780
|
inNestedProject(args.projectDir, args.nestedProjectDirs),
|
|
781
|
+
emptyDirs,
|
|
704
782
|
)),
|
|
705
783
|
]
|
|
706
784
|
// Containment, enforced HERE and not only at the loader. `cleanOutputs`
|
|
@@ -710,7 +788,7 @@ export async function resolveOutputs(args: {
|
|
|
710
788
|
// outside the project, so any future caller reaching it by another route (a
|
|
711
789
|
// programmatic embedder, a config source that skips the loader) is contained
|
|
712
790
|
// by construction.
|
|
713
|
-
return containedIn(args.projectDir, scanned).sort()
|
|
791
|
+
return { files: containedIn(args.projectDir, scanned).sort() }
|
|
714
792
|
}
|
|
715
793
|
|
|
716
794
|
/**
|
|
@@ -729,8 +807,8 @@ export function committedFiles(
|
|
|
729
807
|
|
|
730
808
|
/**
|
|
731
809
|
* The literal head of each output glob, walked: the scan follows a link
|
|
732
|
-
* there (`dist -> ../elsewhere` for `dist/**`) and only there. A link
|
|
733
|
-
*
|
|
810
|
+
* there (`dist -> ../elsewhere` for `dist/**`) and only there. A link
|
|
811
|
+
* below it is an output, kept as the link (`archive.ts`).
|
|
734
812
|
*/
|
|
735
813
|
function refuseDirLinkedOut(projectDir: string, trees: readonly string[]): void {
|
|
736
814
|
let realRoot: string | undefined
|
|
@@ -742,10 +820,11 @@ function refuseDirLinkedOut(projectDir: string, trees: readonly string[]): void
|
|
|
742
820
|
if (!isInside(projectDir, at)) break
|
|
743
821
|
let st
|
|
744
822
|
try {
|
|
745
|
-
st = lstatSync(at)
|
|
823
|
+
st = lstatSync(at, { throwIfNoEntry: false })
|
|
746
824
|
} catch {
|
|
747
825
|
break
|
|
748
826
|
}
|
|
827
|
+
if (st === undefined) break
|
|
749
828
|
if (!st.isSymbolicLink()) continue
|
|
750
829
|
let real: string
|
|
751
830
|
try {
|
|
@@ -948,6 +1027,8 @@ export async function cleanOutputPaths(args: {
|
|
|
948
1027
|
rels: readonly string[]
|
|
949
1028
|
outputs: readonly string[]
|
|
950
1029
|
committed?: (abs: string) => boolean
|
|
1030
|
+
/** Directories the prune leaves standing even when emptied (absolute). */
|
|
1031
|
+
keep?: ReadonlySet<string>
|
|
951
1032
|
}): Promise<void> {
|
|
952
1033
|
const committed = args.committed ?? committedFiles(undefined, args.projectDir)
|
|
953
1034
|
const files = args.rels.map((r) => path.resolve(args.projectDir, r)).filter((f) => !committed(f))
|
|
@@ -955,6 +1036,7 @@ export async function cleanOutputPaths(args: {
|
|
|
955
1036
|
args.projectDir,
|
|
956
1037
|
await removeAll(files, args.projectDir),
|
|
957
1038
|
pruneScope(args.projectDir, args.outputs),
|
|
1039
|
+
args.keep,
|
|
958
1040
|
)
|
|
959
1041
|
}
|
|
960
1042
|
|
|
@@ -1147,6 +1229,7 @@ async function pruneEmptiedDirs(
|
|
|
1147
1229
|
root: string,
|
|
1148
1230
|
removed: readonly string[],
|
|
1149
1231
|
scope?: PruneScope,
|
|
1232
|
+
keep?: ReadonlySet<string>,
|
|
1150
1233
|
): Promise<void> {
|
|
1151
1234
|
const rootResolved = path.resolve(root)
|
|
1152
1235
|
// LEVEL ORDER, not a walk-up per directory. A parent is attempted only
|
|
@@ -1166,7 +1249,8 @@ async function pruneEmptiedDirs(
|
|
|
1166
1249
|
(dir) =>
|
|
1167
1250
|
dir !== rootResolved &&
|
|
1168
1251
|
dir.startsWith(rootResolved + path.sep) &&
|
|
1169
|
-
(scope === undefined || inScope(dir, scope))
|
|
1252
|
+
(scope === undefined || inScope(dir, scope)) &&
|
|
1253
|
+
keep?.has(dir) !== true,
|
|
1170
1254
|
)
|
|
1171
1255
|
const gone = (err: NodeJS.ErrnoException): boolean => err.code === 'ENOENT'
|
|
1172
1256
|
if (dirs.length <= SYNC_CLEAN_MAX) {
|
|
@@ -1240,7 +1324,7 @@ export async function resolveWorkspaceOutputs(args: {
|
|
|
1240
1324
|
}): Promise<string[]> {
|
|
1241
1325
|
const { positive, negative } = splitNegations(args.outputs)
|
|
1242
1326
|
if (positive.length === 0) return []
|
|
1243
|
-
const excludeGlobs = [...outputExcludes(positive), ...asTrees(negative).map(globFor)]
|
|
1327
|
+
const excludeGlobs = [...outputExcludes(positive, 'workspace'), ...asTrees(negative).map(globFor)]
|
|
1244
1328
|
const scanned = [...(await scanUnion(asTrees(positive), excludeGlobs, args.workspaceRoot))]
|
|
1245
1329
|
// Same containment as the project twin, anchored one level out. These globs
|
|
1246
1330
|
// deliberately ignore PROJECT boundaries — that is the escape hatch — but
|
|
@@ -1301,7 +1385,11 @@ export { asTrees } from '../util/index.js'
|
|
|
1301
1385
|
* unanswered. Testing every literal against every file was ~1.5 ms of a
|
|
1302
1386
|
* 1,800-file all-cached run (2026-10-06).
|
|
1303
1387
|
*/
|
|
1304
|
-
function unanswered(
|
|
1388
|
+
function unanswered(
|
|
1389
|
+
literals: readonly string[],
|
|
1390
|
+
files: readonly string[],
|
|
1391
|
+
newIgnored?: (lit: string) => boolean,
|
|
1392
|
+
): Set<string> {
|
|
1305
1393
|
const left = new Set<string>()
|
|
1306
1394
|
for (const lit of literals) {
|
|
1307
1395
|
const under = `${lit}/`
|
|
@@ -1309,7 +1397,7 @@ function unanswered(literals: readonly string[], files: readonly string[]): Set<
|
|
|
1309
1397
|
files[lowerBound(files, lit)] === lit ||
|
|
1310
1398
|
files[lowerBound(files, under)]?.startsWith(under) === true ||
|
|
1311
1399
|
files.some((rel) => rel === lit || rel.startsWith(under))
|
|
1312
|
-
if (!answered) left.add(lit)
|
|
1400
|
+
if (!answered || newIgnored?.(lit) === true) left.add(lit)
|
|
1313
1401
|
}
|
|
1314
1402
|
return left
|
|
1315
1403
|
}
|
|
@@ -1337,19 +1425,33 @@ function lowerBound(files: readonly string[], key: string): number {
|
|
|
1337
1425
|
function ignoredReach(
|
|
1338
1426
|
reach: { prefixes: readonly string[]; literals: readonly string[] },
|
|
1339
1427
|
listed: readonly string[],
|
|
1428
|
+
ignoredIn?: { dir: string; cache: GitFilesCache | undefined },
|
|
1340
1429
|
): readonly string[] {
|
|
1341
|
-
|
|
1430
|
+
let named = outrightMemo.get(reach)
|
|
1431
|
+
if (named === undefined) outrightMemo.set(reach, (named = outright(reach)))
|
|
1342
1432
|
if (named.length === 0) return []
|
|
1343
1433
|
// Per listing and entries: thousands of tasks share a few declarations.
|
|
1344
1434
|
let memo = reachMemo.get(listed)
|
|
1345
1435
|
if (memo === undefined) reachMemo.set(listed, (memo = new Map()))
|
|
1346
1436
|
const key = named.join('\0')
|
|
1347
1437
|
let left = memo.get(key)
|
|
1348
|
-
if (left === undefined)
|
|
1438
|
+
if (left === undefined) {
|
|
1439
|
+
// A listed path under a named directory git otherwise ignores (a kept
|
|
1440
|
+
// `gen/.gitkeep`) answered for the whole of it, and the untracked files
|
|
1441
|
+
// beside it never entered the key.
|
|
1442
|
+
const cache = ignoredIn?.cache
|
|
1443
|
+
const newIgnored =
|
|
1444
|
+
cache === undefined
|
|
1445
|
+
? undefined
|
|
1446
|
+
: (lit: string) => cache.newFilesIgnored(path.resolve(ignoredIn!.dir, lit))
|
|
1447
|
+
memo.set(key, (left = [...unanswered(named, listed, newIgnored)]))
|
|
1448
|
+
}
|
|
1349
1449
|
return left
|
|
1350
1450
|
}
|
|
1351
1451
|
|
|
1352
1452
|
const reachMemo = new WeakMap<readonly string[], Map<string, readonly string[]>>()
|
|
1453
|
+
// A declaration's plan is memoized, so its entries are reckoned once.
|
|
1454
|
+
const outrightMemo = new WeakMap<object, string[]>()
|
|
1353
1455
|
|
|
1354
1456
|
/**
|
|
1355
1457
|
* A declaration's literals and globs' fixed prefixes, each once: none for a
|
|
@@ -1458,30 +1560,6 @@ function refuseUndecodable(
|
|
|
1458
1560
|
)
|
|
1459
1561
|
}
|
|
1460
1562
|
|
|
1461
|
-
const ONE_ALTERNATIVE_BRACE = /(?<!\\)\{[^{},]*\}/
|
|
1462
|
-
|
|
1463
|
-
/**
|
|
1464
|
-
* `Bun.Glob` reads `{b}` as a brace of one alternative, so `src/{b}.ts`
|
|
1465
|
-
* matches `src/b.ts` and never a file named `{b}.ts`: that file stays out
|
|
1466
|
-
* of the key and an edit to it is a hit. Neither reading is sure to be
|
|
1467
|
-
* the one meant, so the entry is refused with both spellings.
|
|
1468
|
-
*/
|
|
1469
|
-
function refuseOneAlternativeBrace(
|
|
1470
|
-
entries: readonly string[],
|
|
1471
|
-
field: 'files' | 'workspaceFiles',
|
|
1472
|
-
): void {
|
|
1473
|
-
for (const entry of entries) {
|
|
1474
|
-
const m = ONE_ALTERNATIVE_BRACE.exec(entry)
|
|
1475
|
-
if (m === null) continue
|
|
1476
|
-
const inner = m[0].slice(1, -1)
|
|
1477
|
-
throw new UserError(
|
|
1478
|
-
`inputs.${field}: "${entry}" holds a brace with one alternative, which matches ` +
|
|
1479
|
-
`"${inner}" and never a name holding "${m[0]}". Write "${entry.replace(m[0], inner)}", ` +
|
|
1480
|
-
`or "${entry.replace(m[0], `\\{${inner}\\}`)}" for the braces themselves.`,
|
|
1481
|
-
)
|
|
1482
|
-
}
|
|
1483
|
-
}
|
|
1484
|
-
|
|
1485
1563
|
/** What a `files` declaration and its task's outputs compile to, whatever the project. */
|
|
1486
1564
|
interface FilesPlan {
|
|
1487
1565
|
positive: string[]
|
|
@@ -1530,7 +1608,6 @@ function filesPlan(
|
|
|
1530
1608
|
if (files === undefined) {
|
|
1531
1609
|
positive.push(...DEFAULT_FILE_GLOBS)
|
|
1532
1610
|
} else {
|
|
1533
|
-
refuseOneAlternativeBrace(files, 'files')
|
|
1534
1611
|
for (const entry of files) {
|
|
1535
1612
|
if (entry.startsWith('!')) negative.push(entry.slice(1))
|
|
1536
1613
|
else positive.push(entry)
|
|
@@ -1637,7 +1714,7 @@ function workspaceMatcher(decl: readonly string[]): (rel: string) => boolean {
|
|
|
1637
1714
|
if (m !== undefined) return m
|
|
1638
1715
|
const { positive, negative } = splitNegations(decl)
|
|
1639
1716
|
const isPositive = anyTaskGlob(asTrees(positive))
|
|
1640
|
-
const isExcluded = anyTaskGlob([...
|
|
1717
|
+
const isExcluded = anyTaskGlob([...WORKSPACE_ALWAYS_IGNORE, ...asTrees(negative)])
|
|
1641
1718
|
m = (rel) => isPositive(rel) && !isExcluded(rel)
|
|
1642
1719
|
workspaceMatchers.set(key, m)
|
|
1643
1720
|
return m
|
|
@@ -1648,7 +1725,7 @@ async function resolveFiles(
|
|
|
1648
1725
|
): Promise<{ files: string[]; listing?: InputListing }> {
|
|
1649
1726
|
const plan = filesPlan(args.files, args.ownOutputs)
|
|
1650
1727
|
if (plan === null) return { files: [] }
|
|
1651
|
-
const {
|
|
1728
|
+
const { isExcluded, ownOutput, positiveGlobs, isPositive } = plan
|
|
1652
1729
|
|
|
1653
1730
|
const nested = inNestedProject(args.projectDir, args.nestedProjectDirs)
|
|
1654
1731
|
// Its root-anchored outputs that land in the project, as `files` sees them.
|
|
@@ -1664,7 +1741,13 @@ async function resolveFiles(
|
|
|
1664
1741
|
// duration of one orchestrator run.
|
|
1665
1742
|
// Everything below the snapshot that decides the result: the project, what
|
|
1666
1743
|
// it declares, what it excludes as its own outputs, and the boundaries.
|
|
1667
|
-
const memoKey =
|
|
1744
|
+
const memoKey = filesMemoKey(
|
|
1745
|
+
args.projectDir,
|
|
1746
|
+
plan,
|
|
1747
|
+
args.ownOutputs,
|
|
1748
|
+
args.ownWorkspaceOutputs,
|
|
1749
|
+
args.nestedProjectDirs,
|
|
1750
|
+
)
|
|
1668
1751
|
let gitFiles = args.gitFilesCache?.snapshotFor(args.projectDir, positiveGlobs)
|
|
1669
1752
|
let undecodable = args.gitFilesCache?.undecodableNames
|
|
1670
1753
|
if (gitFiles !== undefined) {
|
|
@@ -1677,7 +1760,7 @@ async function resolveFiles(
|
|
|
1677
1760
|
files: withIgnored(
|
|
1678
1761
|
args.projectDir,
|
|
1679
1762
|
memo.result,
|
|
1680
|
-
ignoredReach(plan, gitFiles),
|
|
1763
|
+
ignoredReach(plan, gitFiles, { dir: args.projectDir, cache: args.gitFilesCache }),
|
|
1681
1764
|
plan,
|
|
1682
1765
|
nested,
|
|
1683
1766
|
ownWsOutput,
|
|
@@ -1739,7 +1822,7 @@ async function resolveFiles(
|
|
|
1739
1822
|
if (!input && !committed(abs)) continue
|
|
1740
1823
|
candidates.push(abs)
|
|
1741
1824
|
}
|
|
1742
|
-
const reach = ignoredReach(plan, gitFiles)
|
|
1825
|
+
const reach = ignoredReach(plan, gitFiles, { dir: args.projectDir, cache: args.gitFilesCache })
|
|
1743
1826
|
refuseUndecodable(candidates, undecodable, args.projectDir, 'files')
|
|
1744
1827
|
// Second pass: existence check — but ONLY for paths without a
|
|
1745
1828
|
// trusted index OID. A clean-per-status tracked file necessarily
|
|
@@ -1870,7 +1953,11 @@ export interface InputListing {
|
|
|
1870
1953
|
literals: readonly string[]
|
|
1871
1954
|
}
|
|
1872
1955
|
|
|
1873
|
-
function reachOf(
|
|
1956
|
+
function reachOf(entries: readonly string[]): { prefixes: string[]; literals: string[] } {
|
|
1957
|
+
// Each alternative names what it would alone: `{gen/a.txt,gen/b.txt}`
|
|
1958
|
+
// names both files, as `gen/{a,b}.txt` does; whole, it had no fixed
|
|
1959
|
+
// prefix and named nothing (Growth's parity, 2026-10-17).
|
|
1960
|
+
const positive = entries.flatMap(braceAlternatives)
|
|
1874
1961
|
const prefixes = new Set<string>()
|
|
1875
1962
|
for (const p of asTrees(positive)) {
|
|
1876
1963
|
if (isLiteralPattern(p)) continue
|
|
@@ -2013,6 +2100,7 @@ async function scanUnion(
|
|
|
2013
2100
|
excludeGlobs: readonly Bun.Glob[],
|
|
2014
2101
|
cwd: string,
|
|
2015
2102
|
nested: (rel: string) => boolean = () => false,
|
|
2103
|
+
emptyDirs?: Set<string>,
|
|
2016
2104
|
): Promise<Set<string>> {
|
|
2017
2105
|
const matches = new Set<string>()
|
|
2018
2106
|
// `Bun.Glob`'s scan finds nothing for a brace whose alternatives hold a
|
|
@@ -2026,7 +2114,9 @@ async function scanUnion(
|
|
|
2026
2114
|
const abs = path.resolve(cwd, rel)
|
|
2027
2115
|
const st = lstatSync(abs, { throwIfNoEntry: false })
|
|
2028
2116
|
if (st !== undefined && (st.isFile() || st.isSymbolicLink())) matches.add(abs)
|
|
2029
|
-
else if (
|
|
2117
|
+
else if (emptyDirs !== undefined && st?.isDirectory() === true) {
|
|
2118
|
+
if (readdirSync(abs).length === 0) emptyDirs.add(abs)
|
|
2119
|
+
} else if (st === undefined && rel.includes('\ufffd')) refuseUndecodableOutput(abs)
|
|
2030
2120
|
}
|
|
2031
2121
|
}
|
|
2032
2122
|
return matches
|
package/src/cache/key-fold.ts
CHANGED
|
@@ -110,7 +110,14 @@ import type { CacheKeyInput } from './layer.js'
|
|
|
110
110
|
// v43 stays when core stopped folding package files (2026-10-14): the
|
|
111
111
|
// `workspace:` and `pkg:` parts left the chain, so every key moved and no
|
|
112
112
|
// unchanged key holds wrong bytes.
|
|
113
|
-
|
|
113
|
+
// v44: the sidecar lists the empty directories under the outputs; a v43
|
|
114
|
+
// artifact holds none and a hit left them out (Growth's parity, 2026-10-16).
|
|
115
|
+
// v45: the sidecar lists the symlinks under the outputs and a hit makes
|
|
116
|
+
// them; a v44 artifact holds a link as a copy of its target (M, 2026-10-16).
|
|
117
|
+
// v46: vx's names are reserved only at the project root; a v45 artifact
|
|
118
|
+
// left out a build's own `dist/.git/**`, `dist/.vx/**` and nested
|
|
119
|
+
// `vx.lock.json` (Growth's parity, 2026-10-17).
|
|
120
|
+
export const CACHE_VERSION = 'vx-cache-v46'
|
|
114
121
|
|
|
115
122
|
/**
|
|
116
123
|
* The digest a key folds for an input gone between its enumeration and its
|
|
@@ -151,6 +158,87 @@ export async function foldKey(
|
|
|
151
158
|
*/
|
|
152
159
|
behind?: (link: string) => Promise<string>,
|
|
153
160
|
): Promise<string> {
|
|
161
|
+
const cap = input.captureInto
|
|
162
|
+
let h = foldHead(input, digestValue)
|
|
163
|
+
|
|
164
|
+
// `resolveFiles` already returns its paths sorted, and a copy-and-sort
|
|
165
|
+
// of three thousand strings per task is not free: 7.4 ms of a 44-task
|
|
166
|
+
// run here (2026-09-20). A linear order check costs one comparison per
|
|
167
|
+
// file instead of n log n, and a caller that hands over an unsorted set
|
|
168
|
+
// — a plugin's own input list — still gets one. `>` on strings compares
|
|
169
|
+
// UTF-16 code units, which is what a comparator-less `sort` does.
|
|
170
|
+
let sortedInputs: readonly string[] = input.inputFiles
|
|
171
|
+
for (let i = 1; i < sortedInputs.length; i++) {
|
|
172
|
+
if (sortedInputs[i - 1]! > sortedInputs[i]!) {
|
|
173
|
+
sortedInputs = [...input.inputFiles].sort()
|
|
174
|
+
break
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
h = xxh3(`inputs:${sortedInputs.length}`, h)
|
|
178
|
+
// Per-file hash source, in preference order: the caller-supplied
|
|
179
|
+
// index-OID map (clean tracked files — zero I/O), then hashFile's
|
|
180
|
+
// stat memo (no read), then a full in-process blob-OID computation.
|
|
181
|
+
// All three describe the WORKTREE bytes, which is what the task
|
|
182
|
+
// reads — the index-OID map is only populated for paths whose
|
|
183
|
+
// worktree form matches their index form, because a clean filter
|
|
184
|
+
// (`text`/`eol`/`ident`/`filter`/`working-tree-encoding`) makes the index blob a DIFFERENT sequence
|
|
185
|
+
// of bytes and folding it would let two distinct worktree contents
|
|
186
|
+
// share a key. The fold order is locked to `sortedInputs` so
|
|
187
|
+
// results are stable across runs.
|
|
188
|
+
// The common warm shape is that the caller's OID map covers every file,
|
|
189
|
+
// and then there is nothing to await: building three thousand promises
|
|
190
|
+
// per task to resolve values already in hand cost 8.4 ms of that same
|
|
191
|
+
// run. One pass fills the array, and the first gap falls back to the
|
|
192
|
+
// awaited form for the whole list.
|
|
193
|
+
let fileHashes: readonly string[] | undefined
|
|
194
|
+
const provided = input.fileHashes
|
|
195
|
+
if (provided !== undefined) {
|
|
196
|
+
const out: string[] = []
|
|
197
|
+
for (const file of sortedInputs) {
|
|
198
|
+
const oid = provided.get(file)
|
|
199
|
+
if (oid === undefined) break
|
|
200
|
+
out.push(oid)
|
|
201
|
+
}
|
|
202
|
+
if (out.length === sortedInputs.length) fileHashes = out
|
|
203
|
+
}
|
|
204
|
+
fileHashes ??= await Promise.all(
|
|
205
|
+
sortedInputs.map((f) => provided?.get(f) ?? hashFile(f).catch(absentOr)),
|
|
206
|
+
)
|
|
207
|
+
// Links are rare, so a list without one awaits nothing more.
|
|
208
|
+
let behindLinks: Map<number, string> | undefined
|
|
209
|
+
if (behind !== undefined) {
|
|
210
|
+
const links: number[] = []
|
|
211
|
+
for (let i = 0; i < fileHashes.length; i++)
|
|
212
|
+
if (fileHashes[i]!.startsWith('120000:')) links.push(i)
|
|
213
|
+
if (links.length > 0) {
|
|
214
|
+
const digests = await Promise.all(links.map((i) => behind(sortedInputs[i]!)))
|
|
215
|
+
behindLinks = new Map(links.map((i, n) => [i, digests[n]!]))
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
for (let i = 0; i < sortedInputs.length; i++) {
|
|
219
|
+
const file = sortedInputs[i]!
|
|
220
|
+
const rel = relOf(file)
|
|
221
|
+
const oid = fileHashes[i]!
|
|
222
|
+
const linked = behindLinks?.get(i)
|
|
223
|
+
// A link folds what it points to in its own step, so a key without one is unchanged.
|
|
224
|
+
h = xxh3(linked === undefined ? `${rel}\0${oid}` : `${rel}\0${oid}\0${linked}`, h)
|
|
225
|
+
// The OID is already awaited — file capture is zero extra I/O.
|
|
226
|
+
if (cap) cap.push({ kind: 'file', name: rel, hash: oid })
|
|
227
|
+
if (linked !== undefined && cap) cap.push({ kind: 'linked', name: rel, hash: xxh3hex(linked) })
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return h.toString(16).padStart(16, '0')
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The fold up to the input files, which come last: what a task's key is
|
|
235
|
+
* once its files are folded onto it. `computeTaskHash` asks it alone when
|
|
236
|
+
* a remembered fold of the same files answers the rest (`FoldMemo`).
|
|
237
|
+
*/
|
|
238
|
+
export function foldHead(
|
|
239
|
+
input: CacheKeyInput,
|
|
240
|
+
digestValue: (value: string) => string = xxh3hex,
|
|
241
|
+
): bigint {
|
|
154
242
|
// Seed-chained xxHash3: each step folds one field into the
|
|
155
243
|
// running digest via `xxh3(part, prevDigest)`. Equivalent to the
|
|
156
244
|
// old CryptoHasher.update() pattern, no intermediate buffer.
|
|
@@ -230,72 +318,5 @@ export async function foldKey(
|
|
|
230
318
|
for (const [n, v] of pluginParts) cap.push({ kind: 'plugin', name: n, hash: digestValue(v) })
|
|
231
319
|
}
|
|
232
320
|
}
|
|
233
|
-
|
|
234
|
-
// `resolveFiles` already returns its paths sorted, and a copy-and-sort
|
|
235
|
-
// of three thousand strings per task is not free: 7.4 ms of a 44-task
|
|
236
|
-
// run here (2026-09-20). A linear order check costs one comparison per
|
|
237
|
-
// file instead of n log n, and a caller that hands over an unsorted set
|
|
238
|
-
// — a plugin's own input list — still gets one. `>` on strings compares
|
|
239
|
-
// UTF-16 code units, which is what a comparator-less `sort` does.
|
|
240
|
-
let sortedInputs: readonly string[] = input.inputFiles
|
|
241
|
-
for (let i = 1; i < sortedInputs.length; i++) {
|
|
242
|
-
if (sortedInputs[i - 1]! > sortedInputs[i]!) {
|
|
243
|
-
sortedInputs = [...input.inputFiles].sort()
|
|
244
|
-
break
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
h = xxh3(`inputs:${sortedInputs.length}`, h)
|
|
248
|
-
// Per-file hash source, in preference order: the caller-supplied
|
|
249
|
-
// index-OID map (clean tracked files — zero I/O), then hashFile's
|
|
250
|
-
// stat memo (no read), then a full in-process blob-OID computation.
|
|
251
|
-
// All three describe the WORKTREE bytes, which is what the task
|
|
252
|
-
// reads — the index-OID map is only populated for paths whose
|
|
253
|
-
// worktree form matches their index form, because a clean filter
|
|
254
|
-
// (`text`/`eol`/`ident`/`filter`/`working-tree-encoding`) makes the index blob a DIFFERENT sequence
|
|
255
|
-
// of bytes and folding it would let two distinct worktree contents
|
|
256
|
-
// share a key. The fold order is locked to `sortedInputs` so
|
|
257
|
-
// results are stable across runs.
|
|
258
|
-
// The common warm shape is that the caller's OID map covers every file,
|
|
259
|
-
// and then there is nothing to await: building three thousand promises
|
|
260
|
-
// per task to resolve values already in hand cost 8.4 ms of that same
|
|
261
|
-
// run. One pass fills the array, and the first gap falls back to the
|
|
262
|
-
// awaited form for the whole list.
|
|
263
|
-
let fileHashes: readonly string[] | undefined
|
|
264
|
-
const provided = input.fileHashes
|
|
265
|
-
if (provided !== undefined) {
|
|
266
|
-
const out: string[] = []
|
|
267
|
-
for (const file of sortedInputs) {
|
|
268
|
-
const oid = provided.get(file)
|
|
269
|
-
if (oid === undefined) break
|
|
270
|
-
out.push(oid)
|
|
271
|
-
}
|
|
272
|
-
if (out.length === sortedInputs.length) fileHashes = out
|
|
273
|
-
}
|
|
274
|
-
fileHashes ??= await Promise.all(
|
|
275
|
-
sortedInputs.map((f) => provided?.get(f) ?? hashFile(f).catch(absentOr)),
|
|
276
|
-
)
|
|
277
|
-
// Links are rare, so a list without one awaits nothing more.
|
|
278
|
-
let behindLinks: Map<number, string> | undefined
|
|
279
|
-
if (behind !== undefined) {
|
|
280
|
-
const links: number[] = []
|
|
281
|
-
for (let i = 0; i < fileHashes.length; i++)
|
|
282
|
-
if (fileHashes[i]!.startsWith('120000:')) links.push(i)
|
|
283
|
-
if (links.length > 0) {
|
|
284
|
-
const digests = await Promise.all(links.map((i) => behind(sortedInputs[i]!)))
|
|
285
|
-
behindLinks = new Map(links.map((i, n) => [i, digests[n]!]))
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
for (let i = 0; i < sortedInputs.length; i++) {
|
|
289
|
-
const file = sortedInputs[i]!
|
|
290
|
-
const rel = relOf(file)
|
|
291
|
-
const oid = fileHashes[i]!
|
|
292
|
-
const linked = behindLinks?.get(i)
|
|
293
|
-
// A link folds what it points to in its own step, so a key without one is unchanged.
|
|
294
|
-
h = xxh3(linked === undefined ? `${rel}\0${oid}` : `${rel}\0${oid}\0${linked}`, h)
|
|
295
|
-
// The OID is already awaited — file capture is zero extra I/O.
|
|
296
|
-
if (cap) cap.push({ kind: 'file', name: rel, hash: oid })
|
|
297
|
-
if (linked !== undefined && cap) cap.push({ kind: 'linked', name: rel, hash: xxh3hex(linked) })
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
return h.toString(16).padStart(16, '0')
|
|
321
|
+
return h
|
|
301
322
|
}
|