@vzn/vx 0.0.639 → 0.0.640
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 +1 -1
- package/docs/caching.md +111 -83
- package/docs/cli.md +70 -40
- package/docs/execution.md +4 -4
- package/docs/features.md +9 -8
- package/docs/patterns.md +1 -1
- package/docs/schema.md +235 -180
- package/docs/security.md +1 -1
- package/package.json +5 -5
- package/schemas/show.json +14 -14
- package/skills/vx/SKILL.md +4 -3
- package/src/cache/cache.ts +4 -1
- package/src/cache/file-hashes.ts +7 -12
- package/src/cache/fs-clock.ts +62 -0
- package/src/cache/git-inputs.ts +2 -2
- package/src/cache/index.ts +3 -0
- package/src/cache/inputs.ts +154 -83
- package/src/cache/key-fold.ts +23 -3
- package/src/cache/layer.ts +8 -7
- package/src/cache/linked-inputs.ts +87 -0
- package/src/cli/foreign-flags.ts +1 -1
- package/src/cli/init.ts +23 -11
- package/src/cli/last.ts +24 -1
- package/src/cli/plugin-templates.ts +2 -2
- package/src/cli/select.ts +16 -6
- package/src/cli/show.ts +9 -6
- package/src/cli/upgrade.ts +95 -19
- package/src/cli/watch-filter.ts +22 -6
- package/src/cli/watch-judge.ts +49 -5
- package/src/cli/watch-set.ts +18 -8
- package/src/cli/watch.ts +74 -2
- package/src/cli/why.ts +5 -3
- package/src/config.ts +19 -15
- package/src/exec/executor.ts +4 -4
- package/src/graph/dependency-spec.ts +2 -2
- package/src/graph/folds.ts +236 -0
- package/src/graph/index.ts +8 -1
- package/src/graph/scheduler.ts +41 -15
- package/src/graph/task-graph.ts +135 -73
- package/src/index.ts +1 -1
- package/src/orchestrator/affected-tasks.ts +20 -19
- package/src/orchestrator/download-policy.ts +16 -22
- package/src/orchestrator/excluded-keys.ts +4 -5
- package/src/orchestrator/execute-task.ts +29 -25
- package/src/orchestrator/index.ts +1 -0
- package/src/orchestrator/keyed-projects.ts +12 -51
- package/src/orchestrator/local-shortcircuit.ts +1 -1
- package/src/orchestrator/persistent-task.ts +3 -3
- package/src/orchestrator/placement.ts +2 -2
- package/src/orchestrator/plan.ts +1 -1
- package/src/orchestrator/plugin-host.ts +62 -22
- package/src/orchestrator/plugin.ts +12 -2
- package/src/orchestrator/prepare.ts +12 -5
- package/src/orchestrator/projects.ts +7 -10
- package/src/orchestrator/run-report.ts +124 -46
- package/src/orchestrator/run-steps.ts +57 -0
- package/src/orchestrator/run.ts +51 -21
- package/src/orchestrator/sandbox-fix.ts +1 -1
- package/src/orchestrator/sandbox-request.ts +2 -2
- package/src/orchestrator/stable-keys.ts +59 -26
- package/src/orchestrator/steps.ts +26 -6
- package/src/orchestrator/task-hash.ts +31 -9
- package/src/orchestrator/telemetry-host.ts +3 -3
- package/src/orchestrator/telemetry.ts +9 -4
- package/src/orchestrator/upstream.ts +4 -181
- package/src/report/framed-output.ts +1 -1
- package/src/report/status-line.ts +7 -3
- package/src/settings.ts +11 -17
- package/src/util/paths.ts +1 -1
- package/src/workspace/affected.ts +81 -2
- package/src/workspace/config-imports.ts +1 -1
- package/src/workspace/config-schema.ts +142 -143
- package/src/workspace/lockfile.ts +1 -1
- package/src/workspace/migration.ts +19 -3
package/src/cache/inputs.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// - envValues: [name, value] pairs from parent process.env (value
|
|
5
5
|
// undefined for an unset name)
|
|
6
6
|
//
|
|
7
|
-
// `
|
|
7
|
+
// `inputs.env` is the cache-tracking axis for env vars; it's
|
|
8
8
|
// independent of `exec.env`, which controls what reaches the child.
|
|
9
9
|
//
|
|
10
10
|
// File enumeration defers to git — same as Turbo and Nx. git-inputs.ts
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
import path from 'node:path'
|
|
20
20
|
import { lstatSync, readdirSync, realpathSync, rmdirSync, rmSync, statSync } from 'node:fs'
|
|
21
21
|
import { rm, rmdir } from 'node:fs/promises'
|
|
22
|
-
import type {
|
|
22
|
+
import type { InputsConfig, TaskConfig } from '../config.js'
|
|
23
23
|
import {
|
|
24
24
|
anyTaskGlob,
|
|
25
25
|
asTrees,
|
|
@@ -121,7 +121,7 @@ export interface ResolveInputsArgs {
|
|
|
121
121
|
projectDir: string
|
|
122
122
|
workspaceRoot: string
|
|
123
123
|
envSource: NodeJS.ProcessEnv
|
|
124
|
-
inputs:
|
|
124
|
+
inputs: InputsConfig | undefined
|
|
125
125
|
/** Project-relative output globs to exclude from inputs. */
|
|
126
126
|
ownOutputs: readonly string[]
|
|
127
127
|
/** Root-relative `outputs.workspaceFiles` globs to exclude from
|
|
@@ -137,7 +137,7 @@ export interface ResolveInputsArgs {
|
|
|
137
137
|
*/
|
|
138
138
|
gitFilesCache?: GitFilesCache
|
|
139
139
|
/**
|
|
140
|
-
* Run-scoped memo for `
|
|
140
|
+
* Run-scoped memo for `inputs.runtime` command execution, keyed
|
|
141
141
|
* by `projectDir + '\0' + command`. Shared across a run's tasks so a
|
|
142
142
|
* project's command runs once even across build/test/lint and across
|
|
143
143
|
* the hash + sandbox-baseline resolveInputs calls. The key is sound
|
|
@@ -148,7 +148,7 @@ export interface ResolveInputsArgs {
|
|
|
148
148
|
*/
|
|
149
149
|
runtimeCache?: Map<string, Promise<string>>
|
|
150
150
|
/**
|
|
151
|
-
* Run-scoped memo for `
|
|
151
|
+
* Run-scoped memo for `inputs.workspaceRuntime`, keyed by command
|
|
152
152
|
* only — global dedup so a root-level probe spawns once per run.
|
|
153
153
|
*/
|
|
154
154
|
workspaceRuntimeCache?: Map<string, Promise<string>>
|
|
@@ -160,7 +160,7 @@ export interface ResolveInputsArgs {
|
|
|
160
160
|
*/
|
|
161
161
|
runtimeScope?: string
|
|
162
162
|
/**
|
|
163
|
-
* Run-scoped memo for `
|
|
163
|
+
* Run-scoped memo for `inputs.workspaceFiles`, keyed by the
|
|
164
164
|
* declaration and valid for one enumeration snapshot. A Turbo-mapped
|
|
165
165
|
* workspace gives every task the same `globalDependencies`, and
|
|
166
166
|
* resolving one literal against medusa's 24k-file enumeration per task
|
|
@@ -177,7 +177,7 @@ export type WorkspaceFilesCache = Map<
|
|
|
177
177
|
>
|
|
178
178
|
|
|
179
179
|
/**
|
|
180
|
-
* Per-run memo of `
|
|
180
|
+
* Per-run memo of `inputs.files` resolution, keyed by the project and
|
|
181
181
|
* the DECLARATION — tasks of one project that declare the same inputs and
|
|
182
182
|
* the same outputs resolve to the same list, and this repo's own config is
|
|
183
183
|
* the shape that pays for it: twelve shard tasks, each declaring the same
|
|
@@ -263,7 +263,7 @@ export async function resolveInputs(args: ResolveInputsArgs): Promise<ResolvedIn
|
|
|
263
263
|
}
|
|
264
264
|
|
|
265
265
|
/**
|
|
266
|
-
* Resolve `
|
|
266
|
+
* Resolve `inputs.workspaceFiles` — workspace-root-relative
|
|
267
267
|
* globs matched against the workspace-wide git file set (tracked +
|
|
268
268
|
* untracked-not-ignored, same visibility as project inputs).
|
|
269
269
|
*
|
|
@@ -354,10 +354,21 @@ function resolveWorkspaceFiles(args: {
|
|
|
354
354
|
const ownsAny =
|
|
355
355
|
splitNegations(args.ownWorkspaceOutputs).positive.length > 0 ||
|
|
356
356
|
splitNegations(args.ownOutputs).positive.length > 0
|
|
357
|
+
const reach = ignoredReach(reachOf(positive), gitFiles)
|
|
357
358
|
const files = base.then((all) => {
|
|
358
359
|
const kept = ownsAny ? all.filter((abs) => !own(relPosix(args.workspaceRoot, abs))) : all
|
|
359
360
|
refuseUndecodable(kept, undecodable, args.workspaceRoot, 'workspaceFiles')
|
|
360
|
-
return kept
|
|
361
|
+
if (reach.length === 0) return kept
|
|
362
|
+
// Outside the memo: an ignored file a task writes mid-run leaves the
|
|
363
|
+
// snapshot as it was.
|
|
364
|
+
isPositive ??= anyTaskGlob(asTrees(positive))
|
|
365
|
+
const accept = isPositive
|
|
366
|
+
const extra = ignoredInputs(
|
|
367
|
+
args.workspaceRoot,
|
|
368
|
+
reach,
|
|
369
|
+
(rel) => accept(rel) && !excludedByDeclaration(rel) && !own(rel),
|
|
370
|
+
)
|
|
371
|
+
return extra.length === 0 ? kept : mergeSorted(kept, extra)
|
|
361
372
|
})
|
|
362
373
|
return { files, listing }
|
|
363
374
|
}
|
|
@@ -369,23 +380,12 @@ async function resolveWorkspaceFilesOver(
|
|
|
369
380
|
isPositive: (rel: string) => boolean,
|
|
370
381
|
excluded: (rel: string) => boolean,
|
|
371
382
|
): Promise<string[]> {
|
|
372
|
-
// Second call site of the literal-input guard. `resolveWorkspaceFiles`
|
|
373
|
-
// carries its own copy of the filter-over-git-set design, so the same
|
|
374
|
-
// silently-folds-nothing hazard exists here — and a fix applied only to the
|
|
375
|
-
// project half would pass that half's tests while leaving this one live.
|
|
376
|
-
const unmatchedLiterals = unanswered(
|
|
377
|
-
positive.map(normalizeGlob).filter(isLiteralPattern).map(stripTrailingSlash),
|
|
378
|
-
gitFiles,
|
|
379
|
-
)
|
|
380
383
|
const candidates: string[] = []
|
|
381
384
|
for (const rel of gitFiles) {
|
|
382
385
|
if (!isPositive(rel)) continue
|
|
383
386
|
if (excluded(rel)) continue
|
|
384
387
|
candidates.push(path.resolve(args.workspaceRoot, rel))
|
|
385
388
|
}
|
|
386
|
-
if (unmatchedLiterals.size > 0) {
|
|
387
|
-
await assertNoInvisibleLiteralInputs(unmatchedLiterals, args.workspaceRoot, 'workspaceFiles')
|
|
388
|
-
}
|
|
389
389
|
// Same OID-trust shortcut as project files: a clean-per-status
|
|
390
390
|
// tracked file necessarily exists on disk.
|
|
391
391
|
const oids = args.gitFilesCache?.oidsFor(args.workspaceRoot)
|
|
@@ -536,10 +536,10 @@ async function runRuntimeCommand(
|
|
|
536
536
|
// the shell, not the command (item 244).
|
|
537
537
|
if (isExecutableMissing(err)) {
|
|
538
538
|
throw new UserError(
|
|
539
|
-
`
|
|
539
|
+
`inputs runtime command could not run: vx runs it with sh -c and 'sh' is not on PATH (command: ${command}, cwd: ${cwd}). Install a POSIX sh and re-run.`,
|
|
540
540
|
)
|
|
541
541
|
}
|
|
542
|
-
throw new UserError(`
|
|
542
|
+
throw new UserError(`inputs runtime command failed to spawn: ${command} (cwd: ${cwd})`)
|
|
543
543
|
}
|
|
544
544
|
liveProbes.add(proc)
|
|
545
545
|
let mine: Set<ReturnType<typeof Bun.spawn>> | undefined
|
|
@@ -564,7 +564,7 @@ async function runRuntimeCommand(
|
|
|
564
564
|
const lossy = new TextDecoder()
|
|
565
565
|
const shown = `${lossy.decode(stdout)}${lossy.decode(stderr)}`.trim()
|
|
566
566
|
throw new UserError(
|
|
567
|
-
`
|
|
567
|
+
`inputs runtime command exited ${exitCode}: ${command} (cwd: ${cwd})` +
|
|
568
568
|
(shown ? `\n${shown}` : ''),
|
|
569
569
|
)
|
|
570
570
|
}
|
|
@@ -577,7 +577,7 @@ async function runRuntimeCommand(
|
|
|
577
577
|
// A lossy decode keys every invalid byte as U+FFFD: Latin-1 é and è
|
|
578
578
|
// folded the same output and replayed each other's build.
|
|
579
579
|
throw new UserError(
|
|
580
|
-
`
|
|
580
|
+
`inputs runtime command printed bytes that are not UTF-8: ${command} (cwd: ${cwd}). ` +
|
|
581
581
|
`Pipe it through a hash or od.`,
|
|
582
582
|
)
|
|
583
583
|
}
|
|
@@ -1282,52 +1282,85 @@ function lowerBound(files: readonly string[], key: string): number {
|
|
|
1282
1282
|
}
|
|
1283
1283
|
|
|
1284
1284
|
/**
|
|
1285
|
-
*
|
|
1286
|
-
*
|
|
1287
|
-
*
|
|
1288
|
-
*
|
|
1289
|
-
*
|
|
1290
|
-
*
|
|
1291
|
-
* quiet one: the run replays cached stdout, so it even LOOKS like it executed.
|
|
1292
|
-
*
|
|
1293
|
-
* Turbo honours the declaration instead (an explicit `inputs` entry overrides
|
|
1294
|
-
* gitignore). vx cannot afford to, and the reason is not cost: honouring it
|
|
1295
|
-
* would make the cache key depend on a change that `git diff` and
|
|
1296
|
-
* `git ls-files --others` CANNOT SEE, so editing that input would re-key the
|
|
1297
|
-
* task while `--affected` selected nothing. `docs/cli.md` states the invariant
|
|
1298
|
-
* those two surfaces owe each other as a principle — "input hashing sees it, so
|
|
1299
|
-
* `--affected` must too" — and it was closed for lockfiles only recently.
|
|
1300
|
-
* Honouring a gitignored input would reopen it from the other side.
|
|
1301
|
-
*
|
|
1302
|
-
* An entry that does NOT exist on disk stays silent: that is an ordinary stale
|
|
1303
|
-
* declaration, and refusing it would break every config that lists an
|
|
1304
|
-
* optional file.
|
|
1305
|
-
*
|
|
1306
|
-
* "Exists" is lstat, not `Bun.file(p).exists()`: that answers false for a
|
|
1307
|
-
* DIRECTORY, so a literal naming a gitignored `gen/` was never judged and
|
|
1308
|
-
* folded nothing in silence — the stale hit this refusal exists to stop, one
|
|
1309
|
-
* directory above where it looked (item 565, fixed in 576). A literal naming
|
|
1310
|
-
* an EMPTY directory is refused by the same rule, and that is right: git
|
|
1311
|
-
* lists no file under it, so it folds nothing whether tracked or not.
|
|
1285
|
+
* The entries a declaration names outright that git answers nothing for: a
|
|
1286
|
+
* literal, or a glob's fixed prefix (`dist` of `dist/**`), with no listed
|
|
1287
|
+
* path at or under it. Such a path is gitignored or absent, and naming it
|
|
1288
|
+
* is asking for it (owner, 2026-10-16), so {@link ignoredInputs} reads it
|
|
1289
|
+
* from disk. A broad glob (`**`, or `src/**` over a listed `src`) reaches
|
|
1290
|
+
* no ignored file: `node_modules` is never walked.
|
|
1312
1291
|
*/
|
|
1313
|
-
|
|
1314
|
-
literals:
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1292
|
+
function ignoredReach(
|
|
1293
|
+
reach: { prefixes: readonly string[]; literals: readonly string[] },
|
|
1294
|
+
listed: readonly string[],
|
|
1295
|
+
): readonly string[] {
|
|
1296
|
+
const named = outright(reach)
|
|
1297
|
+
if (named.length === 0) return []
|
|
1298
|
+
// Per listing and entries: thousands of tasks share a few declarations.
|
|
1299
|
+
let memo = reachMemo.get(listed)
|
|
1300
|
+
if (memo === undefined) reachMemo.set(listed, (memo = new Map()))
|
|
1301
|
+
const key = named.join('\0')
|
|
1302
|
+
let left = memo.get(key)
|
|
1303
|
+
if (left === undefined) memo.set(key, (left = [...unanswered(named, listed)]))
|
|
1304
|
+
return left
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
const reachMemo = new WeakMap<readonly string[], Map<string, readonly string[]>>()
|
|
1308
|
+
|
|
1309
|
+
/**
|
|
1310
|
+
* A declaration's literals and globs' fixed prefixes, each once: none for a
|
|
1311
|
+
* glob that starts with a wildcard, and none that leaves the directory the
|
|
1312
|
+
* entries are relative to (a project's boundary is hard).
|
|
1313
|
+
*/
|
|
1314
|
+
function outright(reach: { prefixes: readonly string[]; literals: readonly string[] }): string[] {
|
|
1315
|
+
return [
|
|
1316
|
+
...new Set(
|
|
1317
|
+
[...reach.literals, ...reach.prefixes].filter(
|
|
1318
|
+
(p) => p !== '' && p !== '.' && p !== '..' && !p.startsWith('../') && !path.isAbsolute(p),
|
|
1319
|
+
),
|
|
1320
|
+
),
|
|
1321
|
+
]
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
/**
|
|
1325
|
+
* The files on disk under the {@link ignoredReach} entries that `accept`
|
|
1326
|
+
* keeps, sorted and absolute. A file is hashed from disk like any untracked
|
|
1327
|
+
* one; nothing here is stored or restored. An install (`node_modules`)
|
|
1328
|
+
* stays out, as in the enumeration: lockfile plugins key it. lstat, not
|
|
1329
|
+
* `exists()`: that answers false for a directory. Never memoized: a dependency writes these
|
|
1330
|
+
* mid-run.
|
|
1331
|
+
*/
|
|
1332
|
+
function ignoredInputs(
|
|
1333
|
+
root: string,
|
|
1334
|
+
reach: readonly string[],
|
|
1335
|
+
accept: (rel: string) => boolean,
|
|
1336
|
+
): string[] {
|
|
1337
|
+
const found = new Set<string>()
|
|
1338
|
+
for (const entry of reach) {
|
|
1339
|
+
const abs = path.resolve(root, entry)
|
|
1340
|
+
const st = lstatSync(abs, { throwIfNoEntry: false })
|
|
1341
|
+
if (st === undefined) continue
|
|
1342
|
+
if (!st.isDirectory()) {
|
|
1343
|
+
if ((st.isFile() || st.isSymbolicLink()) && !isInstalledPath(entry) && accept(entry))
|
|
1344
|
+
found.add(abs)
|
|
1345
|
+
continue
|
|
1346
|
+
}
|
|
1347
|
+
for (const rel of WALK.scanSync({
|
|
1348
|
+
cwd: abs,
|
|
1349
|
+
onlyFiles: false,
|
|
1350
|
+
followSymlinks: false,
|
|
1351
|
+
dot: true,
|
|
1352
|
+
})) {
|
|
1353
|
+
const full = `${entry}/${rel.split(path.sep).join('/')}`
|
|
1354
|
+
if (isInstalledPath(full) || !accept(full)) continue
|
|
1355
|
+
const file = path.join(abs, rel)
|
|
1356
|
+
if (isInputOnDisk(file)) found.add(file)
|
|
1357
|
+
}
|
|
1328
1358
|
}
|
|
1359
|
+
return [...found].sort()
|
|
1329
1360
|
}
|
|
1330
1361
|
|
|
1362
|
+
const WALK = new Bun.Glob('**')
|
|
1363
|
+
|
|
1331
1364
|
interface ResolveFilesArgs {
|
|
1332
1365
|
projectDir: string
|
|
1333
1366
|
workspaceRoot: string
|
|
@@ -1373,7 +1406,7 @@ function refuseUndecodable(
|
|
|
1373
1406
|
if (bad.length === 0) return
|
|
1374
1407
|
const names = bad.map((abs) => JSON.stringify(relPosix(root, abs)))
|
|
1375
1408
|
throw new UserError(
|
|
1376
|
-
`
|
|
1409
|
+
`inputs.${field} matched ${names.join(', ')} in ${root}: the name is not valid UTF-8 ` +
|
|
1377
1410
|
`(shown with \ufffd), and vx cannot read a file by it. Rename it, or exclude it with a ` +
|
|
1378
1411
|
`negated glob.`,
|
|
1379
1412
|
)
|
|
@@ -1396,7 +1429,7 @@ function refuseOneAlternativeBrace(
|
|
|
1396
1429
|
if (m === null) continue
|
|
1397
1430
|
const inner = m[0].slice(1, -1)
|
|
1398
1431
|
throw new UserError(
|
|
1399
|
-
`
|
|
1432
|
+
`inputs.${field}: "${entry}" holds a brace with one alternative, which matches ` +
|
|
1400
1433
|
`"${inner}" and never a name holding "${m[0]}". Write "${entry.replace(m[0], inner)}", ` +
|
|
1401
1434
|
`or "${entry.replace(m[0], `\\{${inner}\\}`)}" for the braces themselves.`,
|
|
1402
1435
|
)
|
|
@@ -1478,41 +1511,59 @@ function filesPlan(
|
|
|
1478
1511
|
}
|
|
1479
1512
|
|
|
1480
1513
|
/**
|
|
1481
|
-
* Whether a changed path is one of a
|
|
1514
|
+
* Whether a changed path is one of a task's declared file inputs:
|
|
1482
1515
|
* `projectRel` (the path relative to the project that owns it) against
|
|
1483
1516
|
* `files`, `workspaceRel` (relative to the workspace root) against
|
|
1484
1517
|
* `workspaceFiles`; null skips that half, by the globs and `!` exclusions (a path under
|
|
1485
1518
|
* the task's own outputs counts: a committed one is keyed). `--affected` seeds a task with it (owner, 2026-10-04).
|
|
1486
1519
|
*/
|
|
1487
1520
|
export function declaresInput(
|
|
1488
|
-
|
|
1521
|
+
task: TaskConfig,
|
|
1489
1522
|
projectRel: string | null,
|
|
1490
1523
|
workspaceRel: string | null,
|
|
1491
1524
|
): boolean {
|
|
1525
|
+
const inputs = task.inputs
|
|
1526
|
+
if (inputs === undefined) return false
|
|
1492
1527
|
if (projectRel !== null) {
|
|
1493
1528
|
// A changed path under the task's own outputs is a committed file
|
|
1494
1529
|
// (`git diff` lists only what git tracks) or a stray `git status`
|
|
1495
1530
|
// lists: either way it may be an input, so it counts.
|
|
1496
|
-
const plan = filesPlan(
|
|
1531
|
+
const plan = filesPlan(inputs.files, task.cache?.outputs.files ?? [])
|
|
1497
1532
|
if (plan !== null && (inPlan(plan, projectRel) || ownOnly(plan, projectRel))) return true
|
|
1498
1533
|
}
|
|
1499
|
-
const ws =
|
|
1534
|
+
const ws = inputs.workspaceFiles
|
|
1500
1535
|
if (workspaceRel === null || ws === undefined || ws.length === 0) return false
|
|
1501
1536
|
return workspaceMatcher(ws)(workspaceRel)
|
|
1502
1537
|
}
|
|
1503
1538
|
|
|
1504
1539
|
/**
|
|
1505
|
-
* Whether a
|
|
1540
|
+
* Whether a task's `workspaceFiles` may name a file under `dir`
|
|
1506
1541
|
* (workspace-relative): a nested repository git reports as that one path,
|
|
1507
1542
|
* whose files the key folds and a match against the path alone misses.
|
|
1508
1543
|
* By the entries' static prefixes, so a glob that may descend counts.
|
|
1509
1544
|
*/
|
|
1510
|
-
export function workspaceFilesReachInto(
|
|
1511
|
-
const ws =
|
|
1545
|
+
export function workspaceFilesReachInto(task: TaskConfig, dir: string): boolean {
|
|
1546
|
+
const ws = task.inputs?.workspaceFiles
|
|
1512
1547
|
if (ws === undefined || ws.length === 0) return false
|
|
1513
1548
|
return reaches(reachOf(splitNegations(ws).positive).prefixes, dir)
|
|
1514
1549
|
}
|
|
1515
1550
|
|
|
1551
|
+
/**
|
|
1552
|
+
* The paths a task names outright (literals, and globs' fixed prefixes),
|
|
1553
|
+
* `files` relative to its project and `workspaceFiles` to the root: where a
|
|
1554
|
+
* gitignored input can be (`ignoredReach`).
|
|
1555
|
+
*/
|
|
1556
|
+
export function namedOutright(task: TaskConfig): { files: string[]; workspaceFiles: string[] } {
|
|
1557
|
+
const inputs = task.inputs
|
|
1558
|
+
const plan =
|
|
1559
|
+
inputs === undefined ? null : filesPlan(inputs.files, task.cache?.outputs.files ?? [])
|
|
1560
|
+
const ws = inputs?.workspaceFiles ?? []
|
|
1561
|
+
return {
|
|
1562
|
+
files: plan === null ? [] : outright(plan),
|
|
1563
|
+
workspaceFiles: ws.length === 0 ? [] : outright(reachOf(splitNegations(ws).positive)),
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1516
1567
|
function inPlan(plan: FilesPlan, rel: string): boolean {
|
|
1517
1568
|
let input = plan.verdicts.get(rel)
|
|
1518
1569
|
if (input === undefined) {
|
|
@@ -1577,7 +1628,14 @@ async function resolveFiles(
|
|
|
1577
1628
|
// inputs must be walked again.
|
|
1578
1629
|
if (memo !== undefined && memo.snapshot === gitFiles) {
|
|
1579
1630
|
return {
|
|
1580
|
-
files:
|
|
1631
|
+
files: withIgnored(
|
|
1632
|
+
args.projectDir,
|
|
1633
|
+
memo.result,
|
|
1634
|
+
ignoredReach(plan, gitFiles),
|
|
1635
|
+
plan,
|
|
1636
|
+
nested,
|
|
1637
|
+
ownWsOutput,
|
|
1638
|
+
),
|
|
1581
1639
|
listing: listingFor(args, gitFiles, plan, nested, ownWsOutput),
|
|
1582
1640
|
}
|
|
1583
1641
|
}
|
|
@@ -1635,10 +1693,7 @@ async function resolveFiles(
|
|
|
1635
1693
|
if (!input && !committed(abs)) continue
|
|
1636
1694
|
candidates.push(abs)
|
|
1637
1695
|
}
|
|
1638
|
-
const
|
|
1639
|
-
if (unmatchedLiterals.size > 0) {
|
|
1640
|
-
await assertNoInvisibleLiteralInputs(unmatchedLiterals, args.projectDir, 'files')
|
|
1641
|
-
}
|
|
1696
|
+
const reach = ignoredReach(plan, gitFiles)
|
|
1642
1697
|
refuseUndecodable(candidates, undecodable, args.projectDir, 'files')
|
|
1643
1698
|
// Second pass: existence check — but ONLY for paths without a
|
|
1644
1699
|
// trusted index OID. A clean-per-status tracked file necessarily
|
|
@@ -1656,15 +1711,31 @@ async function resolveFiles(
|
|
|
1656
1711
|
break
|
|
1657
1712
|
}
|
|
1658
1713
|
}
|
|
1659
|
-
// Stored only on the way out: a declaration whose literal named an
|
|
1660
|
-
// invisible file threw above, and every task sharing it must throw too.
|
|
1661
1714
|
args.projectFilesCache?.set(memoKey, { snapshot: gitFiles, result: resolved })
|
|
1662
1715
|
return {
|
|
1663
|
-
files:
|
|
1716
|
+
files: withIgnored(args.projectDir, resolved, reach, plan, nested, ownWsOutput),
|
|
1664
1717
|
listing: listingFor(args, gitFiles, plan, nested, ownWsOutput),
|
|
1665
1718
|
}
|
|
1666
1719
|
}
|
|
1667
1720
|
|
|
1721
|
+
/** The resolved files, plus the gitignored ones the declaration names outright. */
|
|
1722
|
+
function withIgnored(
|
|
1723
|
+
projectDir: string,
|
|
1724
|
+
resolved: readonly string[],
|
|
1725
|
+
reach: readonly string[],
|
|
1726
|
+
plan: FilesPlan,
|
|
1727
|
+
nested: (rel: string) => boolean,
|
|
1728
|
+
ownWsOutput: (rel: string) => boolean,
|
|
1729
|
+
): string[] {
|
|
1730
|
+
if (reach.length === 0) return [...resolved]
|
|
1731
|
+
const extra = ignoredInputs(
|
|
1732
|
+
projectDir,
|
|
1733
|
+
reach,
|
|
1734
|
+
(rel) => inPlan(plan, rel) && !nested(rel) && !ownWsOutput(rel),
|
|
1735
|
+
)
|
|
1736
|
+
return extra.length === 0 ? [...resolved] : mergeSorted(resolved, extra)
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1668
1739
|
function listingFor(
|
|
1669
1740
|
args: { projectDir: string; gitFilesCache?: GitFilesCache | undefined },
|
|
1670
1741
|
listed: readonly string[],
|
package/src/cache/key-fold.ts
CHANGED
|
@@ -28,7 +28,7 @@ import type { CacheKeyInput } from './layer.js'
|
|
|
28
28
|
// in any cache key. SCHEMA v21 drops the now-unused outputs_hash
|
|
29
29
|
// column. Early cutoff removed (an upstream that re-emits identical
|
|
30
30
|
// output still re-runs dependents) — rare, not worth the cascade.
|
|
31
|
-
// v23: fold
|
|
31
|
+
// v23: fold inputs.runtime / workspaceRuntime command output into
|
|
32
32
|
// the key (two namespaced sections after env-values). Command strings
|
|
33
33
|
// live in the resolved config (frozen by `vx lock`); the OUTPUT is
|
|
34
34
|
// resolved live every run, so it stays correct under the lock. No
|
|
@@ -144,6 +144,12 @@ export async function foldKey(
|
|
|
144
144
|
* prints nothing.
|
|
145
145
|
*/
|
|
146
146
|
digestValue: (value: string) => string = xxh3hex,
|
|
147
|
+
/**
|
|
148
|
+
* The content behind an input that is a symlink (linked-inputs.ts), folded
|
|
149
|
+
* after the link's own target string. Absent, a link folds its target
|
|
150
|
+
* string alone: a caller that keys no worktree.
|
|
151
|
+
*/
|
|
152
|
+
behind?: (link: string) => Promise<string>,
|
|
147
153
|
): Promise<string> {
|
|
148
154
|
// Seed-chained xxHash3: each step folds one field into the
|
|
149
155
|
// running digest via `xxh3(part, prevDigest)`. Equivalent to the
|
|
@@ -168,7 +174,7 @@ export async function foldKey(
|
|
|
168
174
|
// NOTE on `cap` (entry_inputs capture): the `hash` field stores a
|
|
169
175
|
// DIGEST of each component, never the raw value. For value-bearing kinds
|
|
170
176
|
// (env, runtime, ws-runtime, forward) the payload is a secret or
|
|
171
|
-
// sensitive string (API keys via
|
|
177
|
+
// sensitive string (API keys via inputs.env, runtime-command
|
|
172
178
|
// output, args after `--`), so we push `digestValue(v)` — the diff
|
|
173
179
|
// consumer only needs to know whether a component CHANGED, which a
|
|
174
180
|
// digest preserves losslessly. cache.db must never hold plaintext
|
|
@@ -268,13 +274,27 @@ export async function foldKey(
|
|
|
268
274
|
fileHashes ??= await Promise.all(
|
|
269
275
|
sortedInputs.map((f) => provided?.get(f) ?? hashFile(f).catch(absentOr)),
|
|
270
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
|
+
}
|
|
271
288
|
for (let i = 0; i < sortedInputs.length; i++) {
|
|
272
289
|
const file = sortedInputs[i]!
|
|
273
290
|
const rel = relOf(file)
|
|
274
291
|
const oid = fileHashes[i]!
|
|
275
|
-
|
|
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)
|
|
276
295
|
// The OID is already awaited — file capture is zero extra I/O.
|
|
277
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) })
|
|
278
298
|
}
|
|
279
299
|
|
|
280
300
|
return h.toString(16).padStart(16, '0')
|
package/src/cache/layer.ts
CHANGED
|
@@ -5,13 +5,14 @@
|
|
|
5
5
|
// reference to `Cache` below is a type (the local handle a layer may wrap).
|
|
6
6
|
|
|
7
7
|
import type { Cache } from './cache.js'
|
|
8
|
+
import { fsClockLag, stampGrainMs } from './fs-clock.js'
|
|
8
9
|
|
|
9
10
|
export interface CacheKeyInput {
|
|
10
11
|
taskId: string
|
|
11
12
|
/**
|
|
12
13
|
* Hash of the resolved task config (post-evaluation). Folds in everything
|
|
13
14
|
* the user wrote — command, env declarations (passThrough names + define
|
|
14
|
-
* key/value pairs), dependsOn,
|
|
15
|
+
* key/value pairs), dependsOn, inputs declarations, outputs — including
|
|
15
16
|
* values that arrived via `import` at config-load time.
|
|
16
17
|
*/
|
|
17
18
|
taskConfigHash: string
|
|
@@ -22,12 +23,12 @@ export interface CacheKeyInput {
|
|
|
22
23
|
*/
|
|
23
24
|
envValues: Array<[name: string, value: string | undefined]>
|
|
24
25
|
/**
|
|
25
|
-
* Resolved `
|
|
26
|
+
* Resolved `inputs.runtime` commands as [command, output] pairs
|
|
26
27
|
* (output = trimmed stdout+stderr, resolved live at hash time). Folded
|
|
27
28
|
* into the key in a namespace distinct from workspaceRuntimeValues.
|
|
28
29
|
*/
|
|
29
30
|
runtimeValues?: Array<[command: string, output: string]>
|
|
30
|
-
/** Resolved `
|
|
31
|
+
/** Resolved `inputs.workspaceRuntime` pairs (root-cwd commands). */
|
|
31
32
|
workspaceRuntimeValues?: Array<[command: string, output: string]>
|
|
32
33
|
/** Absolute paths to input files. */
|
|
33
34
|
inputFiles: string[]
|
|
@@ -51,7 +52,7 @@ export interface CacheKeyInput {
|
|
|
51
52
|
* - It expands groups, which contribute a synthetic roll-up hash and no
|
|
52
53
|
* outputs of their own, so a dependent of one would otherwise describe
|
|
53
54
|
* an empty closure.
|
|
54
|
-
* - It ignores `
|
|
55
|
+
* - It ignores `inputs.tasks`. That filter says which upstream KEYS
|
|
55
56
|
* this task's key folds; what the task may READ is `dependsOn`, and
|
|
56
57
|
* locally every dependency's outputs are on disk before the command runs
|
|
57
58
|
* however the filter is written.
|
|
@@ -411,11 +412,11 @@ export const FILE_HASH_RACY_MS = 50
|
|
|
411
412
|
* field (A-38). Pass the UNFLOORED stamp: a kernel's nanosecond stamp lands
|
|
412
413
|
* on a whole second about once in 10^9, one set through a millisecond
|
|
413
414
|
* `utimes` once in a thousand, and either is then trusted up to two seconds
|
|
414
|
-
* later — never wrongly.
|
|
415
|
+
* later — never wrongly. And every window is widened by how far the file
|
|
416
|
+
* system's clock runs behind this process's (`fsClockLag`, fs-clock.ts).
|
|
415
417
|
*/
|
|
416
418
|
export function racyWindowMs(stampMs: number, windowMs: number): number {
|
|
417
|
-
|
|
418
|
-
return stampMs % 1000 === 0 ? windowMs + 1000 : windowMs
|
|
419
|
+
return windowMs + stampGrainMs(stampMs) + fsClockLag()
|
|
419
420
|
}
|
|
420
421
|
|
|
421
422
|
/**
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// A symlinked input keys the content behind it, as Nx reads a linked file
|
|
2
|
+
// where it points (owner, 2026-10-16: "Do like nx"). Its own fold stays the
|
|
3
|
+
// blob of its target STRING (file-hashes.ts), so a retarget to equal bytes
|
|
4
|
+
// moves the key; this is what follows it. The target string alone let an
|
|
5
|
+
// edit behind the link replay the old output (Growth, 2026-10-16), wherever
|
|
6
|
+
// the target lies: in the project, elsewhere in the workspace, outside it.
|
|
7
|
+
// A directory link keys every file beneath it, tracked or not, where Nx
|
|
8
|
+
// does not enter one: an edit there is an edit to what the task reads.
|
|
9
|
+
// `--affected` (workspace/affected.ts) and `vx watch` (cli/watch.ts) read
|
|
10
|
+
// the same targets.
|
|
11
|
+
|
|
12
|
+
import { readdirSync, realpathSync, statSync } from 'node:fs'
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
import { xxh3hex } from '../util/index.js'
|
|
15
|
+
import { absentOr } from './key-fold.js'
|
|
16
|
+
|
|
17
|
+
/** What a dangling link, or a loop of links, holds behind it: nothing to read. */
|
|
18
|
+
const NOTHING_BEHIND = ''
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Directories a linked directory's walk never enters, as Nx's walker never
|
|
22
|
+
* does (its HARDCODED_IGNORE_PATTERNS): a link to a workspace would
|
|
23
|
+
* otherwise key the cache each run writes.
|
|
24
|
+
*/
|
|
25
|
+
const SKIPPED = ['.git', 'node_modules', '.vx/cache', '.yarn/cache']
|
|
26
|
+
const skipped = (rel: string): boolean => SKIPPED.some((s) => rel === s || rel.endsWith(`/${s}`))
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The files under `dir` a linked directory keys, relative and `/`-separated,
|
|
30
|
+
* sorted: every regular file and link at any depth. A link is listed, never
|
|
31
|
+
* entered (`behindLink` follows it), so a loop of directories ends.
|
|
32
|
+
*/
|
|
33
|
+
export function filesBehind(dir: string): string[] {
|
|
34
|
+
const out: string[] = []
|
|
35
|
+
const walk = (abs: string, rel: string): void => {
|
|
36
|
+
let entries
|
|
37
|
+
try {
|
|
38
|
+
entries = readdirSync(abs, { withFileTypes: true })
|
|
39
|
+
} catch {
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
for (const e of entries) {
|
|
43
|
+
const childRel = rel === '' ? e.name : `${rel}/${e.name}`
|
|
44
|
+
if (e.isDirectory()) {
|
|
45
|
+
if (!skipped(childRel)) walk(path.join(abs, e.name), childRel)
|
|
46
|
+
} else if (e.isFile() || e.isSymbolicLink()) out.push(childRel)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
walk(dir, '')
|
|
50
|
+
return out.sort()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The content behind the symlink at `link`, as one string: a file's identity
|
|
55
|
+
* (`hashFile` of its real path), a directory's files and theirs, or
|
|
56
|
+
* `NOTHING_BEHIND` for a dangling link or a loop. A special file (a FIFO, a
|
|
57
|
+
* device) is named, never read: a read can block. Never memoized: an upstream
|
|
58
|
+
* task may write behind the link between two keys.
|
|
59
|
+
*/
|
|
60
|
+
export async function behindLink(
|
|
61
|
+
link: string,
|
|
62
|
+
hashFile: (file: string) => Promise<string>,
|
|
63
|
+
seen: ReadonlySet<string> = new Set(),
|
|
64
|
+
): Promise<string> {
|
|
65
|
+
let real: string
|
|
66
|
+
let st
|
|
67
|
+
try {
|
|
68
|
+
real = realpathSync(link)
|
|
69
|
+
st = statSync(real)
|
|
70
|
+
} catch {
|
|
71
|
+
return NOTHING_BEHIND
|
|
72
|
+
}
|
|
73
|
+
if (seen.has(real)) return NOTHING_BEHIND
|
|
74
|
+
if (st.isFile()) return `file\0${await hashFile(real).catch(absentOr)}`
|
|
75
|
+
if (!st.isDirectory()) return 'special'
|
|
76
|
+
const inner = new Set(seen).add(real)
|
|
77
|
+
const parts = await Promise.all(
|
|
78
|
+
filesBehind(real).map(async (rel) => {
|
|
79
|
+
const abs = path.join(real, rel)
|
|
80
|
+
const id = await hashFile(abs).catch(absentOr)
|
|
81
|
+
return id.startsWith('120000:')
|
|
82
|
+
? `${rel}\0${id}\0${await behindLink(abs, hashFile, inner)}`
|
|
83
|
+
: `${rel}\0${id}`
|
|
84
|
+
}),
|
|
85
|
+
)
|
|
86
|
+
return `dir\0${xxh3hex(parts.join('\n'))}`
|
|
87
|
+
}
|
package/src/cli/foreign-flags.ts
CHANGED
|
@@ -343,7 +343,7 @@ export const FOREIGN_FLAGS: readonly ForeignFlag[] = [
|
|
|
343
343
|
names: ['--global-deps'],
|
|
344
344
|
value: true,
|
|
345
345
|
outcome: 'refuse',
|
|
346
|
-
vx: "declare them in `
|
|
346
|
+
vx: "declare them in `inputs.workspaceFiles` (under `turbo()`, turbo.json's `globalDependencies`)",
|
|
347
347
|
},
|
|
348
348
|
{
|
|
349
349
|
runner: 'turbo',
|