@vzn/vx 0.0.646 → 0.0.649
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 +29 -22
- package/launcher.cjs +9 -2
- package/launcher.sh +2 -2
- package/package.json +5 -5
- package/schemas/cache.json +5 -5
- package/schemas/error.json +1 -1
- package/schemas/info.json +16 -2
- package/schemas/last.json +112 -5
- package/schemas/plan.json +28 -2
- package/schemas/show.json +10 -1
- package/skills/vx/SKILL.md +18 -2
- package/src/bin.ts +53 -12
- package/src/cache/archive.ts +324 -23
- package/src/cache/cache.ts +268 -53
- package/src/cache/git-inputs.ts +219 -87
- package/src/cache/index.ts +3 -2
- package/src/cache/inputs.ts +460 -78
- package/src/cache/key-fold.ts +15 -6
- package/src/cache/layer.ts +49 -9
- package/src/cache/layered-cache.ts +123 -58
- package/src/cache/linked-inputs.ts +1 -1
- package/src/cache/output-index.ts +19 -3
- package/src/cache/plugin-cache.ts +49 -23
- package/src/cache/remote-hits.ts +72 -0
- package/src/cache/remote-lane.ts +90 -14
- package/src/cache/run-history.ts +70 -23
- package/src/cache/run-store.ts +11 -10
- package/src/cache/schema.ts +25 -6
- package/src/cache/tar-stream.ts +5 -0
- package/src/cache/umask.ts +1 -2
- package/src/cli/cache.ts +58 -27
- package/src/cli/completions.ts +12 -5
- package/src/cli/docs.ts +42 -16
- package/src/cli/help.ts +58 -19
- package/src/cli/index.ts +79 -33
- package/src/cli/info.ts +12 -6
- package/src/cli/init.ts +186 -31
- package/src/cli/last.ts +439 -45
- package/src/cli/lock.ts +197 -52
- package/src/cli/plan-format.ts +91 -49
- package/src/cli/plugin-commands.ts +70 -14
- package/src/cli/plugin-templates.ts +13 -13
- package/src/cli/registry.ts +209 -0
- package/src/cli/run.ts +109 -34
- package/src/cli/select.ts +143 -46
- package/src/cli/show.ts +59 -16
- package/src/cli/task-verb.ts +16 -6
- package/src/cli/upgrade.ts +151 -49
- package/src/cli/watch-cycle.ts +10 -5
- package/src/cli/watch-filter.ts +71 -3
- package/src/cli/watch-fs.ts +22 -6
- package/src/cli/watch-judge.ts +48 -6
- package/src/cli/watch.ts +163 -42
- package/src/cli/why.ts +211 -73
- package/src/cli/workspace-config.ts +54 -15
- package/src/config.ts +13 -1
- package/src/exec/env.ts +7 -2
- package/src/exec/executor.ts +154 -10
- package/src/exec/index.ts +6 -2
- package/src/exec/kill-tree.ts +15 -0
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/proc-sample.ts +3 -1
- package/src/exec/runner.ts +253 -54
- package/src/exec/sandbox-binds.ts +7 -3
- package/src/exec/sandbox-paths.ts +1 -1
- package/src/exec/sandbox-runtime.ts +211 -24
- package/src/exec/sandbox-violations.ts +52 -20
- package/src/graph/critical-path.ts +79 -0
- package/src/graph/default-build.ts +19 -0
- package/src/graph/definition.ts +38 -0
- package/src/graph/folds.ts +38 -5
- package/src/graph/index.ts +4 -1
- package/src/graph/priorities.ts +74 -8
- package/src/graph/scheduler.ts +270 -60
- package/src/graph/task-graph.ts +228 -35
- package/src/handoff.ts +97 -9
- package/src/history/failure-mode.ts +6 -3
- package/src/history/history.ts +167 -61
- package/src/history/index.ts +2 -0
- package/src/history/metrics.ts +90 -30
- package/src/index.ts +30 -13
- package/src/orchestrator/admission.ts +10 -6
- package/src/orchestrator/affected-tasks.ts +11 -6
- package/src/orchestrator/deferred-outputs.ts +2 -0
- package/src/orchestrator/doctor.ts +134 -42
- package/src/orchestrator/execute-args.ts +4 -1
- package/src/orchestrator/execute-task.ts +130 -38
- package/src/orchestrator/fingerprint-watch.ts +1 -1
- package/src/orchestrator/graph-memo.ts +140 -0
- package/src/orchestrator/graph-stage.ts +18 -1
- package/src/orchestrator/held-servers.ts +1 -1
- package/src/orchestrator/hit-restore.ts +217 -119
- package/src/orchestrator/index.ts +4 -3
- package/src/orchestrator/local-floor.ts +119 -25
- package/src/orchestrator/local-shortcircuit.ts +28 -4
- package/src/orchestrator/lockfile-claim.ts +96 -16
- package/src/orchestrator/miss-save.ts +42 -8
- package/src/orchestrator/options.ts +8 -6
- package/src/orchestrator/output-host.ts +43 -14
- package/src/orchestrator/output-log.ts +90 -10
- package/src/orchestrator/persistent-task.ts +84 -29
- package/src/orchestrator/persistent.ts +16 -4
- package/src/orchestrator/placement.ts +185 -17
- package/src/orchestrator/plan.ts +64 -7
- package/src/orchestrator/plugin-host.ts +214 -42
- package/src/orchestrator/plugin.ts +113 -37
- package/src/orchestrator/prepare.ts +168 -30
- package/src/orchestrator/projects.ts +65 -40
- package/src/orchestrator/remote-prefetch.ts +119 -25
- package/src/orchestrator/run-artifacts.ts +95 -32
- package/src/orchestrator/run-context.ts +39 -41
- package/src/orchestrator/run-failures.ts +65 -23
- package/src/orchestrator/run-id.ts +6 -3
- package/src/orchestrator/run-keys.ts +149 -13
- package/src/orchestrator/run-live.ts +294 -0
- package/src/orchestrator/run-lock.ts +13 -7
- package/src/orchestrator/run-records.ts +35 -29
- package/src/orchestrator/run-report.ts +3 -3
- package/src/orchestrator/run-steps.ts +58 -22
- package/src/orchestrator/run-stop.ts +22 -5
- package/src/orchestrator/run.ts +331 -88
- package/src/orchestrator/sandbox-fix.ts +7 -5
- package/src/orchestrator/sandbox-request.ts +70 -20
- package/src/orchestrator/shell-verdict.ts +5 -5
- package/src/orchestrator/signals.ts +26 -1
- package/src/orchestrator/stable-keys.ts +92 -8
- package/src/orchestrator/steps.ts +2 -2
- package/src/orchestrator/task-artifacts.ts +90 -26
- package/src/orchestrator/task-hash.ts +78 -13
- package/src/orchestrator/task-log-buffer.ts +72 -36
- package/src/orchestrator/task-process.ts +7 -1
- package/src/orchestrator/telemetry-host.ts +22 -2
- package/src/orchestrator/telemetry.ts +53 -21
- package/src/orchestrator/upstream.ts +15 -0
- package/src/report/events.ts +32 -13
- package/src/report/forecast.ts +31 -9
- package/src/report/framed-output.ts +141 -50
- package/src/report/index.ts +1 -0
- package/src/report/logger.ts +170 -179
- package/src/report/path-links.ts +12 -4
- package/src/report/plain-output.ts +13 -5
- package/src/report/status-line.ts +81 -53
- package/src/report/summary.ts +114 -32
- package/src/report/terminal-signals.ts +13 -2
- package/src/settings.ts +7 -6
- package/src/util/bun-version.ts +4 -1
- package/src/util/colors.ts +4 -2
- package/src/util/config-frame.ts +41 -4
- package/src/{graph → util}/dependency-spec.ts +22 -13
- package/src/util/edit-distance.ts +13 -1
- package/src/util/env-flag.ts +9 -0
- package/src/util/errors.ts +59 -3
- package/src/util/essential-env.ts +57 -0
- package/src/util/git-ignored.ts +1 -1
- package/src/util/hash.ts +35 -0
- package/src/util/hide-env.ts +45 -5
- package/src/util/index.ts +21 -5
- package/src/util/num.ts +8 -0
- package/src/util/paths.ts +91 -21
- package/src/util/report.ts +6 -1
- package/src/util/secret-mask.ts +62 -1
- package/src/util/secrets.ts +7 -2
- package/src/util/size.ts +11 -3
- package/src/util/tail.ts +6 -1
- package/src/util/timing.ts +13 -1
- package/src/util/ui.ts +61 -3
- package/src/util/which.ts +1 -1
- package/src/util/write-file.ts +67 -0
- package/src/workspace/affected.ts +710 -107
- package/src/workspace/bracket-literal.ts +84 -0
- package/src/workspace/canonical-dir.ts +37 -0
- package/src/workspace/config-bytecode.ts +17 -11
- package/src/workspace/config-eval.ts +29 -8
- package/src/workspace/config-imports.ts +199 -58
- package/src/workspace/config-schema.ts +242 -39
- package/src/workspace/filter.ts +37 -8
- package/src/workspace/index.ts +11 -1
- package/src/workspace/json-data.ts +3 -4
- package/src/workspace/lockfile.ts +57 -8
- package/src/workspace/migration.ts +69 -18
- package/src/workspace/package-graph.ts +5 -4
- package/src/workspace/project-loader.ts +226 -37
- package/src/workspace/repo-id.ts +1 -1
- package/src/workspace/workspace.ts +176 -33
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import path from 'node:path'
|
|
9
9
|
import {
|
|
10
|
+
PLATFORMS,
|
|
10
11
|
PLUGIN_FUNCTION_HOOKS,
|
|
11
12
|
PLUGIN_HOOKS,
|
|
12
13
|
PLUGIN_PACKAGE,
|
|
@@ -22,9 +23,14 @@ import {
|
|
|
22
23
|
UserError,
|
|
23
24
|
BUN_GLOB_WILDCARDS,
|
|
24
25
|
EXTGLOB,
|
|
26
|
+
braceRange,
|
|
27
|
+
BRACE_RANGE_LIMIT,
|
|
25
28
|
anyTaskGlob,
|
|
26
29
|
asTrees,
|
|
27
30
|
isLiteralPattern,
|
|
31
|
+
hasControl,
|
|
32
|
+
parseDependencySpec,
|
|
33
|
+
DependencySpecError,
|
|
28
34
|
} from '../util/index.js'
|
|
29
35
|
import {
|
|
30
36
|
parseSettingValue,
|
|
@@ -36,6 +42,7 @@ import {
|
|
|
36
42
|
} from '../settings.js'
|
|
37
43
|
import { compileNameGlob, parseFilter } from './filter.js'
|
|
38
44
|
import { nonJsonMessage, nonJsonPaths } from './json-data.js'
|
|
45
|
+
import { projectNameProblem, ROOT_NAME } from './workspace.js'
|
|
39
46
|
|
|
40
47
|
// Mirrors `WorkspaceConfig` in src/config.ts. Unknown keys are REJECTED for
|
|
41
48
|
// the same reason the task levels reject them: `plugin: [...]` (singular)
|
|
@@ -49,27 +56,31 @@ export function isCacheScope(v: unknown): v is string {
|
|
|
49
56
|
return typeof v === 'string' && /^[\w.@/:-]{1,128}$/.test(v)
|
|
50
57
|
}
|
|
51
58
|
|
|
52
|
-
/**
|
|
53
|
-
* A branch's cache scope, `<owner>/<repo>:<branch>`: what CI writes for a
|
|
54
|
-
* branch and a laptop on it reads (owner, 2026-10-17). The repository's
|
|
55
|
-
* identity, not a remote's local name, so a clone whose `origin` is a fork
|
|
56
|
-
* names the fork. A name may hold characters a scope may not; each
|
|
57
|
-
* becomes `_`.
|
|
58
|
-
*/
|
|
59
59
|
const BASE_BRANCH_RULE = "must be a branch name like 'main'"
|
|
60
60
|
|
|
61
61
|
/**
|
|
62
62
|
* A branch name git takes after `origin/`: a leading '-' would reach git
|
|
63
|
-
* as an option, and whitespace, `..`, `~^:?*[\\` are no ref's.
|
|
63
|
+
* as an option, and whitespace, `..`, `~^:?*[\\` are no ref's. A ref
|
|
64
|
+
* spelling (`origin/main`, `refs/heads/main`) is no branch either: vx
|
|
65
|
+
* looked for `origin/origin/main`, found no base, and a run on main took
|
|
66
|
+
* a branch's cache scope.
|
|
64
67
|
*/
|
|
65
68
|
function isBaseBranch(value: unknown): value is string {
|
|
66
69
|
return (
|
|
67
70
|
typeof value === 'string' &&
|
|
68
71
|
/^[^-\s\0~^:?*[\\][^\s\0~^:?*[\\]*$/.test(value) &&
|
|
69
|
-
!value.includes('..')
|
|
72
|
+
!value.includes('..') &&
|
|
73
|
+
!/^(?:origin|refs)\//.test(value)
|
|
70
74
|
)
|
|
71
75
|
}
|
|
72
76
|
|
|
77
|
+
/**
|
|
78
|
+
* A branch's cache scope, `<owner>/<repo>:<branch>`: what CI writes for a
|
|
79
|
+
* branch and a laptop on it reads (2026-10-17). The repository's
|
|
80
|
+
* identity, not a remote's local name, so a clone whose `origin` is a fork
|
|
81
|
+
* names the fork. A name may hold characters a scope may not; each
|
|
82
|
+
* becomes `_`.
|
|
83
|
+
*/
|
|
73
84
|
export function branchCacheScope(repo: string, branch: string): string {
|
|
74
85
|
return `${repo.toLowerCase()}:${branch}`.replace(/[^\w.@/:-]/g, '_').slice(0, 128)
|
|
75
86
|
}
|
|
@@ -79,16 +90,16 @@ export function cacheScopeError(source = 'VX_CACHE_SCOPE'): string {
|
|
|
79
90
|
return `${source} ${CACHE_SCOPE_RULE}`
|
|
80
91
|
}
|
|
81
92
|
|
|
82
|
-
/** Why `VX_CACHE_SCOPE` is refused, as the run's error. */
|
|
83
|
-
export function cacheScopeEnvError(): UserError {
|
|
84
|
-
return new UserError(cacheScopeError())
|
|
93
|
+
/** Why `VX_CACHE_SCOPE` (`got`) is refused, as the run's error: usage, as every `VX_*` value is. */
|
|
94
|
+
export function cacheScopeEnvError(got: string): UserError {
|
|
95
|
+
return new UserError(`${cacheScopeError()} (got ${got})`, 'VX_E_USAGE')
|
|
85
96
|
}
|
|
86
97
|
|
|
87
98
|
/** `baseBranch`: `VX_BASE_BRANCH`, else the workspace's; undefined lets git name it. */
|
|
88
99
|
export function configuredBaseBranch(workspace: WorkspaceConfig | null): string | undefined {
|
|
89
100
|
const env = settingEnv('baseBranch')
|
|
90
101
|
if (env !== undefined && !isBaseBranch(env)) {
|
|
91
|
-
throw new UserError(`VX_BASE_BRANCH ${BASE_BRANCH_RULE}`, 'VX_E_USAGE')
|
|
102
|
+
throw new UserError(`VX_BASE_BRANCH ${BASE_BRANCH_RULE} (got ${env as string})`, 'VX_E_USAGE')
|
|
92
103
|
}
|
|
93
104
|
return (env as string | undefined) ?? workspace?.baseBranch
|
|
94
105
|
}
|
|
@@ -178,6 +189,14 @@ function validateDependencyRules(value: unknown, where: string): void {
|
|
|
178
189
|
const shape = `an array of { from, allow?, deny? }, each a selector like 'tag:app' or '@acme/*'`
|
|
179
190
|
if (!Array.isArray(value)) throw new UserError(`${where} must be ${shape}`)
|
|
180
191
|
const selector = (v: unknown) => typeof v === 'string' && v.trim() !== '' && v !== 'tag:'
|
|
192
|
+
const matchable = (v: string, field: string) => {
|
|
193
|
+
const why = ruleSelectorProblem(v)
|
|
194
|
+
if (why !== null)
|
|
195
|
+
throw new UserError(
|
|
196
|
+
`${field} ${JSON.stringify(v)} ${why}; a selector is 'tag:<tag>' or a project name, ` +
|
|
197
|
+
`either may hold '*' (like 'tag:app' or '@acme/*')`,
|
|
198
|
+
)
|
|
199
|
+
}
|
|
181
200
|
for (const [i, rule] of (value as unknown[]).entries()) {
|
|
182
201
|
const at = `${where}[${i}]`
|
|
183
202
|
if (typeof rule !== 'object' || rule === null || Array.isArray(rule))
|
|
@@ -185,12 +204,14 @@ function validateDependencyRules(value: unknown, where: string): void {
|
|
|
185
204
|
assertKnownFields(rule, RULE_FIELDS, at)
|
|
186
205
|
const { from, allow, deny } = rule as Record<string, unknown>
|
|
187
206
|
if (!selector(from)) throw new UserError(`${at}.from must be a selector like 'tag:app'`)
|
|
207
|
+
matchable(from as string, `${at}.from`)
|
|
188
208
|
for (const [k, list] of [
|
|
189
209
|
['allow', allow],
|
|
190
210
|
['deny', deny],
|
|
191
211
|
] as const) {
|
|
192
212
|
if (list !== undefined && (!Array.isArray(list) || !list.every(selector)))
|
|
193
213
|
throw new UserError(`${at}.${k} must be an array of selectors like 'tag:lib'`)
|
|
214
|
+
for (const [j, v] of ((list ?? []) as string[]).entries()) matchable(v, `${at}.${k}[${j}]`)
|
|
194
215
|
}
|
|
195
216
|
// A rule that says nothing reads as a constraint and checks nothing.
|
|
196
217
|
if (allow === undefined && deny === undefined)
|
|
@@ -198,6 +219,33 @@ function validateDependencyRules(value: unknown, where: string): void {
|
|
|
198
219
|
}
|
|
199
220
|
}
|
|
200
221
|
|
|
222
|
+
/**
|
|
223
|
+
* Why a rule selector can match no project, or null. The checker reads a
|
|
224
|
+
* selector as a tag or name glob only, so a `--filter` form loaded and
|
|
225
|
+
* matched nothing, and a `deny` of it passed every edge. Asked of the
|
|
226
|
+
* filter parser itself, as `tagProblem`.
|
|
227
|
+
*/
|
|
228
|
+
function ruleSelectorProblem(s: string): string | null {
|
|
229
|
+
const glob = s.startsWith('tag:') ? s.slice(4) : s
|
|
230
|
+
if (s.trim() !== s || glob.trim() !== glob) return 'has surrounding whitespace'
|
|
231
|
+
// The root's own name, which the filter reads as the root's directory.
|
|
232
|
+
if (s === ROOT_NAME) return null
|
|
233
|
+
if (s.startsWith('!')) return "starts with '!', which a filter reads as an exclusion"
|
|
234
|
+
const walk = "starts or ends with '...', which a filter reads as a dependency walk"
|
|
235
|
+
let f: ReturnType<typeof parseFilter>
|
|
236
|
+
try {
|
|
237
|
+
f = parseFilter(s, '/')
|
|
238
|
+
} catch {
|
|
239
|
+
// `...` alone: the walk with no project between.
|
|
240
|
+
return walk
|
|
241
|
+
}
|
|
242
|
+
if (f.withDeps || f.withDependents || f.onlyDeps || f.onlyDependents) return walk
|
|
243
|
+
if (f.gitSince !== undefined)
|
|
244
|
+
return `ends in "[${f.gitSince}]", which a filter reads as a git range`
|
|
245
|
+
if (f.isPath) return 'reads as a directory to a filter'
|
|
246
|
+
return null
|
|
247
|
+
}
|
|
248
|
+
|
|
201
249
|
/** `value` as the setting at `path` takes it, refused in `where`'s words. */
|
|
202
250
|
function validateSettingValue(value: unknown, path: string, where: string): void {
|
|
203
251
|
const { kind } = setting(path)
|
|
@@ -206,7 +254,8 @@ function validateSettingValue(value: unknown, path: string, where: string): void
|
|
|
206
254
|
const untyped =
|
|
207
255
|
(kind.type === 'concurrency' && typeof value === 'string' && !value.endsWith('%')) ||
|
|
208
256
|
(kind.type === 'int' && typeof value !== 'number') ||
|
|
209
|
-
(kind.type === 'bool' && typeof value !== 'boolean')
|
|
257
|
+
(kind.type === 'bool' && typeof value !== 'boolean') ||
|
|
258
|
+
(kind.type === 'size' && value === 'false')
|
|
210
259
|
if (untyped || parseSettingValue(kind, value) === null) {
|
|
211
260
|
throw new UserError(settingRefusal(path, where, value, shown(value)))
|
|
212
261
|
}
|
|
@@ -231,11 +280,13 @@ function validateSettingsExtra(config: WorkspaceConfig, configPath: string): voi
|
|
|
231
280
|
/**
|
|
232
281
|
* A refused value as a message quotes it. A workspace file is evaluated
|
|
233
282
|
* in-process and no JSON rule precedes its schema, so a value here may be
|
|
234
|
-
* one `JSON.stringify` throws on (`30n`)
|
|
235
|
-
* became a TypeError with a stack.
|
|
283
|
+
* one `JSON.stringify` throws on (`30n`), drops (a symbol) or misnames
|
|
284
|
+
* (`Infinity` as null): the refusal became a TypeError with a stack.
|
|
236
285
|
*/
|
|
237
286
|
function shown(v: unknown): string {
|
|
238
|
-
|
|
287
|
+
if (typeof v === 'bigint') return `${v}n`
|
|
288
|
+
if (typeof v === 'number' && !Number.isFinite(v)) return String(v)
|
|
289
|
+
return JSON.stringify(v) ?? String(v)
|
|
239
290
|
}
|
|
240
291
|
|
|
241
292
|
/** A `plugins` entry that stands for no plugin: `undefined`, `null` or `false`. */
|
|
@@ -258,18 +309,24 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
|
|
|
258
309
|
}
|
|
259
310
|
const verbOwners = new Map<string, string>()
|
|
260
311
|
const fileClaimants = new Map<string, { name: string; at: number }>()
|
|
312
|
+
const seenAt = new Map<object, number>()
|
|
261
313
|
for (const [i, p] of (config.plugins as readonly unknown[]).entries()) {
|
|
262
314
|
// A factory that declines returns nothing, and `cond && plugin()` is
|
|
263
315
|
// false: both are skipped, as Vite skips them.
|
|
264
316
|
if (isSkippedPlugin(p)) continue
|
|
265
317
|
if (p === null || typeof p !== 'object') {
|
|
266
318
|
// Nx's nx.json lists plugins by module name (`'@nx/vite/plugin'`);
|
|
267
|
-
// a vx plugin is the value its package's function returns
|
|
319
|
+
// a vx plugin is the value its package's function returns, and
|
|
320
|
+
// `plugins: [otel]` is that function, not called.
|
|
268
321
|
throw new UserError(
|
|
269
322
|
`${configPath}: \`plugins[${i}]\` must be an object` +
|
|
270
323
|
(typeof p === 'string'
|
|
271
324
|
? ` — a plugin is what its package's function returns (\`nx()\` from \`@vzn/vx-migrate\`), not a module name`
|
|
272
|
-
: ''
|
|
325
|
+
: typeof p === 'function'
|
|
326
|
+
? p.name === ''
|
|
327
|
+
? ' — it is a function; call the factory that returns the plugin'
|
|
328
|
+
: ` — it is the function '${p.name}'; call it: ${p.name}()`
|
|
329
|
+
: ''),
|
|
273
330
|
)
|
|
274
331
|
}
|
|
275
332
|
// The loose schema's shape (unknown-typed hooks) plus the one retired
|
|
@@ -297,6 +354,15 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
|
|
|
297
354
|
`${configPath}: \`plugins[${i}].name\` overrides the package name ('${String(plug.name)}' over '${pkg}') — a plugin's name is its package name; drop the field`,
|
|
298
355
|
)
|
|
299
356
|
}
|
|
357
|
+
// One instance twice (two presets sharing it) ran its setup, its
|
|
358
|
+
// handlers and its telemetry sinks twice. Two instances stay two.
|
|
359
|
+
const first = seenAt.get(p)
|
|
360
|
+
if (first !== undefined) {
|
|
361
|
+
throw new UserError(
|
|
362
|
+
`${configPath}: \`plugins[${i}]\` is \`plugins[${first}]\` again (plugin '${pkg}') — declare a plugin once`,
|
|
363
|
+
)
|
|
364
|
+
}
|
|
365
|
+
seenAt.set(p, i)
|
|
300
366
|
// A misspelled hook (`excutor`, `setUp`) was never called and never
|
|
301
367
|
// said: tasks ran here with the plugin's executor unheard (X-19).
|
|
302
368
|
for (const key of Object.keys(plug)) {
|
|
@@ -462,6 +528,18 @@ export function validateProjectConfig(
|
|
|
462
528
|
`${configPath}: \`name\` must be a non-empty string with no surrounding whitespace and no "#" (a task is <name>#<task>)`,
|
|
463
529
|
)
|
|
464
530
|
}
|
|
531
|
+
// Printed raw by the status line and summary: `ESC ]0;` retitled the terminal.
|
|
532
|
+
if (hasControl(name)) {
|
|
533
|
+
throw new UserError(
|
|
534
|
+
`${configPath}: \`name\` ${JSON.stringify(name)} holds a control character — rename the project`,
|
|
535
|
+
)
|
|
536
|
+
}
|
|
537
|
+
const why = projectNameProblem(name)
|
|
538
|
+
if (why !== null) {
|
|
539
|
+
throw new UserError(
|
|
540
|
+
`${configPath}: \`name\` ${JSON.stringify(name)} ${why} — dependsOn, --filter and the CLI could not name the project. Rename it.`,
|
|
541
|
+
)
|
|
542
|
+
}
|
|
465
543
|
}
|
|
466
544
|
const tags: unknown = config.tags
|
|
467
545
|
if (
|
|
@@ -478,6 +556,12 @@ export function validateProjectConfig(
|
|
|
478
556
|
`name it. Rename the tag.`,
|
|
479
557
|
)
|
|
480
558
|
}
|
|
559
|
+
// Printed raw by `vx show`, as a name is.
|
|
560
|
+
if (hasControl(tag)) {
|
|
561
|
+
throw new UserError(
|
|
562
|
+
`${configPath}: tag ${JSON.stringify(tag)} holds a control character — rename the tag`,
|
|
563
|
+
)
|
|
564
|
+
}
|
|
481
565
|
}
|
|
482
566
|
const tasks = config.tasks
|
|
483
567
|
if (tasks === undefined) return
|
|
@@ -528,9 +612,7 @@ export function validateProjectConfig(
|
|
|
528
612
|
}
|
|
529
613
|
const retries = (exec as { retries?: unknown }).retries
|
|
530
614
|
if (retries !== undefined) {
|
|
531
|
-
|
|
532
|
-
throw new UserError(`${where}.exec.retries must be a non-negative integer`)
|
|
533
|
-
}
|
|
615
|
+
validateSettingValue(retries, 'run.retries', `${where}.exec.retries`)
|
|
534
616
|
}
|
|
535
617
|
const remote = (exec as { remote?: unknown }).remote
|
|
536
618
|
if (remote !== undefined && typeof remote !== 'boolean' && remote !== 'only') {
|
|
@@ -545,6 +627,20 @@ export function validateProjectConfig(
|
|
|
545
627
|
`and its key is the address of its remote record`,
|
|
546
628
|
)
|
|
547
629
|
}
|
|
630
|
+
const platforms = (exec as { platforms?: unknown }).platforms
|
|
631
|
+
if (platforms !== undefined) {
|
|
632
|
+
const known = PLATFORMS as readonly unknown[]
|
|
633
|
+
if (
|
|
634
|
+
!Array.isArray(platforms) ||
|
|
635
|
+
platforms.length === 0 ||
|
|
636
|
+
!platforms.every((p) => known.includes(p)) ||
|
|
637
|
+
new Set(platforms).size !== platforms.length
|
|
638
|
+
) {
|
|
639
|
+
throw new UserError(
|
|
640
|
+
`${where}.exec.platforms must be a non-empty array of distinct platforms, each one of ${PLATFORMS.join(', ')}`,
|
|
641
|
+
)
|
|
642
|
+
}
|
|
643
|
+
}
|
|
548
644
|
const env = (exec as { env?: unknown }).env
|
|
549
645
|
if (env !== undefined) {
|
|
550
646
|
if (typeof env !== 'object' || env === null) {
|
|
@@ -799,9 +895,9 @@ export function validateProjectConfig(
|
|
|
799
895
|
// The only InputsConfig field that reached the run unvalidated: a
|
|
800
896
|
// non-string entry crashes deep in `filterUpstreamHashes` /
|
|
801
897
|
// `parseDependencySpec` with a raw TypeError naming neither the task
|
|
802
|
-
// nor the config.
|
|
803
|
-
// `
|
|
804
|
-
//
|
|
898
|
+
// nor the config. Each entry's syntax too: a malformed one (`^`,
|
|
899
|
+
// `a#`) passed `vx lock` and failed the task at hash time, after its
|
|
900
|
+
// upstream ran.
|
|
805
901
|
const taskFilters = (inputs as { tasks?: unknown }).tasks
|
|
806
902
|
if (taskFilters !== undefined) {
|
|
807
903
|
if (
|
|
@@ -813,6 +909,14 @@ export function validateProjectConfig(
|
|
|
813
909
|
`(Turbo/Nx micro-syntax: 'name', '^name', 'pkg#name', '*', '^*', '!name')`,
|
|
814
910
|
)
|
|
815
911
|
}
|
|
912
|
+
for (const raw of taskFilters as string[]) {
|
|
913
|
+
try {
|
|
914
|
+
parseDependencySpec(raw, 'inputs.tasks')
|
|
915
|
+
} catch (err) {
|
|
916
|
+
if (err instanceof DependencySpecError) throw new UserError(`${where}: ${err.message}`)
|
|
917
|
+
throw err
|
|
918
|
+
}
|
|
919
|
+
}
|
|
816
920
|
assertFilterNamesDeclaredDeps(taskFilters, task.dependsOn, where)
|
|
817
921
|
}
|
|
818
922
|
for (const field of ['runtime', 'workspaceRuntime'] as const) {
|
|
@@ -893,8 +997,8 @@ export function validateProjectConfig(
|
|
|
893
997
|
if (hasParentSegment(g)) {
|
|
894
998
|
throw new UserError(
|
|
895
999
|
`${where}.cache.outputs.files: '..' path segments are not allowed (got "${g}") — ` +
|
|
896
|
-
`outputs must stay within the project (
|
|
897
|
-
`
|
|
1000
|
+
`outputs must stay within the project (vx clears a task's outputs before it runs, ` +
|
|
1001
|
+
`so a glob that escapes the project would delete files outside it)`,
|
|
898
1002
|
)
|
|
899
1003
|
}
|
|
900
1004
|
assertPosixGlob(g, `${where}.cache.outputs.files`, 'project')
|
|
@@ -931,6 +1035,7 @@ export function validateProjectConfig(
|
|
|
931
1035
|
}
|
|
932
1036
|
}
|
|
933
1037
|
assertOutputsNotNegationOnly(outFiles as string[], `${where}.cache.outputs.files`)
|
|
1038
|
+
assertNoLiteralTakenBack(outFiles as string[], `${where}.cache.outputs.files`, OUTPUT_LOST)
|
|
934
1039
|
const wsOutputs = (outputs as { workspaceFiles?: unknown }).workspaceFiles
|
|
935
1040
|
if (wsOutputs !== undefined) {
|
|
936
1041
|
validateWorkspaceGlobs(wsOutputs, `${where}.cache.outputs.workspaceFiles`, false)
|
|
@@ -958,6 +1063,7 @@ const EXEC_FIELDS = new Set([
|
|
|
958
1063
|
'interactive',
|
|
959
1064
|
'remote',
|
|
960
1065
|
'sandbox',
|
|
1066
|
+
'platforms',
|
|
961
1067
|
])
|
|
962
1068
|
const PERSISTENT_FIELDS = new Set(['readyWhen'])
|
|
963
1069
|
const ENV_FIELDS = new Set(['passThrough', 'define', 'secret'])
|
|
@@ -1028,7 +1134,7 @@ const FOREIGN_FIELDS: ReadonlyMap<ReadonlySet<string>, Readonly<Record<string, s
|
|
|
1028
1134
|
files: "the task's `inputs.files`",
|
|
1029
1135
|
output: '`outputs.files`',
|
|
1030
1136
|
env: "the task's `inputs.env`",
|
|
1031
|
-
dependencies: "the task's `dependsOn` (an upstream's key
|
|
1137
|
+
dependencies: "the task's `dependsOn` (an upstream's key goes into this one)",
|
|
1032
1138
|
enabled: 'no field: a task with a `cache` block caches, one without runs every time',
|
|
1033
1139
|
},
|
|
1034
1140
|
],
|
|
@@ -1134,6 +1240,23 @@ function nearestSetting(key: string): string | undefined {
|
|
|
1134
1240
|
return name === undefined ? undefined : `\`${byName.get(name)}\``
|
|
1135
1241
|
}
|
|
1136
1242
|
|
|
1243
|
+
/**
|
|
1244
|
+
* Where a field one level down lives: `retries`, `timeout` or `sandbox` on
|
|
1245
|
+
* a task, beside `exec` rather than in it, was refused with the task's
|
|
1246
|
+
* fields alone, none of which is it.
|
|
1247
|
+
*/
|
|
1248
|
+
function nestedHome(allowed: ReadonlySet<string>, key: string): string | undefined {
|
|
1249
|
+
if (allowed !== TASK_FIELDS) return undefined
|
|
1250
|
+
for (const [field, own] of [
|
|
1251
|
+
['exec', EXEC_FIELDS],
|
|
1252
|
+
['inputs', INPUT_FIELDS],
|
|
1253
|
+
['cache', CACHE_FIELDS],
|
|
1254
|
+
] as const) {
|
|
1255
|
+
if (own.has(key)) return `\`${field}.${key}\``
|
|
1256
|
+
}
|
|
1257
|
+
return undefined
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1137
1260
|
function assertKnownFields(value: object, allowed: ReadonlySet<string>, where: string): void {
|
|
1138
1261
|
// `typeof [] === 'object'`, so an array reaches here and its indices read
|
|
1139
1262
|
// as fields: `outputs: ['dist/**']` (Turbo's spelling) was refused as
|
|
@@ -1148,7 +1271,7 @@ function assertKnownFields(value: object, allowed: ReadonlySet<string>, where: s
|
|
|
1148
1271
|
if (!allowed.has(key)) {
|
|
1149
1272
|
// The nearest accepted spelling first: the list says what the level
|
|
1150
1273
|
// takes, the hint says which one was meant.
|
|
1151
|
-
const foreign = FOREIGN_FIELDS.get(allowed)?.[key]
|
|
1274
|
+
const foreign = FOREIGN_FIELDS.get(allowed)?.[key] ?? nestedHome(allowed, key)
|
|
1152
1275
|
const near =
|
|
1153
1276
|
nearest(key, allowed) ?? (allowed === WORKSPACE_FIELDS ? nearestSetting(key) : undefined)
|
|
1154
1277
|
throw new UserError(
|
|
@@ -1246,6 +1369,63 @@ function assertNoForeignToken(glob: string, where: string): void {
|
|
|
1246
1369
|
`files it names. Write ${rewrite} instead.`,
|
|
1247
1370
|
)
|
|
1248
1371
|
}
|
|
1372
|
+
assertNoBareGroup(glob, body, where)
|
|
1373
|
+
assertNoBraceRange(glob, where)
|
|
1374
|
+
assertNoGluedGlobstar(glob, where)
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
/**
|
|
1378
|
+
* `Bun.Glob` reads `**` glued to other text (`src/**.ts`, `a**`) as `*`, one
|
|
1379
|
+
* level: deeper inputs left the key and deeper outputs were never saved.
|
|
1380
|
+
* Turbo rewrites it to the deep reading (vercel/turborepo#5431); vx refuses
|
|
1381
|
+
* and names both.
|
|
1382
|
+
*/
|
|
1383
|
+
function assertNoGluedGlobstar(glob: string, where: string): void {
|
|
1384
|
+
const body = glob.startsWith('!') ? glob.slice(1) : glob
|
|
1385
|
+
if (!body.split(/[/{},]/).some((seg) => seg.includes('**') && /[^*]/.test(seg))) return
|
|
1386
|
+
const deep = glob.replace(/\*\*([^*/{},]+)/g, '**/*$1').replace(/([^*/{},]+)\*\*/g, '$1*/**')
|
|
1387
|
+
const one = glob.replace(/\*{2,}/g, (run, at: number) =>
|
|
1388
|
+
/[^*/{},]/.test(glob[at - 1] ?? '') || /[^*/{},]/.test(glob[at + run.length] ?? '') ? '*' : run,
|
|
1389
|
+
)
|
|
1390
|
+
throw new UserError(
|
|
1391
|
+
`${where}: "${glob}" glues ** to other text in one path segment, which vx's glob engine ` +
|
|
1392
|
+
`reads as one *, a single directory level. Write "${deep}" for every depth, or "${one}" ` +
|
|
1393
|
+
`for one level.`,
|
|
1394
|
+
)
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
/** `(a|b)` with no extglob sigil: `Bun.Glob` reads parens and `|` as literal text. */
|
|
1398
|
+
const BARE_GROUP = /(?<!\\)\(([^()|]*(?:\|[^()|]*)+)\)/
|
|
1399
|
+
|
|
1400
|
+
function assertNoBareGroup(glob: string, body: string, where: string): void {
|
|
1401
|
+
const group = BARE_GROUP.exec(body)
|
|
1402
|
+
if (group === null) return
|
|
1403
|
+
const names = group[1]!.split('|')
|
|
1404
|
+
const rewrite = BUN_GLOB_WILDCARDS.test(group[1]!)
|
|
1405
|
+
? 'braces ("{a,b}") or one entry per alternative'
|
|
1406
|
+
: `"${glob.replace(group[0], `{${names.join(',')}}`)}"`
|
|
1407
|
+
throw new UserError(
|
|
1408
|
+
`${where}: "${glob}" holds a bare (…|…) group, which vx's glob engine reads as literal ` +
|
|
1409
|
+
`text, never as the alternatives it lists, so the cache key would miss the files it ` +
|
|
1410
|
+
`names. Write ${rewrite} instead.`,
|
|
1411
|
+
)
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
const BRACE_RANGE = /(?<!\\)\{(-?\d+|[a-zA-Z])\.\.(-?\d+|[a-zA-Z])(\.\.-?\d+)?\}/g
|
|
1415
|
+
|
|
1416
|
+
/**
|
|
1417
|
+
* A range vx expands is any task glob's (`braceRange`). One it does not —
|
|
1418
|
+
* mixed case, or past the limit — `Bun.Glob` would still read, so the
|
|
1419
|
+
* readings would differ by engine: refused.
|
|
1420
|
+
*/
|
|
1421
|
+
function assertNoBraceRange(glob: string, where: string): void {
|
|
1422
|
+
// Every range, not the first: `a{1..2}{A..c}` held one vx reads past.
|
|
1423
|
+
const m = [...glob.matchAll(BRACE_RANGE)].find((r) => braceRange(r[0].slice(1, -1)) === null)
|
|
1424
|
+
if (m === undefined) return
|
|
1425
|
+
throw new UserError(
|
|
1426
|
+
`${where}: "${glob}" holds the brace range ${m[0]}, which vx expands only within one case ` +
|
|
1427
|
+
`and up to ${BRACE_RANGE_LIMIT} names. Write a smaller range or a comma brace.`,
|
|
1428
|
+
)
|
|
1249
1429
|
}
|
|
1250
1430
|
|
|
1251
1431
|
/**
|
|
@@ -1331,7 +1511,7 @@ function assertPosixGlob(glob: string, where: string, base: string): void {
|
|
|
1331
1511
|
)
|
|
1332
1512
|
}
|
|
1333
1513
|
|
|
1334
|
-
const ONE_ALTERNATIVE_BRACE = /(?<!\\)\{[^{},]*\}/
|
|
1514
|
+
const ONE_ALTERNATIVE_BRACE = /(?<!\\)\{[^{},]*\}/g
|
|
1335
1515
|
|
|
1336
1516
|
/**
|
|
1337
1517
|
* What every run would refuse, or silently key nothing for, refused at
|
|
@@ -1341,8 +1521,11 @@ const ONE_ALTERNATIVE_BRACE = /(?<!\\)\{[^{},]*\}/
|
|
|
1341
1521
|
* named. A path under `.git` is never listed by git, so it keyed nothing.
|
|
1342
1522
|
*/
|
|
1343
1523
|
function assertInputGlob(glob: string, where: string): void {
|
|
1344
|
-
|
|
1345
|
-
|
|
1524
|
+
// A range is a brace vx expands (`braceRange`), not one alternative.
|
|
1525
|
+
const m = [...glob.matchAll(ONE_ALTERNATIVE_BRACE)].find(
|
|
1526
|
+
(b) => braceRange(b[0].slice(1, -1)) === null,
|
|
1527
|
+
)
|
|
1528
|
+
if (m !== undefined) {
|
|
1346
1529
|
const inner = m[0].slice(1, -1)
|
|
1347
1530
|
throw new UserError(
|
|
1348
1531
|
`${where}: "${glob}" holds a brace with one alternative, which matches ` +
|
|
@@ -1427,7 +1610,7 @@ function assertFilterNamesDeclaredDeps(
|
|
|
1427
1610
|
// the wildcard skip below takes (item 678).
|
|
1428
1611
|
if (raw.startsWith('!')) continue
|
|
1429
1612
|
const f = specForm(raw)
|
|
1430
|
-
if (f.task.
|
|
1613
|
+
if (f.task.includes('*') || named(f)) continue
|
|
1431
1614
|
// `where` opens with the config's path; the message names it once.
|
|
1432
1615
|
const task = where.slice(where.lastIndexOf(': tasks.') + 2)
|
|
1433
1616
|
throw new UserError(
|
|
@@ -1501,7 +1684,9 @@ export function taskNameProblem(name: string): string | null {
|
|
|
1501
1684
|
? "holds '*', which makes it a pattern"
|
|
1502
1685
|
: name.startsWith('^') || name.startsWith('!')
|
|
1503
1686
|
? `starts with '${name[0]}', which names dependencies' tasks or negates`
|
|
1504
|
-
:
|
|
1687
|
+
: hasControl(name)
|
|
1688
|
+
? 'holds a control character'
|
|
1689
|
+
: null
|
|
1505
1690
|
}
|
|
1506
1691
|
|
|
1507
1692
|
/**
|
|
@@ -1512,11 +1697,19 @@ export function taskNameProblem(name: string): string | null {
|
|
|
1512
1697
|
function tagProblem(tag: string): string | null {
|
|
1513
1698
|
if (tag.trim() !== tag) return 'has surrounding whitespace'
|
|
1514
1699
|
if (tag.includes('*')) return "holds '*', which makes it a pattern"
|
|
1515
|
-
const
|
|
1700
|
+
const walk = "ends in '...', which a filter reads as a dependency walk"
|
|
1701
|
+
let f: ReturnType<typeof parseFilter>
|
|
1702
|
+
try {
|
|
1703
|
+
f = parseFilter(`tag:${tag}`, '/')
|
|
1704
|
+
} catch {
|
|
1705
|
+
// `...` alone: the walk leaves the filter no tag, and its refusal
|
|
1706
|
+
// named neither the config nor the tag.
|
|
1707
|
+
return walk
|
|
1708
|
+
}
|
|
1516
1709
|
if (f.gitSince !== undefined) {
|
|
1517
1710
|
return `ends in "[${f.gitSince}]", which a filter reads as a git range`
|
|
1518
1711
|
}
|
|
1519
|
-
if (f.withDeps || f.onlyDeps) return
|
|
1712
|
+
if (f.withDeps || f.onlyDeps) return walk
|
|
1520
1713
|
return null
|
|
1521
1714
|
}
|
|
1522
1715
|
|
|
@@ -1548,7 +1741,12 @@ function assertNotNegationOnly(globs: readonly string[], where: string): void {
|
|
|
1548
1741
|
* entered the key, and an edit to it replayed the old output. Refusing is
|
|
1549
1742
|
* free: the literal already selected nothing.
|
|
1550
1743
|
*/
|
|
1551
|
-
function assertNoLiteralTakenBack(
|
|
1744
|
+
function assertNoLiteralTakenBack(
|
|
1745
|
+
globs: readonly string[],
|
|
1746
|
+
where: string,
|
|
1747
|
+
// An output's literal is never saved: a hit left it missing and said up-to-date.
|
|
1748
|
+
lost = 'the file never enters the key',
|
|
1749
|
+
): void {
|
|
1552
1750
|
const negative = globs.filter((g) => g.startsWith('!')).map((g) => g.slice(1))
|
|
1553
1751
|
if (negative.length === 0) return
|
|
1554
1752
|
const taken = anyTaskGlob(asTrees(negative))
|
|
@@ -1559,7 +1757,7 @@ function assertNoLiteralTakenBack(globs: readonly string[], where: string): void
|
|
|
1559
1757
|
const by = negative.find((n) => anyTaskGlob(asTrees([n]))(lit))
|
|
1560
1758
|
throw new UserError(
|
|
1561
1759
|
`${where}: "${g}" is taken back by "!${by}" — a \`!\` entry subtracts wherever it sits in ` +
|
|
1562
|
-
`the list, so
|
|
1760
|
+
`the list, so ${lost}. Narrow the negation so it leaves "${g}".`,
|
|
1563
1761
|
)
|
|
1564
1762
|
}
|
|
1565
1763
|
}
|
|
@@ -1601,13 +1799,18 @@ function validateWorkspaceGlobs(v: unknown, where: string, negation: boolean): v
|
|
|
1601
1799
|
if (negation) {
|
|
1602
1800
|
assertNotNegationOnly(v as string[], where)
|
|
1603
1801
|
assertNoLiteralTakenBack(v as string[], where)
|
|
1604
|
-
} else
|
|
1802
|
+
} else {
|
|
1803
|
+
assertOutputsNotNegationOnly(v as string[], where)
|
|
1804
|
+
assertNoLiteralTakenBack(v as string[], where, OUTPUT_LOST)
|
|
1805
|
+
}
|
|
1605
1806
|
}
|
|
1606
1807
|
|
|
1607
1808
|
/**
|
|
1608
1809
|
* An output list of only `!` entries selects nothing: the task saves and
|
|
1609
1810
|
* restores nothing while it reads as if it declared outputs (A-44).
|
|
1610
1811
|
*/
|
|
1812
|
+
const OUTPUT_LOST = 'the file is never saved or restored'
|
|
1813
|
+
|
|
1611
1814
|
function assertOutputsNotNegationOnly(globs: readonly string[], where: string): void {
|
|
1612
1815
|
if (globs.length === 0 || globs.some((g) => !g.startsWith('!'))) return
|
|
1613
1816
|
throw new UserError(
|
package/src/workspace/filter.ts
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
import path from 'node:path'
|
|
25
25
|
import { BUN_GLOB_WILDCARDS, relPosix, UserError } from '../util/index.js'
|
|
26
26
|
import type { PackageGraph } from './package-graph.js'
|
|
27
|
+
import { canonicalDir } from './canonical-dir.js'
|
|
27
28
|
import type { ProjectMeta } from './workspace.js'
|
|
28
29
|
|
|
29
30
|
export interface ParsedFilter {
|
|
@@ -54,7 +55,7 @@ export interface ParsedFilter {
|
|
|
54
55
|
pathGlob?: Bun.Glob
|
|
55
56
|
/** `./a/**`'s `a`: a trailing `**` matches zero dirs, and `Bun.Glob` does not (D-84). */
|
|
56
57
|
pathGlobBase?: Bun.Glob
|
|
57
|
-
/** The workspace root
|
|
58
|
+
/** The workspace root a path form is relative to. */
|
|
58
59
|
pathRoot?: string
|
|
59
60
|
/** `//`: the project at `matcher` itself, never the ones under it. */
|
|
60
61
|
exactDir?: true
|
|
@@ -67,7 +68,12 @@ export interface ParsedFilter {
|
|
|
67
68
|
dirFallback?: ParsedFilter
|
|
68
69
|
}
|
|
69
70
|
|
|
70
|
-
export function parseFilter(
|
|
71
|
+
export function parseFilter(
|
|
72
|
+
raw: string,
|
|
73
|
+
workspaceRoot: string,
|
|
74
|
+
/** The filter as typed, when `raw` is the `./` reading of a name (`dirFallback`). */
|
|
75
|
+
typed = raw,
|
|
76
|
+
): ParsedFilter {
|
|
71
77
|
let s = raw
|
|
72
78
|
const negate = s.startsWith('!')
|
|
73
79
|
if (negate) s = s.slice(1)
|
|
@@ -180,8 +186,18 @@ export function parseFilter(raw: string, workspaceRoot: string): ParsedFilter {
|
|
|
180
186
|
? s.slice(1, -1)
|
|
181
187
|
: undefined
|
|
182
188
|
if (pathForm !== undefined) {
|
|
189
|
+
// `{}` resolved to the root, every project under it: an unset
|
|
190
|
+
// variable in `--filter "{$PKG}"` ran the whole workspace.
|
|
191
|
+
if (pathForm.trim() === '') throw new UserError(`filter "${raw}" names no project`)
|
|
183
192
|
isPath = true
|
|
184
193
|
matcher = path.resolve(workspaceRoot, pathForm)
|
|
194
|
+
// `../` (pnpm's cwd-relative sibling) climbed out of the root, and
|
|
195
|
+
// every project sat under the directory it named: all of them ran.
|
|
196
|
+
const up = path.relative(workspaceRoot, matcher)
|
|
197
|
+
if (up === '..' || up.startsWith(`..${path.sep}`))
|
|
198
|
+
throw new UserError(
|
|
199
|
+
`filter "${typed}" names a directory outside the workspace root: a path filter is relative to the root`,
|
|
200
|
+
)
|
|
185
201
|
// `./packages/*` — pnpm's and Turbo's spelling for "every package under
|
|
186
202
|
// packages": a glob over the root-relative project dir. Resolved
|
|
187
203
|
// against the workspace root like the literal form.
|
|
@@ -200,7 +216,7 @@ export function parseFilter(raw: string, workspaceRoot: string): ParsedFilter {
|
|
|
200
216
|
// 23.2.1), and `-p 'apps/*'` matched nothing here.
|
|
201
217
|
const dirFallback =
|
|
202
218
|
!isPath && s.includes('/') && !s.startsWith('@')
|
|
203
|
-
? parseFilter(`./${s}`, workspaceRoot)
|
|
219
|
+
? parseFilter(`./${s}`, workspaceRoot, raw)
|
|
204
220
|
: undefined
|
|
205
221
|
|
|
206
222
|
return {
|
|
@@ -213,7 +229,8 @@ export function parseFilter(raw: string, workspaceRoot: string): ParsedFilter {
|
|
|
213
229
|
isPath,
|
|
214
230
|
matcher,
|
|
215
231
|
...(dirFallback !== undefined ? { dirFallback } : {}),
|
|
216
|
-
...(
|
|
232
|
+
...(isPath ? { pathRoot: workspaceRoot } : {}),
|
|
233
|
+
...(pathGlob !== undefined ? { pathGlob } : {}),
|
|
217
234
|
...(pathGlobBase !== undefined ? { pathGlobBase } : {}),
|
|
218
235
|
...(gitSince !== undefined ? { gitSince } : {}),
|
|
219
236
|
...(sinceViaDeps ? { sinceViaDeps: true as const } : {}),
|
|
@@ -261,7 +278,13 @@ function matchSelector(
|
|
|
261
278
|
// pnpm read it: `.` is the root project, never every project under the
|
|
262
279
|
// root, and `./packages/app` leaves the examples nested in it (D-43).
|
|
263
280
|
// A directory that is no project keeps the "at or under" reading.
|
|
264
|
-
|
|
281
|
+
// A link to a project's directory (`./widgets/a -> ../submodules/a`)
|
|
282
|
+
// names that project, which discovery holds at its real directory.
|
|
283
|
+
let exact = projects.find((p) => p.dir === filter.matcher)
|
|
284
|
+
if (exact === undefined && filter.pathRoot !== undefined) {
|
|
285
|
+
const real = canonicalDir(filter.pathRoot, filter.matcher)
|
|
286
|
+
if (real !== null && real !== filter.matcher) exact = projects.find((p) => p.dir === real)
|
|
287
|
+
}
|
|
265
288
|
if (exact !== undefined) return [exact.name]
|
|
266
289
|
if (filter.exactDir === true) return out
|
|
267
290
|
const prefix = filter.matcher + path.sep
|
|
@@ -339,9 +362,10 @@ export interface ApplyFiltersOptions {
|
|
|
339
362
|
/**
|
|
340
363
|
* Called once per filter that matched zero projects, before expansion.
|
|
341
364
|
* Only the TOTAL empty selection is an error, so without this a typo among
|
|
342
|
-
* several filters silently under-selects.
|
|
365
|
+
* several filters silently under-selects. `emptyDiff`: a `[<since>]`
|
|
366
|
+
* filter whose name or dir part names projects, none of them changed.
|
|
343
367
|
*/
|
|
344
|
-
onNoMatch?: (filter: ParsedFilter) => void
|
|
368
|
+
onNoMatch?: (filter: ParsedFilter, emptyDiff: boolean) => void
|
|
345
369
|
/**
|
|
346
370
|
* Called once per filter whose pattern matched projects but whose walk
|
|
347
371
|
* selected none (`...^c` for a `c` nothing depends on), with what it
|
|
@@ -362,7 +386,12 @@ export function applyFilters(opts: ApplyFiltersOptions): Set<string> {
|
|
|
362
386
|
const excludes: Set<string>[] = []
|
|
363
387
|
for (const f of opts.filters) {
|
|
364
388
|
const matched = matchProjects(f, opts.projects, opts.affectedByFilter, opts.graph, opts.tags)
|
|
365
|
-
if (matched.length === 0)
|
|
389
|
+
if (matched.length === 0)
|
|
390
|
+
opts.onNoMatch?.(
|
|
391
|
+
f,
|
|
392
|
+
f.gitSince !== undefined &&
|
|
393
|
+
(f.matcher === '' || matchSelector(f, opts.projects, opts.tags).length > 0),
|
|
394
|
+
)
|
|
366
395
|
const expanded = new Set<string>()
|
|
367
396
|
for (const name of matched) {
|
|
368
397
|
if (!f.onlyDeps && !f.onlyDependents) expanded.add(name)
|