@vzn/vx 0.0.130 → 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/exec/index.ts +1 -1
- package/src/exec/sandbox-paths.ts +9 -1
- package/src/exec/sandbox-runtime.ts +9 -14
- package/src/exec/sandbox-violations.ts +2 -2
- 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/orchestrator/sandbox-request.ts +13 -17
- package/src/orchestrator/upstream.ts +7 -1
- 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/exec/index.ts
CHANGED
|
@@ -28,7 +28,7 @@ export {
|
|
|
28
28
|
thrownReason,
|
|
29
29
|
} from './sandbox-runtime.js'
|
|
30
30
|
export { type DeniedCall } from './sandbox-violations.js'
|
|
31
|
-
export { isMountableLiteral, MOUNT_WILDCARDS, toRealPath } from './sandbox-paths.js'
|
|
31
|
+
export { atOrUnder, isMountableLiteral, MOUNT_WILDCARDS, toRealPath } from './sandbox-paths.js'
|
|
32
32
|
export { bindableWrites, punchWalls } from './sandbox-binds.js'
|
|
33
33
|
export { isLocalExecutor, localExecutor } from './local-executor.js'
|
|
34
34
|
export { holdGroups, killTree, untilGroupsGone } from './kill-tree.js'
|
|
@@ -52,10 +52,18 @@ export function absolutize(p: string, cwd?: string): string {
|
|
|
52
52
|
return path.resolve(cwd ?? process.cwd(), p)
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* `p` is `dir` or below it, by path: a sibling sharing `dir`'s name prefix
|
|
57
|
+
* is not, and `/` already ends in the separator.
|
|
58
|
+
*/
|
|
59
|
+
export function atOrUnder(p: string, dir: string): boolean {
|
|
60
|
+
return p === dir || p.startsWith(dir.endsWith(path.sep) ? dir : dir + path.sep)
|
|
61
|
+
}
|
|
62
|
+
|
|
55
63
|
export function isUnderAny(abs: string, allow: Set<string>): boolean {
|
|
56
64
|
if (allow.has(abs)) return true
|
|
57
65
|
for (const a of allow) {
|
|
58
|
-
if (abs
|
|
66
|
+
if (atOrUnder(abs, a)) return true
|
|
59
67
|
}
|
|
60
68
|
return false
|
|
61
69
|
}
|
|
@@ -64,6 +64,7 @@ import {
|
|
|
64
64
|
} from '../util/index.js'
|
|
65
65
|
import { bindableWrites, buildCustomConfig } from './sandbox-binds.js'
|
|
66
66
|
import {
|
|
67
|
+
atOrUnder,
|
|
67
68
|
isMountableLiteral,
|
|
68
69
|
localBindingOn,
|
|
69
70
|
MOUNT_WILDCARDS,
|
|
@@ -719,16 +720,14 @@ function canonicalBaselines(
|
|
|
719
720
|
function assertWriteStaysHome(grant: string, real: string, projectDir: string): void {
|
|
720
721
|
if (grant.startsWith('~') || path.isAbsolute(grant)) return
|
|
721
722
|
const lexical = path.resolve(projectDir, grant)
|
|
722
|
-
|
|
723
|
-
p === dir || p.startsWith(dir.endsWith(path.sep) ? dir : dir + path.sep)
|
|
724
|
-
if (!inside(lexical, projectDir)) return
|
|
723
|
+
if (!atOrUnder(lexical, projectDir)) return
|
|
725
724
|
const home = toRealPath(projectDir)
|
|
726
725
|
// `toRealPath` stops at a DANGLING link and keeps its own path, which is
|
|
727
726
|
// inside; the link's target is where a write through it would land.
|
|
728
727
|
const target = throughLinks(real)
|
|
729
|
-
if (
|
|
728
|
+
if (atOrUnder(real, home) && atOrUnder(target, home)) return
|
|
730
729
|
throw new UserError(
|
|
731
|
-
`exec.sandbox.allow.write: "${grant}" resolves through a symlink to ${
|
|
730
|
+
`exec.sandbox.allow.write: "${grant}" resolves through a symlink to ${atOrUnder(real, home) ? target : real}, outside the ` +
|
|
732
731
|
`project — a write grant binds the path it names in the project, and vx does not follow a ` +
|
|
733
732
|
`link out of it. Remove the link, or grant the target by its own path.`,
|
|
734
733
|
)
|
|
@@ -1557,8 +1556,6 @@ async function runSandboxedOnce(
|
|
|
1557
1556
|
*/
|
|
1558
1557
|
function readableUnder(dir: string, granted: readonly string[]): boolean {
|
|
1559
1558
|
const target = toRealPath(dir)
|
|
1560
|
-
const under = (a: string, b: string): boolean =>
|
|
1561
|
-
a === b || a.startsWith(b.endsWith(path.sep) ? b : b + path.sep)
|
|
1562
1559
|
return granted.some((p) => {
|
|
1563
1560
|
const g = toRealPath(p)
|
|
1564
1561
|
// On Linux a grant INSIDE the cwd makes the cwd listable too: bwrap
|
|
@@ -1566,7 +1563,7 @@ function readableUnder(dir: string, granted: readonly string[]): boolean {
|
|
|
1566
1563
|
// children around the walls (`wallOff`), so every failing root task
|
|
1567
1564
|
// was told its cwd was unreadable while it listed it (B-20). Seatbelt
|
|
1568
1565
|
// grants no parent, so on macOS the cwd itself must be covered.
|
|
1569
|
-
return
|
|
1566
|
+
return atOrUnder(target, g) || (process.platform === 'linux' && atOrUnder(g, target))
|
|
1570
1567
|
})
|
|
1571
1568
|
}
|
|
1572
1569
|
|
|
@@ -1736,9 +1733,7 @@ export function wallsGlobsReach(grants: readonly string[], walls: readonly strin
|
|
|
1736
1733
|
const heads = grants
|
|
1737
1734
|
.filter((g) => !isMountableLiteral(g))
|
|
1738
1735
|
.map((g) => path.dirname(g.slice(0, g.search(MOUNT_WILDCARDS))))
|
|
1739
|
-
return walls.filter((w) =>
|
|
1740
|
-
heads.some((h) => w === h || w.startsWith(h === path.sep ? h : h + path.sep)),
|
|
1741
|
-
)
|
|
1736
|
+
return walls.filter((w) => heads.some((h) => atOrUnder(w, h)))
|
|
1742
1737
|
}
|
|
1743
1738
|
|
|
1744
1739
|
/**
|
|
@@ -1760,7 +1755,7 @@ export function darwinWallRules(
|
|
|
1760
1755
|
for (const w of walls) {
|
|
1761
1756
|
const at = `(subpath "${sbplResolvedPath(w, 'wall')}")`
|
|
1762
1757
|
const kept = literals
|
|
1763
|
-
.filter((l) => isMountableLiteral(l) && (l
|
|
1758
|
+
.filter((l) => isMountableLiteral(l) && atOrUnder(l, w))
|
|
1764
1759
|
.map((l) => `(require-not (subpath "${sbplResolvedPath(l, 'grant')}"))`)
|
|
1765
1760
|
rules.push(
|
|
1766
1761
|
kept.length === 0
|
|
@@ -1827,12 +1822,12 @@ function expandGrants(
|
|
|
1827
1822
|
for (const hit of scanOrNothing(pattern, base)) {
|
|
1828
1823
|
const abs = path.join(base, hit)
|
|
1829
1824
|
const real = toRealPath(abs)
|
|
1830
|
-
if (real
|
|
1825
|
+
if (!atOrUnder(real, home)) continue
|
|
1831
1826
|
// A hit that IS a wall, or lies inside one, was matched, not named:
|
|
1832
1827
|
// `read: ['*']` in a root project bound `.git` and `.vx`, and
|
|
1833
1828
|
// `packages/*` a nested project its key excludes (B-1). A grant that
|
|
1834
1829
|
// names a wall literally never reaches this loop and stays.
|
|
1835
|
-
if (walls.some((w) => real
|
|
1830
|
+
if (walls.some((w) => atOrUnder(real, w))) continue
|
|
1836
1831
|
out.push(abs)
|
|
1837
1832
|
hits++
|
|
1838
1833
|
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// no grant can avoid, minus what the task chose to ignore.
|
|
6
6
|
|
|
7
7
|
import path from 'node:path'
|
|
8
|
-
import { absolutize, isUnderAny, localBindingOn, toRealPath } from './sandbox-paths.js'
|
|
8
|
+
import { absolutize, atOrUnder, isUnderAny, localBindingOn, toRealPath } from './sandbox-paths.js'
|
|
9
9
|
import type {
|
|
10
10
|
ResolvedSandboxConfig,
|
|
11
11
|
SandboxedRunArgs,
|
|
@@ -119,7 +119,7 @@ export async function parseStraceViolations(
|
|
|
119
119
|
const abs = toRealPath(absolutize(rawPath, baselines.cwd))
|
|
120
120
|
// Only report paths under the workspace-root deny anchor — system
|
|
121
121
|
// libs / /proc / /sys / etc. probes are not interesting violations.
|
|
122
|
-
if (!denyAnchors.some((root) => abs
|
|
122
|
+
if (!denyAnchors.some((root) => atOrUnder(abs, root))) continue
|
|
123
123
|
// Skip paths the user explicitly allowed (and their descendants).
|
|
124
124
|
if (isUnderAny(abs, allowAbs)) continue
|
|
125
125
|
const key = `${syscall}|${abs}`
|
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
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
toRealPath,
|
|
19
19
|
type ExecuteRequest,
|
|
20
20
|
isMountableLiteral,
|
|
21
|
+
atOrUnder,
|
|
21
22
|
type SandboxViolation,
|
|
22
23
|
} from '../exec/index.js'
|
|
23
24
|
import type { TaskNode } from '../graph/index.js'
|
|
@@ -299,7 +300,11 @@ async function linkedDeps(
|
|
|
299
300
|
const full = path.join(dir, e.name)
|
|
300
301
|
if (e.isSymbolicLink()) {
|
|
301
302
|
const target = await realpath(full).catch(() => undefined)
|
|
302
|
-
if (
|
|
303
|
+
if (
|
|
304
|
+
target !== undefined &&
|
|
305
|
+
!dirs.some((d) => atOrUnder(target, d)) &&
|
|
306
|
+
!atOrUnder(self, target)
|
|
307
|
+
) {
|
|
303
308
|
found.push([target, full, scope + e.name])
|
|
304
309
|
}
|
|
305
310
|
} else if (scope === '' && e.isDirectory() && e.name.startsWith('@')) {
|
|
@@ -321,7 +326,7 @@ async function linkedDeps(
|
|
|
321
326
|
const granted: string[] = []
|
|
322
327
|
const withheld: WithheldLink[] = []
|
|
323
328
|
for (const [target, { link, name }] of targets) {
|
|
324
|
-
if (!
|
|
329
|
+
if (!atOrUnder(target, root) || keyedDirs.has(target)) granted.push(target)
|
|
325
330
|
else
|
|
326
331
|
withheld.push({
|
|
327
332
|
dir: target,
|
|
@@ -355,7 +360,7 @@ export function reachedWithheld(
|
|
|
355
360
|
violations: readonly SandboxViolation[],
|
|
356
361
|
): WithheldLink[] {
|
|
357
362
|
return withheld.filter((w) =>
|
|
358
|
-
violations.some((v) => v.path !== undefined &&
|
|
363
|
+
violations.some((v) => v.path !== undefined && atOrUnder(v.path, w.dir)),
|
|
359
364
|
)
|
|
360
365
|
}
|
|
361
366
|
|
|
@@ -364,15 +369,6 @@ function posixRel(from: string, p: string): string {
|
|
|
364
369
|
return path.relative(from, p).split(path.sep).join('/')
|
|
365
370
|
}
|
|
366
371
|
|
|
367
|
-
/**
|
|
368
|
-
* `p` is `dir` or below it — by path, so a sibling sharing `dir`'s name
|
|
369
|
-
* prefix is not. `/` already ends in the separator, and a link to it
|
|
370
|
-
* holds every project.
|
|
371
|
-
*/
|
|
372
|
-
function within(p: string, dir: string): boolean {
|
|
373
|
-
return p === dir || p.startsWith(dir.endsWith(path.sep) ? dir : dir + path.sep)
|
|
374
|
-
}
|
|
375
|
-
|
|
376
372
|
/**
|
|
377
373
|
* Ensure each declared write path exists on the host as either an empty
|
|
378
374
|
* file (a literal path) or a directory (a glob's static prefix, or a
|
|
@@ -451,8 +447,8 @@ function wallOff(
|
|
|
451
447
|
for (const bind of bindableWrites(config.allowWrite)) {
|
|
452
448
|
// A bind outside the workspace is the user's own path (`/tmp/x`,
|
|
453
449
|
// `~/.cache/y`), spelled there on purpose; only one inside is judged.
|
|
454
|
-
if (bind
|
|
455
|
-
const wall = walls.find((w) => w
|
|
450
|
+
if (!atOrUnder(bind, home)) continue
|
|
451
|
+
const wall = walls.find((w) => atOrUnder(w, bind))
|
|
456
452
|
if (wall === undefined) continue
|
|
457
453
|
throw new UserError(
|
|
458
454
|
`exec.sandbox.allow.write: the grant binding ${bind} would make ${wall} writable — another ` +
|
|
@@ -558,8 +554,8 @@ export function commandWriteReach(node: TaskNode, workspaceRoot: string): WriteR
|
|
|
558
554
|
let reach: 'none' | 'project' = 'none'
|
|
559
555
|
for (const grant of exec.sandbox.allow?.write ?? []) {
|
|
560
556
|
const abs = path.resolve(node.projectDir, expandHome(grantPrefix(grant)))
|
|
561
|
-
if (
|
|
562
|
-
else if (
|
|
557
|
+
if (atOrUnder(abs, node.projectDir)) reach = 'project'
|
|
558
|
+
else if (atOrUnder(abs, workspaceRoot) || atOrUnder(workspaceRoot, abs)) return 'workspace'
|
|
563
559
|
}
|
|
564
560
|
return reach
|
|
565
561
|
}
|
|
@@ -579,7 +575,7 @@ export function mayWriteFingerprint(node: TaskNode, workspaceRoot: string): bool
|
|
|
579
575
|
for (const grant of exec.sandbox.allow?.write ?? []) {
|
|
580
576
|
const abs = path.resolve(node.projectDir, expandHome(grantPrefix(grant)))
|
|
581
577
|
for (const f of WORKSPACE_FINGERPRINT_FILES) {
|
|
582
|
-
if (
|
|
578
|
+
if (atOrUnder(path.join(workspaceRoot, f), abs)) return true
|
|
583
579
|
}
|
|
584
580
|
}
|
|
585
581
|
return false
|
|
@@ -18,7 +18,13 @@ import { UserError } from '../util/index.js'
|
|
|
18
18
|
*/
|
|
19
19
|
export function keyUpstream(node: TaskNode, upstream: TaskOutcome[]): TaskOutcome[] {
|
|
20
20
|
const order = node.orderOnly
|
|
21
|
-
|
|
21
|
+
// A restore-tier hit dispatches before its dependencies settle, and the
|
|
22
|
+
// scheduler hands it their outcomes so far: a hole stays a hole, as it
|
|
23
|
+
// does with no order-only edge (reading `.node` of one threw).
|
|
24
|
+
const keyed =
|
|
25
|
+
order === undefined
|
|
26
|
+
? upstream
|
|
27
|
+
: upstream.filter((o) => o === undefined || !order.includes(o.node.id))
|
|
22
28
|
return node.excludedUpstream === undefined ? keyed : [...keyed, ...node.excludedUpstream]
|
|
23
29
|
}
|
|
24
30
|
|
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)
|