@vzn/vx 0.0.131 → 0.0.132
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -5
- package/src/cache/archive.ts +2 -2
- package/src/cache/cache.ts +6 -6
- package/src/cache/inputs.ts +22 -19
- package/src/cli/cache.ts +20 -5
- package/src/cli/help.ts +2 -1
- package/src/cli/watch-filter.ts +7 -3
- package/src/graph/task-graph.ts +14 -5
- package/src/orchestrator/deferred-outputs.ts +0 -4
- package/src/orchestrator/local-shortcircuit.ts +3 -3
- package/src/util/index.ts +2 -0
- package/src/util/paths.ts +36 -0
- package/src/workspace/config-schema.ts +24 -21
- package/src/workspace/workspace.ts +16 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.132",
|
|
4
4
|
"description": "A fast, correct task runner and build cache for JavaScript monorepos.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -17,10 +17,10 @@
|
|
|
17
17
|
"node": ">=18"
|
|
18
18
|
},
|
|
19
19
|
"optionalDependencies": {
|
|
20
|
-
"@vzn/vx-linux-x64": "0.0.
|
|
21
|
-
"@vzn/vx-linux-arm64": "0.0.
|
|
22
|
-
"@vzn/vx-darwin-x64": "0.0.
|
|
23
|
-
"@vzn/vx-darwin-arm64": "0.0.
|
|
20
|
+
"@vzn/vx-linux-x64": "0.0.132",
|
|
21
|
+
"@vzn/vx-linux-arm64": "0.0.132",
|
|
22
|
+
"@vzn/vx-darwin-x64": "0.0.132",
|
|
23
|
+
"@vzn/vx-darwin-arm64": "0.0.132"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"@anthropic-ai/sandbox-runtime": "^0.0.76"
|
package/src/cache/archive.ts
CHANGED
|
@@ -224,14 +224,14 @@ export async function planArtifact(args: PackArgs): Promise<ArtifactPlan> {
|
|
|
224
224
|
const st = await stat(abs).catch((err: NodeJS.ErrnoException) => {
|
|
225
225
|
if (err.code === 'ENOENT') {
|
|
226
226
|
throw new UserError(
|
|
227
|
-
`output ${shown} is a dangling symlink: vx stores regular files only — emit a file there, or narrow cache.outputs.files to the files the task produces
|
|
227
|
+
`output ${shown} is a dangling symlink: vx stores regular files only — emit a file there, or narrow cache.outputs.files to the files the task produces, or take it back with a '!' entry`,
|
|
228
228
|
)
|
|
229
229
|
}
|
|
230
230
|
throw err
|
|
231
231
|
})
|
|
232
232
|
if (!st.isFile()) {
|
|
233
233
|
throw new UserError(
|
|
234
|
-
`output ${shown} is not a regular file (a symlink to a directory?): vx stores regular files only — emit a file there, or narrow cache.outputs.files to the files the task produces
|
|
234
|
+
`output ${shown} is not a regular file (a symlink to a directory?): vx stores regular files only — emit a file there, or narrow cache.outputs.files to the files the task produces, or take it back with a '!' entry`,
|
|
235
235
|
)
|
|
236
236
|
}
|
|
237
237
|
// Windows reports 0o666 (0o444 read-only) and never an exec bit: a
|
package/src/cache/cache.ts
CHANGED
|
@@ -32,16 +32,15 @@ import path from 'node:path'
|
|
|
32
32
|
import { createTables } from './schema.js'
|
|
33
33
|
import {
|
|
34
34
|
UserError,
|
|
35
|
-
asTrees,
|
|
36
35
|
formatBytes,
|
|
37
36
|
isDiskFull,
|
|
38
37
|
isFsRefusal,
|
|
39
38
|
isOutOfFds,
|
|
40
39
|
OUT_OF_FDS_HINT,
|
|
40
|
+
outputMatcher,
|
|
41
41
|
relPosix,
|
|
42
42
|
span,
|
|
43
43
|
splitTaskId,
|
|
44
|
-
taskGlob,
|
|
45
44
|
xxh3hex,
|
|
46
45
|
} from '../util/index.js'
|
|
47
46
|
import {
|
|
@@ -1952,13 +1951,14 @@ function assertArtifactNames(
|
|
|
1952
1951
|
}
|
|
1953
1952
|
}
|
|
1954
1953
|
if (declared === undefined) return
|
|
1955
|
-
|
|
1956
|
-
const
|
|
1954
|
+
// A path a `!` entry takes back is no declared output either (A-44).
|
|
1955
|
+
const isFile = outputMatcher(declared.files)
|
|
1956
|
+
const isWsFile = outputMatcher(declared.workspaceFiles)
|
|
1957
1957
|
for (const name of names) {
|
|
1958
1958
|
const ok = name.startsWith('outputs/')
|
|
1959
|
-
?
|
|
1959
|
+
? isFile(name.slice('outputs/'.length))
|
|
1960
1960
|
: name.startsWith(WORKSPACE_OUTPUT_PREFIX)
|
|
1961
|
-
?
|
|
1961
|
+
? isWsFile(name.slice(WORKSPACE_OUTPUT_PREFIX.length))
|
|
1962
1962
|
: true
|
|
1963
1963
|
if (!ok) {
|
|
1964
1964
|
throw new CorruptArtifactError(
|
package/src/cache/inputs.ts
CHANGED
|
@@ -25,7 +25,9 @@ import {
|
|
|
25
25
|
isExecutableMissing,
|
|
26
26
|
isLiteralPattern,
|
|
27
27
|
normalizeGlob,
|
|
28
|
+
outputMatcher,
|
|
28
29
|
shellArgv,
|
|
30
|
+
splitNegations,
|
|
29
31
|
slashBraceExpansions,
|
|
30
32
|
taskGlob,
|
|
31
33
|
UserError,
|
|
@@ -242,11 +244,10 @@ async function resolveWorkspaceFiles(args: {
|
|
|
242
244
|
}
|
|
243
245
|
if (positive.length === 0) return []
|
|
244
246
|
|
|
245
|
-
const excludeGlobs = [
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
].map(globFor)
|
|
247
|
+
const excludeGlobs = [...ALWAYS_IGNORE, ...asTrees(negative)].map(globFor)
|
|
248
|
+
// A path the task's own outputs take back with `!` is no output, so it
|
|
249
|
+
// stays an input (A-44).
|
|
250
|
+
const ownOutput = outputMatcher(args.ownWorkspaceOutputs, globFor)
|
|
250
251
|
const positiveGlobs = asTrees(positive).map(globFor)
|
|
251
252
|
// Workspace-wide partition, keyed by the workspace root. Populated
|
|
252
253
|
// up-front by `populateGitFilesCache(..., workspaceWide: true)` when
|
|
@@ -276,7 +277,7 @@ async function resolveWorkspaceFiles(args: {
|
|
|
276
277
|
gitFiles,
|
|
277
278
|
positive,
|
|
278
279
|
positiveGlobs,
|
|
279
|
-
excludeGlobs,
|
|
280
|
+
(rel) => excludeGlobs.some((g) => g.match(rel)) || ownOutput(rel),
|
|
280
281
|
undecodable,
|
|
281
282
|
)
|
|
282
283
|
if (memoKey !== undefined) args.memo!.set(memoKey, { snapshot: gitFiles, result })
|
|
@@ -288,7 +289,7 @@ async function resolveWorkspaceFilesOver(
|
|
|
288
289
|
gitFiles: readonly string[],
|
|
289
290
|
positive: readonly string[],
|
|
290
291
|
positiveGlobs: readonly Bun.Glob[],
|
|
291
|
-
|
|
292
|
+
excluded: (rel: string) => boolean,
|
|
292
293
|
undecodable: ReadonlySet<string> | undefined,
|
|
293
294
|
): Promise<string[]> {
|
|
294
295
|
// Second call site of the literal-input guard. `resolveWorkspaceFiles`
|
|
@@ -302,7 +303,7 @@ async function resolveWorkspaceFilesOver(
|
|
|
302
303
|
for (const rel of gitFiles) {
|
|
303
304
|
if (unmatchedLiterals.size > 0) settleLiterals(unmatchedLiterals, rel)
|
|
304
305
|
if (!positiveGlobs.some((g) => g.match(rel))) continue
|
|
305
|
-
if (
|
|
306
|
+
if (excluded(rel)) continue
|
|
306
307
|
candidates.push(path.resolve(args.workspaceRoot, rel))
|
|
307
308
|
}
|
|
308
309
|
if (unmatchedLiterals.size > 0) {
|
|
@@ -510,11 +511,12 @@ export async function resolveOutputs(args: {
|
|
|
510
511
|
outputs: string[]
|
|
511
512
|
nestedProjectDirs: string[]
|
|
512
513
|
}): Promise<string[]> {
|
|
513
|
-
|
|
514
|
-
|
|
514
|
+
const { positive, negative } = splitNegations(args.outputs)
|
|
515
|
+
if (positive.length === 0) return []
|
|
516
|
+
const excludeGlobs = [...outputExcludes(positive), ...asTrees(negative).map(globFor)]
|
|
515
517
|
const scanned = [
|
|
516
518
|
...(await scanUnion(
|
|
517
|
-
asTrees(
|
|
519
|
+
asTrees(positive),
|
|
518
520
|
excludeGlobs,
|
|
519
521
|
args.projectDir,
|
|
520
522
|
inNestedProject(args.projectDir, args.nestedProjectDirs),
|
|
@@ -781,10 +783,10 @@ export async function resolveWorkspaceOutputs(args: {
|
|
|
781
783
|
workspaceRoot: string
|
|
782
784
|
outputs: string[]
|
|
783
785
|
}): Promise<string[]> {
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
]
|
|
786
|
+
const { positive, negative } = splitNegations(args.outputs)
|
|
787
|
+
if (positive.length === 0) return []
|
|
788
|
+
const excludeGlobs = [...outputExcludes(positive), ...asTrees(negative).map(globFor)]
|
|
789
|
+
const scanned = [...(await scanUnion(asTrees(positive), excludeGlobs, args.workspaceRoot))]
|
|
788
790
|
// Same containment as the project twin, anchored one level out. These globs
|
|
789
791
|
// deliberately ignore PROJECT boundaries — that is the escape hatch — but
|
|
790
792
|
// escaping the WORKSPACE was never part of it, and `cleanWorkspaceOutputs`
|
|
@@ -964,9 +966,10 @@ async function resolveFiles(args: ResolveFilesArgs): Promise<string[]> {
|
|
|
964
966
|
if (positive.length === 0) return []
|
|
965
967
|
|
|
966
968
|
const nested = inNestedProject(args.projectDir, args.nestedProjectDirs)
|
|
967
|
-
const excludeGlobs = [...ALWAYS_IGNORE, ...asTrees(
|
|
968
|
-
|
|
969
|
-
)
|
|
969
|
+
const excludeGlobs = [...ALWAYS_IGNORE, ...asTrees(negative)].map(globFor)
|
|
970
|
+
// A path the task's own outputs take back with `!` is no output, so it
|
|
971
|
+
// stays an input: a tracked file under `dist` the build reads (A-44).
|
|
972
|
+
const ownOutput = outputMatcher(args.ownOutputs, globFor)
|
|
970
973
|
|
|
971
974
|
// Defer to git for the file set (Turbo / Nx parity). Nested .gitignore
|
|
972
975
|
// files, .git/info/exclude, and global excludes all participate
|
|
@@ -1031,7 +1034,7 @@ async function resolveFiles(args: ResolveFilesArgs): Promise<string[]> {
|
|
|
1031
1034
|
}
|
|
1032
1035
|
}
|
|
1033
1036
|
if (!matched) continue
|
|
1034
|
-
if (nested(rel) || excludeGlobs.some((g) => g.match(rel))) continue
|
|
1037
|
+
if (nested(rel) || excludeGlobs.some((g) => g.match(rel)) || ownOutput(rel)) continue
|
|
1035
1038
|
candidates.push(path.resolve(args.projectDir, rel))
|
|
1036
1039
|
}
|
|
1037
1040
|
if (unmatchedLiterals.size > 0) {
|
package/src/cli/cache.ts
CHANGED
|
@@ -59,6 +59,7 @@ interface PruneArgs {
|
|
|
59
59
|
olderThanMs?: number
|
|
60
60
|
maxBytes?: number
|
|
61
61
|
dryRun?: boolean
|
|
62
|
+
format?: 'pretty' | 'json'
|
|
62
63
|
/** `--cache-dir`: prune the cache a run with the same flag uses. */
|
|
63
64
|
cacheDir?: string
|
|
64
65
|
error?: string
|
|
@@ -110,6 +111,12 @@ export function parsePruneArgs(args: readonly string[]): PruneArgs {
|
|
|
110
111
|
out.maxBytes = bytes
|
|
111
112
|
} else if (a === '--dry-run') {
|
|
112
113
|
out.dryRun = true
|
|
114
|
+
} else if (a === '--format' || a?.startsWith('--format=')) {
|
|
115
|
+
const v = a === '--format' ? args[++i] : a.slice('--format='.length)
|
|
116
|
+
if (v !== 'pretty' && v !== 'json') {
|
|
117
|
+
return { error: `--format must be pretty or json${seeHelp('cache')}` }
|
|
118
|
+
}
|
|
119
|
+
out.format = v
|
|
113
120
|
} else {
|
|
114
121
|
const cd = parseCacheDirFlag(args, i)
|
|
115
122
|
if (cd === null)
|
|
@@ -151,10 +158,10 @@ async function pruneCmd(args: readonly string[]): Promise<number> {
|
|
|
151
158
|
// to find that out made it: `.vx/cache` with a database and a
|
|
152
159
|
// `.gitignore`, where item 900 had held the dry run to making nothing
|
|
153
160
|
// (and a read-only checkout was refused as unwritable instead).
|
|
161
|
+
const dry = parsed.dryRun === true
|
|
162
|
+
const json = parsed.format === 'json'
|
|
154
163
|
if (!existsSync(dir)) {
|
|
155
|
-
|
|
156
|
-
parsed.dryRun === true ? 'Would prune 0 entries (0 B)\n' : 'Pruned 0 entries (0 B freed)\n',
|
|
157
|
-
)
|
|
164
|
+
printPruned({ evicted: 0, bytesFreed: 0, orphans: 0, orphanBytes: 0 }, dry, json)
|
|
158
165
|
return 0
|
|
159
166
|
}
|
|
160
167
|
if (parsed.dryRun === true) {
|
|
@@ -163,7 +170,7 @@ async function pruneCmd(args: readonly string[]): Promise<number> {
|
|
|
163
170
|
warnToStderr(
|
|
164
171
|
`[vx] the cache index is schema ${earlier.found} from an earlier vx: the prune resets it first, and every artifact past the hour's grace is then an orphan`,
|
|
165
172
|
)
|
|
166
|
-
printPruned({ evicted: 0, bytesFreed: 0, ...earlier }, true)
|
|
173
|
+
printPruned({ evicted: 0, bytesFreed: 0, ...earlier }, true, json)
|
|
167
174
|
return 0
|
|
168
175
|
}
|
|
169
176
|
}
|
|
@@ -187,7 +194,7 @@ async function pruneCmd(args: readonly string[]): Promise<number> {
|
|
|
187
194
|
if (parsed.olderThanMs !== undefined) opts.olderThanMs = parsed.olderThanMs
|
|
188
195
|
if (parsed.maxBytes !== undefined) opts.maxBytes = parsed.maxBytes
|
|
189
196
|
if (parsed.dryRun) opts.dryRun = true
|
|
190
|
-
printPruned(await cache.prune(opts),
|
|
197
|
+
printPruned(await cache.prune(opts), dry, json)
|
|
191
198
|
} finally {
|
|
192
199
|
cache.close()
|
|
193
200
|
await releaseRunLock()
|
|
@@ -198,7 +205,15 @@ async function pruneCmd(args: readonly string[]): Promise<number> {
|
|
|
198
205
|
function printPruned(
|
|
199
206
|
result: { evicted: number; bytesFreed: number; orphans: number; orphanBytes: number },
|
|
200
207
|
dry: boolean,
|
|
208
|
+
json: boolean,
|
|
201
209
|
): void {
|
|
210
|
+
if (json) {
|
|
211
|
+
const { evicted, bytesFreed, orphans, orphanBytes } = result
|
|
212
|
+
process.stdout.write(
|
|
213
|
+
`${JSON.stringify({ dryRun: dry, evicted, bytesFreed, orphans, orphanBytes })}\n`,
|
|
214
|
+
)
|
|
215
|
+
return
|
|
216
|
+
}
|
|
202
217
|
const orphans =
|
|
203
218
|
result.orphans > 0
|
|
204
219
|
? `, ${dry ? 'would reap' : 'reaped'} ${result.orphans} orphaned artifact${result.orphans === 1 ? '' : 's'} (${formatBytes(result.orphanBytes)})`
|
package/src/cli/help.ts
CHANGED
|
@@ -42,7 +42,7 @@ export function helpText(pluginCommands: readonly string[] = []): string {
|
|
|
42
42
|
'Usage:',
|
|
43
43
|
' vx run [OPTIONS] [TASK | PKG#TASK] [-- forwarded-args...]',
|
|
44
44
|
' vx watch [OPTIONS] TASK [-- forwarded-args...]',
|
|
45
|
-
' vx cache prune [--older-than <duration>] [--max-size <size>] [--dry-run] [--cache-dir <path>]',
|
|
45
|
+
' vx cache prune [--older-than <duration>] [--max-size <size>] [--dry-run] [--format pretty|json] [--cache-dir <path>]',
|
|
46
46
|
' vx lock [--check]',
|
|
47
47
|
' vx init [--dry] [--force] [--mjs] [--plugin <seam>]',
|
|
48
48
|
' vx upgrade [tag]',
|
|
@@ -125,6 +125,7 @@ export function helpText(pluginCommands: readonly string[] = []): string {
|
|
|
125
125
|
' vx cache prune --older-than 30d Evict entries last accessed > 30 days ago.',
|
|
126
126
|
' vx cache prune --max-size 1G Keep total cache under 1 GB (LRU eviction).',
|
|
127
127
|
' --dry-run Say what the policy would reap; delete nothing.',
|
|
128
|
+
' --format <fmt> pretty (default) | json.',
|
|
128
129
|
' --cache-dir <path> The cache a run with the same flag uses (why, last, info too).',
|
|
129
130
|
'',
|
|
130
131
|
' Duration units: s, m, h, d. Size units: K, M, G, T (powers of 1024).',
|
package/src/cli/watch-filter.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
executablePath,
|
|
8
8
|
isLiteralPattern,
|
|
9
9
|
normalizeGlob,
|
|
10
|
+
outputMatcher,
|
|
10
11
|
staticPrefix,
|
|
11
12
|
taskGlob,
|
|
12
13
|
} from '../util/index.js'
|
|
@@ -67,11 +68,14 @@ export function makeWatchIgnore(
|
|
|
67
68
|
// every ancestor of it under the dir are output containers.
|
|
68
69
|
// A literal entry means the file or its whole tree — the resolver's own
|
|
69
70
|
// rule (`asTrees`), so the tree's files never count as edits.
|
|
71
|
+
// A `!` entry takes its paths back, so an edit there is an edit (A-44);
|
|
72
|
+
// compiled as a glob it would be `Bun.Glob`'s own negation and hide every
|
|
73
|
+
// other path.
|
|
70
74
|
const declared = [...outputs].map(
|
|
71
75
|
([dir, globs]) =>
|
|
72
76
|
[
|
|
73
77
|
path.resolve(dir),
|
|
74
|
-
|
|
78
|
+
outputMatcher(globs),
|
|
75
79
|
globs.map(outputContainer).filter((c) => c !== ''),
|
|
76
80
|
] as const,
|
|
77
81
|
)
|
|
@@ -101,13 +105,13 @@ export function makeWatchIgnore(
|
|
|
101
105
|
const abs = path.resolve(base, filename)
|
|
102
106
|
if (abs === cacheAbs || abs.startsWith(cacheAbs + path.sep)) return true
|
|
103
107
|
if (isInput(abs)) return false
|
|
104
|
-
for (const [dir,
|
|
108
|
+
for (const [dir, isOutput, containers] of declared) {
|
|
105
109
|
if (!abs.startsWith(dir + path.sep)) continue
|
|
106
110
|
const rel = abs
|
|
107
111
|
.slice(dir.length + 1)
|
|
108
112
|
.split(path.sep)
|
|
109
113
|
.join('/')
|
|
110
|
-
if (
|
|
114
|
+
if (isOutput(rel)) return true
|
|
111
115
|
if (containers.some((c) => c === rel || c.startsWith(`${rel}/`))) return true
|
|
112
116
|
}
|
|
113
117
|
return false
|
package/src/graph/task-graph.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { ProjectConfig, TaskConfig } from '../config.js'
|
|
|
3
3
|
import {
|
|
4
4
|
asTrees,
|
|
5
5
|
isLiteralPattern,
|
|
6
|
+
splitNegations,
|
|
6
7
|
staticPrefix,
|
|
7
8
|
taskGlob,
|
|
8
9
|
UserError,
|
|
@@ -773,9 +774,16 @@ function detectOutputCollisions(nodes: Map<string, TaskNode>, workspaceRoot?: st
|
|
|
773
774
|
if ((outs?.workspaceFiles?.length ?? 0) > 0) wsDeclarers.push(n)
|
|
774
775
|
}
|
|
775
776
|
|
|
776
|
-
|
|
777
|
+
// Where each task's outputs MIGHT land: its positive globs. A `!` entry
|
|
778
|
+
// only narrows, so leaving it out can refuse a pair it separates, never
|
|
779
|
+
// pass one it does not (A-44); read as a glob it is `Bun.Glob`'s own
|
|
780
|
+
// negation, which overlaps nearly everything.
|
|
781
|
+
const positiveOf = (globs: readonly string[] | undefined): readonly string[] | undefined =>
|
|
782
|
+
globs === undefined ? undefined : splitNegations(globs).positive
|
|
783
|
+
const filesOf = (n: TaskNode): readonly string[] | undefined =>
|
|
784
|
+
positiveOf(n.config.cache?.outputs.files)
|
|
777
785
|
const wsFilesOf = (n: TaskNode): readonly string[] | undefined =>
|
|
778
|
-
n.config.cache?.outputs.workspaceFiles
|
|
786
|
+
positiveOf(n.config.cache?.outputs.workspaceFiles)
|
|
779
787
|
for (const bucket of byProject.values()) {
|
|
780
788
|
// Most projects hold one task with outputs, and a call per project
|
|
781
789
|
// cost the cold build a measured 0.4 ms at 1,000 projects.
|
|
@@ -994,10 +1002,11 @@ function collide(
|
|
|
994
1002
|
// edge the two run in either order, and the refusal below stands.
|
|
995
1003
|
const [up, down] = reaches(b.id, a.id) ? [a, b] : reaches(a.id, b.id) ? [b, a] : []
|
|
996
1004
|
if (up !== undefined && down !== undefined) {
|
|
997
|
-
const downGlobs =
|
|
998
|
-
field === 'files'
|
|
1005
|
+
const downGlobs = splitNegations(
|
|
1006
|
+
(field === 'files'
|
|
999
1007
|
? down.config.cache?.outputs.files
|
|
1000
|
-
: down.config.cache?.outputs.workspaceFiles
|
|
1008
|
+
: down.config.cache?.outputs.workspaceFiles) ?? [],
|
|
1009
|
+
).positive
|
|
1001
1010
|
;(down.addsToOutputsOf ??= []).push(up.id)
|
|
1002
1011
|
;(up.outputsAddedToBy ??= []).push(...(downGlobs ?? []))
|
|
1003
1012
|
return
|
|
@@ -58,10 +58,6 @@ export class DeferredOutputs {
|
|
|
58
58
|
return [...this.entries.keys()].sort()
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
get size(): number {
|
|
62
|
-
return this.entries.size
|
|
63
|
-
}
|
|
64
|
-
|
|
65
61
|
/**
|
|
66
62
|
* Materialise every deferred producer in `node`'s TRANSITIVE dependency
|
|
67
63
|
* closure. Which upstream bytes a command reads is unknowable — that is
|
|
@@ -182,9 +182,9 @@ function restoreTierExclusions(nodes: Map<string, TaskNode>, workspaceRoot: stri
|
|
|
182
182
|
let everything = false
|
|
183
183
|
for (const node of nodes.values()) {
|
|
184
184
|
for (const raw of node.config.cache?.outputs.workspaceFiles ?? []) {
|
|
185
|
-
//
|
|
186
|
-
// (
|
|
187
|
-
//
|
|
185
|
+
// A `!` entry needs no skip: its prefix (`!gen`) names no directory,
|
|
186
|
+
// so it excludes nothing (A-44; the `continue` that stood here was
|
|
187
|
+
// dropped in item 640).
|
|
188
188
|
const glob = normalizeGlob(raw)
|
|
189
189
|
const prefix = staticPrefix(glob)
|
|
190
190
|
if (prefix === '.' || prefix === '' || prefix === '/') everything = true
|
package/src/util/index.ts
CHANGED
package/src/util/paths.ts
CHANGED
|
@@ -228,6 +228,42 @@ export function asTrees(patterns: readonly string[]): string[] {
|
|
|
228
228
|
return out
|
|
229
229
|
}
|
|
230
230
|
|
|
231
|
+
/**
|
|
232
|
+
* An output list's positive globs, and what its `!` entries take back
|
|
233
|
+
* (A-44). A reader that only needs where outputs MIGHT land (an overlap
|
|
234
|
+
* refusal, a preliminary key) takes `positive` alone; one that decides
|
|
235
|
+
* whether a path IS an output takes both, through `outputMatcher`.
|
|
236
|
+
*/
|
|
237
|
+
export function splitNegations(globs: readonly string[]): {
|
|
238
|
+
positive: string[]
|
|
239
|
+
negative: string[]
|
|
240
|
+
} {
|
|
241
|
+
const positive: string[] = []
|
|
242
|
+
const negative: string[] = []
|
|
243
|
+
for (const g of globs) {
|
|
244
|
+
if (g.startsWith('!')) negative.push(g.slice(1))
|
|
245
|
+
else positive.push(g)
|
|
246
|
+
}
|
|
247
|
+
return { positive, negative }
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Whether a relative path is one of the outputs `globs` declare: a positive
|
|
252
|
+
* glob selects it and no `!` entry takes it back. A `!` entry fed to a
|
|
253
|
+
* matcher as-is would be `Bun.Glob`'s own negation, true of every OTHER
|
|
254
|
+
* path (A-44).
|
|
255
|
+
*/
|
|
256
|
+
export function outputMatcher(
|
|
257
|
+
globs: readonly string[],
|
|
258
|
+
compile: (pattern: string) => Bun.Glob = taskGlob,
|
|
259
|
+
): (rel: string) => boolean {
|
|
260
|
+
const { positive, negative } = splitNegations(globs)
|
|
261
|
+
if (positive.length === 0) return () => false
|
|
262
|
+
const pos = asTrees(positive).map(compile)
|
|
263
|
+
const neg = asTrees(negative).map(compile)
|
|
264
|
+
return (rel) => pos.some((g) => g.match(rel)) && !neg.some((g) => g.match(rel))
|
|
265
|
+
}
|
|
266
|
+
|
|
231
267
|
/**
|
|
232
268
|
* `pattern` with its first brace group expanded, recursively, while that
|
|
233
269
|
* group holds a `/`; any other pattern as itself, since `Bun.Glob`
|
|
@@ -511,7 +511,7 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
|
|
|
511
511
|
if (typeof g !== 'string' || g.length === 0) {
|
|
512
512
|
throw new UserError(`${where}.cache.outputs.files must be an array of non-empty strings`)
|
|
513
513
|
}
|
|
514
|
-
if (g.startsWith('/')) {
|
|
514
|
+
if (g.startsWith('/') || g.startsWith('!/')) {
|
|
515
515
|
throw new UserError(
|
|
516
516
|
`${where}.cache.outputs.files: absolute paths are not allowed (got "${g}") — ` +
|
|
517
517
|
`outputs must be project-relative globs`,
|
|
@@ -530,14 +530,10 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
|
|
|
530
530
|
`name the directory the task writes, such as "dist/**"`,
|
|
531
531
|
)
|
|
532
532
|
}
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
`path beginning with '!' and matches nothing. List the outputs you DO produce.`,
|
|
538
|
-
)
|
|
539
|
-
}
|
|
540
|
-
const own = ownFileCovered(g, configPath)
|
|
533
|
+
// A `!` entry takes a path back from the outputs (A-44): it is not
|
|
534
|
+
// cleaned, saved or restored, and it stays an input.
|
|
535
|
+
assertNotDoubleNegated(g, `${where}.cache.outputs.files`)
|
|
536
|
+
const own = g.startsWith('!') ? null : ownFileCovered(g, configPath)
|
|
541
537
|
if (own !== null) {
|
|
542
538
|
throw new UserError(
|
|
543
539
|
`${where}.cache.outputs.files: "${g}" covers the project's own ${own} — vx deletes a ` +
|
|
@@ -546,6 +542,7 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
|
|
|
546
542
|
)
|
|
547
543
|
}
|
|
548
544
|
}
|
|
545
|
+
assertOutputsNotNegationOnly(outFiles as string[], `${where}.cache.outputs.files`)
|
|
549
546
|
// Same for inputs.files.
|
|
550
547
|
for (const g of (inputs as { files: unknown[] }).files) {
|
|
551
548
|
if (typeof g !== 'string' || g.length === 0) {
|
|
@@ -861,7 +858,7 @@ function assertNotDoubleNegated(glob: string, where: string): void {
|
|
|
861
858
|
`${where}: '!!' is not a double negation (got "${glob}") — it INVERTS the set. ` +
|
|
862
859
|
`One '!' is stripped and the remainder is compiled as a glob, which applies its own ` +
|
|
863
860
|
`leading-'!' negation, so this excludes everything EXCEPT ${JSON.stringify(inner)} and ` +
|
|
864
|
-
`
|
|
861
|
+
`keeps only that. Use ${JSON.stringify(`!${inner}`)} to subtract it, or ` +
|
|
865
862
|
`${JSON.stringify(inner)} to include it.`,
|
|
866
863
|
)
|
|
867
864
|
}
|
|
@@ -996,9 +993,8 @@ function assertNotNegationOnly(globs: readonly string[], where: string): void {
|
|
|
996
993
|
}
|
|
997
994
|
|
|
998
995
|
/**
|
|
999
|
-
* `negation: false` for OUTPUT globs
|
|
1000
|
-
*
|
|
1001
|
-
* literal path starting with `!` and matches nothing.
|
|
996
|
+
* `negation: false` for OUTPUT globs: a `!` entry takes a path back (A-44),
|
|
997
|
+
* and a list of only negations selects nothing and is refused.
|
|
1002
998
|
*/
|
|
1003
999
|
function validateWorkspaceGlobs(v: unknown, where: string, negation: boolean): void {
|
|
1004
1000
|
if (!Array.isArray(v)) {
|
|
@@ -1025,16 +1021,23 @@ function validateWorkspaceGlobs(v: unknown, where: string, negation: boolean): v
|
|
|
1025
1021
|
`${where}: "${g}" names the workspace root itself and selects nothing — use "**" for everything under it`,
|
|
1026
1022
|
)
|
|
1027
1023
|
}
|
|
1028
|
-
|
|
1029
|
-
throw new UserError(
|
|
1030
|
-
`${where}: negation is not supported (got "${g}") — ` +
|
|
1031
|
-
`unlike inputs, output globs are never split on '!', so this is read as a literal ` +
|
|
1032
|
-
`path beginning with '!' and matches nothing. List the outputs you DO produce.`,
|
|
1033
|
-
)
|
|
1034
|
-
}
|
|
1035
|
-
if (negation) assertNotDoubleNegated(g, where)
|
|
1024
|
+
assertNotDoubleNegated(g, where)
|
|
1036
1025
|
}
|
|
1037
1026
|
if (negation) assertNotNegationOnly(v as string[], where)
|
|
1027
|
+
else assertOutputsNotNegationOnly(v as string[], where)
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/**
|
|
1031
|
+
* An output list of only `!` entries selects nothing: the task saves and
|
|
1032
|
+
* restores nothing while it reads as if it declared outputs (A-44).
|
|
1033
|
+
*/
|
|
1034
|
+
function assertOutputsNotNegationOnly(globs: readonly string[], where: string): void {
|
|
1035
|
+
if (globs.length === 0 || globs.some((g) => !g.startsWith('!'))) return
|
|
1036
|
+
throw new UserError(
|
|
1037
|
+
`${where}: every entry is a negation, which selects NOTHING (got ${JSON.stringify(globs)}) — ` +
|
|
1038
|
+
`a '!' entry only takes back what a positive glob selected. Add the outputs the task ` +
|
|
1039
|
+
`produces, e.g. ['dist/**', ${JSON.stringify(globs[0])}], or declare [] for none.`,
|
|
1040
|
+
)
|
|
1038
1041
|
}
|
|
1039
1042
|
|
|
1040
1043
|
const SANDBOX_FIELDS = new Set([
|
|
@@ -507,9 +507,13 @@ export async function listProjects(workspace: Workspace): Promise<ProjectMeta[]>
|
|
|
507
507
|
// Run all package globs concurrently. Disk-bound walks parallelize
|
|
508
508
|
// well; serializing them just stretches the discovery phase by N×.
|
|
509
509
|
const { positive, negative } = splitPackageGlobs(workspace.packageGlobs)
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
510
|
+
// A root the globs do not list is a project when it holds a vx config
|
|
511
|
+
// (D-39): the package manager's member list stays the manager's, and
|
|
512
|
+
// the root's globs stop at every member as any parent project's do.
|
|
513
|
+
const [perPattern, rootConfig] = await Promise.all([
|
|
514
|
+
Promise.all(positive.map((pattern) => memberDirs(workspace.root, pattern))),
|
|
515
|
+
findConfigFile(workspace.root),
|
|
516
|
+
])
|
|
513
517
|
const matches = new Set<string>()
|
|
514
518
|
for (const arr of perPattern) {
|
|
515
519
|
for (const m of arr) {
|
|
@@ -517,6 +521,7 @@ export async function listProjects(workspace: Workspace): Promise<ProjectMeta[]>
|
|
|
517
521
|
matches.add(m)
|
|
518
522
|
}
|
|
519
523
|
}
|
|
524
|
+
if (rootConfig !== null) matches.add(workspace.root)
|
|
520
525
|
|
|
521
526
|
// Per-project discovery: the manifest read answers "is there a
|
|
522
527
|
// package.json" (an ENOENT is the "not a member" answer, at the cost of
|
|
@@ -558,8 +563,15 @@ export async function listProjects(workspace: Workspace): Promise<ProjectMeta[]>
|
|
|
558
563
|
}
|
|
559
564
|
const previous = seenName.get(pkg.name)
|
|
560
565
|
if (previous) {
|
|
566
|
+
// pnpm accepts two manifests of one name (sveltejs/kit's test apps,
|
|
567
|
+
// the first-five-minutes walk, 2026-09-28); vx cannot, since a
|
|
568
|
+
// project is addressed by it. Say which two, short, and the way on.
|
|
561
569
|
throw new UserError(
|
|
562
|
-
`Duplicate package name "${pkg.name}" in workspace: ${previous
|
|
570
|
+
`Duplicate package name "${pkg.name}" in workspace: ${[previous, dir]
|
|
571
|
+
.map((d) => relPosix(workspace.root, d))
|
|
572
|
+
.sort()
|
|
573
|
+
.join(' and ')}; ` +
|
|
574
|
+
'vx names a project by its package name — rename one, or leave one out with a `!` pattern in the workspace globs',
|
|
563
575
|
)
|
|
564
576
|
}
|
|
565
577
|
seenName.set(pkg.name, dir)
|