@vzn/vx 0.0.524 → 0.0.525
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/inputs.ts +53 -34
- package/src/util/index.ts +1 -0
- package/src/util/paths.ts +67 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.525",
|
|
4
4
|
"description": "The fastest task runner and build cache for JavaScript monorepos.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -21,10 +21,10 @@
|
|
|
21
21
|
"node": ">=18"
|
|
22
22
|
},
|
|
23
23
|
"optionalDependencies": {
|
|
24
|
-
"@vzn/vx-linux-x64": "0.0.
|
|
25
|
-
"@vzn/vx-linux-arm64": "0.0.
|
|
26
|
-
"@vzn/vx-darwin-x64": "0.0.
|
|
27
|
-
"@vzn/vx-darwin-arm64": "0.0.
|
|
24
|
+
"@vzn/vx-linux-x64": "0.0.525",
|
|
25
|
+
"@vzn/vx-linux-arm64": "0.0.525",
|
|
26
|
+
"@vzn/vx-darwin-x64": "0.0.525",
|
|
27
|
+
"@vzn/vx-darwin-arm64": "0.0.525"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@anthropic-ai/sandbox-runtime": "^0.0.76"
|
package/src/cache/inputs.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { lstatSync, readdirSync, realpathSync, rmdirSync, rmSync } from 'node:fs
|
|
|
21
21
|
import { rm, rmdir } from 'node:fs/promises'
|
|
22
22
|
import type { CacheConfig, CacheInputs } from '../config.js'
|
|
23
23
|
import {
|
|
24
|
+
anyTaskGlob,
|
|
24
25
|
asTrees,
|
|
25
26
|
isExecutableMissing,
|
|
26
27
|
isLiteralPattern,
|
|
@@ -249,10 +250,10 @@ async function resolveWorkspaceFiles(args: {
|
|
|
249
250
|
}
|
|
250
251
|
if (positive.length === 0) return []
|
|
251
252
|
|
|
252
|
-
const
|
|
253
|
+
const isExcluded = anyTaskGlob([...ALWAYS_IGNORE, ...asTrees(negative)])
|
|
253
254
|
// A path the task's own outputs take back with `!` is no output, so it
|
|
254
255
|
// stays an input (A-44).
|
|
255
|
-
const ownOutput = outputMatcher(args.ownWorkspaceOutputs
|
|
256
|
+
const ownOutput = outputMatcher(args.ownWorkspaceOutputs)
|
|
256
257
|
const positiveGlobs = asTrees(positive).map(globFor)
|
|
257
258
|
// Workspace-wide partition, keyed by the workspace root. Populated
|
|
258
259
|
// up-front by `populateGitFilesCache(..., workspaceWide: true)` when
|
|
@@ -281,8 +282,8 @@ async function resolveWorkspaceFiles(args: {
|
|
|
281
282
|
args,
|
|
282
283
|
gitFiles,
|
|
283
284
|
positive,
|
|
284
|
-
|
|
285
|
-
(rel) =>
|
|
285
|
+
anyTaskGlob(asTrees(positive)),
|
|
286
|
+
(rel) => isExcluded(rel) || ownOutput(rel),
|
|
286
287
|
undecodable,
|
|
287
288
|
)
|
|
288
289
|
if (memoKey !== undefined) args.memo!.set(memoKey, { snapshot: gitFiles, result })
|
|
@@ -293,7 +294,7 @@ async function resolveWorkspaceFilesOver(
|
|
|
293
294
|
args: { workspaceRoot: string; gitFilesCache?: GitFilesCache },
|
|
294
295
|
gitFiles: readonly string[],
|
|
295
296
|
positive: readonly string[],
|
|
296
|
-
|
|
297
|
+
isPositive: (rel: string) => boolean,
|
|
297
298
|
excluded: (rel: string) => boolean,
|
|
298
299
|
undecodable: ReadonlySet<string> | undefined,
|
|
299
300
|
): Promise<string[]> {
|
|
@@ -301,13 +302,13 @@ async function resolveWorkspaceFilesOver(
|
|
|
301
302
|
// carries its own copy of the filter-over-git-set design, so the same
|
|
302
303
|
// silently-folds-nothing hazard exists here — and a fix applied only to the
|
|
303
304
|
// project half would pass that half's tests while leaving this one live.
|
|
304
|
-
const unmatchedLiterals =
|
|
305
|
+
const unmatchedLiterals = unanswered(
|
|
305
306
|
positive.map(normalizeGlob).filter(isLiteralPattern).map(stripTrailingSlash),
|
|
307
|
+
gitFiles,
|
|
306
308
|
)
|
|
307
309
|
const candidates: string[] = []
|
|
308
310
|
for (const rel of gitFiles) {
|
|
309
|
-
if (
|
|
310
|
-
if (!positiveGlobs.some((g) => g.match(rel))) continue
|
|
311
|
+
if (!isPositive(rel)) continue
|
|
311
312
|
if (excluded(rel)) continue
|
|
312
313
|
candidates.push(path.resolve(args.workspaceRoot, rel))
|
|
313
314
|
}
|
|
@@ -935,11 +936,37 @@ function stripTrailingSlash(p: string): string {
|
|
|
935
936
|
// unchanged.
|
|
936
937
|
export { asTrees } from '../util/index.js'
|
|
937
938
|
|
|
938
|
-
/**
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
939
|
+
/**
|
|
940
|
+
* The literals no path in `files` answers; a literal is answered by the
|
|
941
|
+
* path itself or by anything under it. Git lists in order, so a binary
|
|
942
|
+
* search finds the answer; a listing out of code-unit order (git sorts
|
|
943
|
+
* bytes) can hide one from it, so only a walk says a literal is
|
|
944
|
+
* unanswered. Testing every literal against every file was ~1.5 ms of a
|
|
945
|
+
* 1,800-file all-cached run (2026-10-06).
|
|
946
|
+
*/
|
|
947
|
+
function unanswered(literals: readonly string[], files: readonly string[]): Set<string> {
|
|
948
|
+
const left = new Set<string>()
|
|
949
|
+
for (const lit of literals) {
|
|
950
|
+
const under = `${lit}/`
|
|
951
|
+
const answered =
|
|
952
|
+
files[lowerBound(files, lit)] === lit ||
|
|
953
|
+
files[lowerBound(files, under)]?.startsWith(under) === true ||
|
|
954
|
+
files.some((rel) => rel === lit || rel.startsWith(under))
|
|
955
|
+
if (!answered) left.add(lit)
|
|
942
956
|
}
|
|
957
|
+
return left
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/** The first index whose path is not below `key`. */
|
|
961
|
+
function lowerBound(files: readonly string[], key: string): number {
|
|
962
|
+
let lo = 0
|
|
963
|
+
let hi = files.length
|
|
964
|
+
while (lo < hi) {
|
|
965
|
+
const mid = (lo + hi) >>> 1
|
|
966
|
+
if (files[mid]! < key) lo = mid + 1
|
|
967
|
+
else hi = mid
|
|
968
|
+
}
|
|
969
|
+
return lo
|
|
943
970
|
}
|
|
944
971
|
|
|
945
972
|
/**
|
|
@@ -1065,10 +1092,11 @@ function refuseOneAlternativeBrace(
|
|
|
1065
1092
|
interface FilesPlan {
|
|
1066
1093
|
positive: string[]
|
|
1067
1094
|
negative: string[]
|
|
1068
|
-
|
|
1095
|
+
isExcluded: (rel: string) => boolean
|
|
1069
1096
|
ownOutput: (rel: string) => boolean
|
|
1070
1097
|
positiveGlobs: Bun.Glob[]
|
|
1071
|
-
|
|
1098
|
+
isPositive: (rel: string) => boolean
|
|
1099
|
+
/** The literal entries, each naming one path (see `unanswered`). */
|
|
1072
1100
|
literals: string[]
|
|
1073
1101
|
/**
|
|
1074
1102
|
* Whether a project-relative path is an input by the declaration alone:
|
|
@@ -1112,11 +1140,12 @@ function filesPlan(
|
|
|
1112
1140
|
: {
|
|
1113
1141
|
positive,
|
|
1114
1142
|
negative,
|
|
1115
|
-
|
|
1143
|
+
isExcluded: anyTaskGlob([...ALWAYS_IGNORE, ...asTrees(negative)]),
|
|
1116
1144
|
// A path the task's own outputs take back with `!` is no output, so it
|
|
1117
1145
|
// stays an input: a tracked file under `dist` the build reads (A-44).
|
|
1118
|
-
ownOutput: outputMatcher(ownOutputs
|
|
1146
|
+
ownOutput: outputMatcher(ownOutputs),
|
|
1119
1147
|
positiveGlobs: asTrees(positive).map(globFor),
|
|
1148
|
+
isPositive: anyTaskGlob(asTrees(positive)),
|
|
1120
1149
|
literals: positive.map(normalizeGlob).filter(isLiteralPattern).map(stripTrailingSlash),
|
|
1121
1150
|
verdicts: new Map<string, boolean>(),
|
|
1122
1151
|
}
|
|
@@ -1149,10 +1178,7 @@ export function declaresInput(
|
|
|
1149
1178
|
function inPlan(plan: FilesPlan, rel: string): boolean {
|
|
1150
1179
|
let input = plan.verdicts.get(rel)
|
|
1151
1180
|
if (input === undefined) {
|
|
1152
|
-
input =
|
|
1153
|
-
plan.positiveGlobs.some((g) => g.match(rel)) &&
|
|
1154
|
-
!plan.excludeGlobs.some((g) => g.match(rel)) &&
|
|
1155
|
-
!plan.ownOutput(rel)
|
|
1181
|
+
input = plan.isPositive(rel) && !plan.isExcluded(rel) && !plan.ownOutput(rel)
|
|
1156
1182
|
plan.verdicts.set(rel, input)
|
|
1157
1183
|
}
|
|
1158
1184
|
return input
|
|
@@ -1168,13 +1194,10 @@ function workspaceMatcher(
|
|
|
1168
1194
|
let m = workspaceMatchers.get(key)
|
|
1169
1195
|
if (m !== undefined) return m
|
|
1170
1196
|
const { positive, negative } = splitNegations(decl)
|
|
1171
|
-
const
|
|
1172
|
-
const
|
|
1173
|
-
const ownOutput = outputMatcher(ownOutputs
|
|
1174
|
-
m = (rel) =>
|
|
1175
|
-
positiveGlobs.some((g) => g.match(rel)) &&
|
|
1176
|
-
!excludeGlobs.some((g) => g.match(rel)) &&
|
|
1177
|
-
!ownOutput(rel)
|
|
1197
|
+
const isPositive = anyTaskGlob(asTrees(positive))
|
|
1198
|
+
const isExcluded = anyTaskGlob([...ALWAYS_IGNORE, ...asTrees(negative)])
|
|
1199
|
+
const ownOutput = outputMatcher(ownOutputs)
|
|
1200
|
+
m = (rel) => isPositive(rel) && !isExcluded(rel) && !ownOutput(rel)
|
|
1178
1201
|
workspaceMatchers.set(key, m)
|
|
1179
1202
|
return m
|
|
1180
1203
|
}
|
|
@@ -1182,7 +1205,7 @@ function workspaceMatcher(
|
|
|
1182
1205
|
async function resolveFiles(args: ResolveFilesArgs): Promise<string[]> {
|
|
1183
1206
|
const plan = filesPlan(args.files, args.ownOutputs)
|
|
1184
1207
|
if (plan === null) return []
|
|
1185
|
-
const { positive, negative,
|
|
1208
|
+
const { positive, negative, isExcluded, ownOutput, positiveGlobs, isPositive } = plan
|
|
1186
1209
|
|
|
1187
1210
|
const nested = inNestedProject(args.projectDir, args.nestedProjectDirs)
|
|
1188
1211
|
|
|
@@ -1233,7 +1256,6 @@ async function resolveFiles(args: ResolveFilesArgs): Promise<string[]> {
|
|
|
1233
1256
|
// an artifact built from an older version of a file the config explicitly
|
|
1234
1257
|
// claims as an input. See the refusal below for why this is not simply
|
|
1235
1258
|
// honoured instead.
|
|
1236
|
-
const unmatchedLiterals = new Set(plan.literals)
|
|
1237
1259
|
// First pass: glob-filter to candidate absolute paths (no I/O). Git
|
|
1238
1260
|
// prints normalized relative paths, so under an absolute, normalized
|
|
1239
1261
|
// project dir a join is a concatenation: `path.resolve` per file was
|
|
@@ -1249,18 +1271,15 @@ async function resolveFiles(args: ResolveFilesArgs): Promise<string[]> {
|
|
|
1249
1271
|
const candidates: string[] = []
|
|
1250
1272
|
const verdicts = plan.verdicts
|
|
1251
1273
|
for (const rel of gitFiles) {
|
|
1252
|
-
if (unmatchedLiterals.size > 0) settleLiterals(unmatchedLiterals, rel)
|
|
1253
1274
|
let input = verdicts.get(rel)
|
|
1254
1275
|
if (input === undefined) {
|
|
1255
|
-
input =
|
|
1256
|
-
positiveGlobs.some((g) => g.match(rel)) &&
|
|
1257
|
-
!excludeGlobs.some((g) => g.match(rel)) &&
|
|
1258
|
-
!ownOutput(rel)
|
|
1276
|
+
input = isPositive(rel) && !isExcluded(rel) && !ownOutput(rel)
|
|
1259
1277
|
verdicts.set(rel, input)
|
|
1260
1278
|
}
|
|
1261
1279
|
if (!input || nested(rel)) continue
|
|
1262
1280
|
candidates.push(base === undefined ? path.resolve(args.projectDir, rel) : base + rel)
|
|
1263
1281
|
}
|
|
1282
|
+
const unmatchedLiterals = unanswered(plan.literals, gitFiles)
|
|
1264
1283
|
if (unmatchedLiterals.size > 0) {
|
|
1265
1284
|
await assertNoInvisibleLiteralInputs(unmatchedLiterals, args.projectDir, 'files')
|
|
1266
1285
|
}
|
package/src/util/index.ts
CHANGED
package/src/util/paths.ts
CHANGED
|
@@ -196,8 +196,69 @@ export const EXTGLOB = /[!@+*?]\(/
|
|
|
196
196
|
*/
|
|
197
197
|
export function taskGlob(pattern: string): Bun.Glob {
|
|
198
198
|
// A `]` with no `[` before it is already literal to `Bun.Glob`.
|
|
199
|
-
|
|
200
|
-
|
|
199
|
+
const source = pattern.includes('[') ? pattern.replace(/(?<!\\)[[\]]/g, '\\$&') : pattern
|
|
200
|
+
const glob = new Bun.Glob(source)
|
|
201
|
+
const re = regExpSource(pattern)
|
|
202
|
+
if (re === null) return glob
|
|
203
|
+
const compiled = new RegExp(`^${re}$`, 'u')
|
|
204
|
+
const native = Object.getPrototypeOf(glob) as Bun.Glob
|
|
205
|
+
// A path with a slash on the end names no file vx lists; `Bun.Glob` reads
|
|
206
|
+
// it by rules of its own (`src/**` takes `src/`, never `src`). `scan`
|
|
207
|
+
// stays `Bun.Glob`'s. Set on the instance, not by a subclass, so the glob
|
|
208
|
+
// is built by whatever `Bun.Glob` is at the call (the playground's port).
|
|
209
|
+
glob.match = (rel: string): boolean =>
|
|
210
|
+
rel.endsWith('/') ? native.match.call(glob, rel) : compiled.test(rel)
|
|
211
|
+
return glob
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Whether a path matches any of `patterns`, each a task glob: one compiled
|
|
216
|
+
* RegExp for the lot when every pattern has one, else a glob each. An input
|
|
217
|
+
* file was matched against a dozen globs (its positives, the always-ignored
|
|
218
|
+
* list, its negatives) one native call at a time.
|
|
219
|
+
*/
|
|
220
|
+
export function anyTaskGlob(patterns: readonly string[]): (rel: string) => boolean {
|
|
221
|
+
if (patterns.length === 0) return () => false
|
|
222
|
+
const sources = patterns.map(regExpSource)
|
|
223
|
+
const globs = patterns.map(taskGlob)
|
|
224
|
+
if (sources.every((r) => r !== null)) {
|
|
225
|
+
const re = new RegExp(`^(?:${sources.join('|')})$`, 'u')
|
|
226
|
+
return (rel) => (rel.endsWith('/') ? globs.some((g) => g.match(rel)) : re.test(rel))
|
|
227
|
+
}
|
|
228
|
+
return (rel) => globs.some((g) => g.match(rel))
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const REGEXP_SYNTAX = /[\\^$.*+?()[\]{}|]/g
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The RegExp source of a task glob, matching what `Bun.Glob` matches
|
|
235
|
+
* (`tests/util-paths.test.ts` holds the two to the same verdict on every
|
|
236
|
+
* shape): `*` any run within a name, `?` one character, a `**` segment any
|
|
237
|
+
* number of directories, a bracket itself. Null for the syntax left to
|
|
238
|
+
* `Bun.Glob`: a brace, an escape, a leading `!`, an empty, `.` or `..`
|
|
239
|
+
* segment, a `**` inside a name, or two `**` segments in a row.
|
|
240
|
+
*/
|
|
241
|
+
function regExpSource(pattern: string): string | null {
|
|
242
|
+
if (/[\\{}]/.test(pattern) || pattern.startsWith('!')) return null
|
|
243
|
+
const segments = pattern.split('/')
|
|
244
|
+
let out = ''
|
|
245
|
+
for (let i = 0; i < segments.length; i++) {
|
|
246
|
+
const seg = segments[i]!
|
|
247
|
+
const last = i === segments.length - 1
|
|
248
|
+
if (seg === '**') {
|
|
249
|
+
if (segments[i - 1] === '**') return null
|
|
250
|
+
// A globstar is zero or more whole directories; at the end, one or
|
|
251
|
+
// more names (`src/**` does not match `src`).
|
|
252
|
+
if (segments.length === 1) out += '[^]*'
|
|
253
|
+
else if (last) out += '/[^]+'
|
|
254
|
+
else out += i === 0 ? '(?:[^]*/)?' : '/(?:[^]*/)?'
|
|
255
|
+
continue
|
|
256
|
+
}
|
|
257
|
+
if (seg === '' || seg === '.' || seg === '..' || seg.includes('**')) return null
|
|
258
|
+
if (i > 0 && segments[i - 1] !== '**') out += '/'
|
|
259
|
+
out += seg.replace(REGEXP_SYNTAX, (c) => (c === '*' ? '[^/]*' : c === '?' ? '[^/]' : `\\${c}`))
|
|
260
|
+
}
|
|
261
|
+
return out
|
|
201
262
|
}
|
|
202
263
|
|
|
203
264
|
function stripTrailingSlash(p: string): string {
|
|
@@ -263,15 +324,12 @@ export function splitNegations(globs: readonly string[]): {
|
|
|
263
324
|
* matcher as-is would be `Bun.Glob`'s own negation, true of every OTHER
|
|
264
325
|
* path (A-44).
|
|
265
326
|
*/
|
|
266
|
-
export function outputMatcher(
|
|
267
|
-
globs: readonly string[],
|
|
268
|
-
compile: (pattern: string) => Bun.Glob = taskGlob,
|
|
269
|
-
): (rel: string) => boolean {
|
|
327
|
+
export function outputMatcher(globs: readonly string[]): (rel: string) => boolean {
|
|
270
328
|
const { positive, negative } = splitNegations(globs)
|
|
271
329
|
if (positive.length === 0) return () => false
|
|
272
|
-
const pos = asTrees(positive)
|
|
273
|
-
const neg = asTrees(negative)
|
|
274
|
-
return (rel) => pos
|
|
330
|
+
const pos = anyTaskGlob(asTrees(positive))
|
|
331
|
+
const neg = anyTaskGlob(asTrees(negative))
|
|
332
|
+
return (rel) => pos(rel) && !neg(rel)
|
|
275
333
|
}
|
|
276
334
|
|
|
277
335
|
/**
|