@vzn/vx-migrate 0.0.595 → 0.0.597

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.
@@ -0,0 +1,558 @@
1
+ // Vite Task (vite-plus's `vp run`) → vx: each package's `vite.config`
2
+ // `run` block and its package.json scripts, as the projects a migration
3
+ // plan writes. The schema is vite-plus's `RunConfig` (1.1): a task is a
4
+ // command string, an array of them, or `{ command, cwd, dependsOn, cache }`.
5
+ // vite-plus 0.x put `env`, `untrackedEnv`, `input` and `output` on the task
6
+ // itself beside `cache: true | false`; both shapes map.
7
+
8
+ import { existsSync } from 'node:fs'
9
+ import path from 'node:path'
10
+ import { type GeneratedProject, type GeneratedTask, type ProjectMeta, UserError } from '@vzn/vx'
11
+ import { minimatchToVx, withoutTakenBack } from '../glob-grammar.js'
12
+ import { packageScripts, relPosix } from '../paths.js'
13
+ import { scriptCommand, yarnPnp } from '../script-command.js'
14
+ import {
15
+ ownFileOutput,
16
+ ownFileTodo,
17
+ takingBack,
18
+ wildcardOutput,
19
+ wildcardTodo,
20
+ } from '../shared-outputs.js'
21
+ import type { TrackedKinds } from '../tracked-outputs.js'
22
+
23
+ /** vite-plus's `VITE_CONFIG_FILES`, in its order: the first one present is the package's. */
24
+ const VITE_CONFIG_FILES = [
25
+ 'vite.config.js',
26
+ 'vite.config.mjs',
27
+ 'vite.config.ts',
28
+ 'vite.config.cjs',
29
+ 'vite.config.mts',
30
+ 'vite.config.cts',
31
+ ]
32
+
33
+ const DEP_FIELDS = ['dependencies', 'devDependencies', 'peerDependencies'] as const
34
+ type DepField = (typeof DEP_FIELDS)[number]
35
+
36
+ /** A command segment that runs Vite Task: `vp run`, `vpr`, through a package runner too. */
37
+ const VP_RUN = /^(?:(?:pnpm(?: exec)?|npx|bunx|yarn)\s+)?(?:vp\s+run|vpr)(?:\s|$)/
38
+
39
+ /** npm's own verbs' hooks: the package manager runs them, never `vp run`'s task list. */
40
+ const LIFECYCLE = /^(pre|post)(install|publish|pack|version)$|^(prepare|prepublishOnly|install)$/
41
+
42
+ type Glob = string | { pattern?: unknown; base?: unknown; auto?: unknown }
43
+
44
+ interface TaskDef extends CacheDef {
45
+ command?: unknown
46
+ cwd?: unknown
47
+ dependsOn?: unknown
48
+ cache?: unknown
49
+ }
50
+
51
+ interface CacheDef {
52
+ env?: unknown
53
+ untrackedEnv?: unknown
54
+ input?: unknown
55
+ output?: unknown
56
+ }
57
+
58
+ interface RunConfig {
59
+ tasks?: Record<string, unknown>
60
+ cache?: unknown
61
+ enablePrePostScripts?: unknown
62
+ }
63
+
64
+ /** The `vite.config` file in `dir`, or null. */
65
+ function viteConfigFile(dir: string): string | null {
66
+ const name = VITE_CONFIG_FILES.find((f) => existsSync(path.join(dir, f)))
67
+ return name === undefined ? null : path.join(dir, name)
68
+ }
69
+
70
+ /**
71
+ * A package's `run` block, as `vp run` reads it: the config evaluated in
72
+ * build mode (vite-plus resolves it with `command: 'build'`), a function
73
+ * config called. Bun loads the TypeScript itself.
74
+ */
75
+ async function readRunConfig(file: string, root: string): Promise<RunConfig | null> {
76
+ let config: unknown
77
+ try {
78
+ config = ((await import(file)) as { default?: unknown }).default
79
+ if (typeof config === 'function')
80
+ config = await (config as (env: object) => unknown)({
81
+ command: 'build',
82
+ mode: 'production',
83
+ isSsrBuild: false,
84
+ isPreview: false,
85
+ })
86
+ } catch (err) {
87
+ throw new UserError(
88
+ `${relPosix(root, file)} did not load (${err instanceof Error ? err.message : String(err)}) — install the workspace's dependencies, then re-run vx-migrate`,
89
+ )
90
+ }
91
+ const run = (config as { run?: unknown } | null | undefined)?.run
92
+ return typeof run === 'object' && run !== null ? run : null
93
+ }
94
+
95
+ /** `run.cache` at the root: what `vp run` caches with no flag. */
96
+ function globalCache(run: RunConfig | null): { tasks: boolean; scripts: boolean } {
97
+ const c = run?.cache
98
+ if (typeof c === 'boolean') return { tasks: c, scripts: c }
99
+ const o = (typeof c === 'object' && c !== null ? c : {}) as { tasks?: unknown; scripts?: unknown }
100
+ return { tasks: o.tasks !== false, scripts: o.scripts === true }
101
+ }
102
+
103
+ export interface ViteTaskOptions {
104
+ /** Each project's tracked files, by root-relative dir: what a wildcard output may not claim. */
105
+ tracked?: (rel: string) => TrackedKinds
106
+ /** The config file each written task lives beside, by project dir. */
107
+ ownConfig?: (dir: string) => string | null
108
+ /** The env names the tracked files under `dirs` spell: a `*` env entry's stand-in. */
109
+ sourceNames?: (dirs: readonly string[]) => Promise<readonly string[]>
110
+ }
111
+
112
+ export interface ViteTaskMapping {
113
+ projects: GeneratedProject[]
114
+ notes: string[]
115
+ }
116
+
117
+ /** A shell word for `cd`: bare when it is safe, single-quoted otherwise. */
118
+ function shellWord(s: string): string {
119
+ return /^[\w@%+=:,./-]+$/.test(s) ? s : `'${s.replaceAll("'", `'\\''`)}'`
120
+ }
121
+
122
+ const asStrings = (v: unknown): string[] =>
123
+ Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []
124
+
125
+ /**
126
+ * `metas` plus the workspace root when it has tasks or scripts and no
127
+ * member is the root: `vp run -w` runs them, so it is a project here.
128
+ */
129
+ export async function viteTaskProjects(
130
+ root: string,
131
+ metas: readonly ProjectMeta[],
132
+ ): Promise<{ metas: ProjectMeta[]; notes: string[] }> {
133
+ if (metas.some((m) => path.resolve(m.dir) === path.resolve(root)))
134
+ return { metas: [...metas], notes: [] }
135
+ const file = viteConfigFile(root)
136
+ const run = file === null ? null : await readRunConfig(file, root)
137
+ const pkg = (await Bun.file(path.join(root, 'package.json'))
138
+ .json()
139
+ .catch(() => ({}))) as { name?: unknown; scripts?: unknown }
140
+ const scripts = typeof pkg.scripts === 'object' && pkg.scripts !== null ? pkg.scripts : {}
141
+ const hasTasks = Object.keys(run?.tasks ?? {}).length > 0 || Object.keys(scripts).length > 0
142
+ if (!hasTasks) return { metas: [...metas], notes: [] }
143
+ if (typeof pkg.name !== 'string' || pkg.name === '' || metas.some((m) => m.name === pkg.name))
144
+ return {
145
+ metas: [...metas],
146
+ notes: [
147
+ 'the workspace root has tasks or scripts `vp run -w` runs, and no package.json "name" of its own (or one a member takes); vx names a project by it — give it one and re-run vx-migrate',
148
+ ],
149
+ }
150
+ return {
151
+ metas: [...metas, { name: pkg.name, dir: root, packageJson: pkg as never, configPath: null }],
152
+ notes: [],
153
+ }
154
+ }
155
+
156
+ /** Every project's Vite Task tasks and package.json scripts as vx tasks. */
157
+ export async function mapViteTaskWorkspace(
158
+ root: string,
159
+ metas: readonly ProjectMeta[],
160
+ opts: ViteTaskOptions = {},
161
+ ): Promise<ViteTaskMapping> {
162
+ const rootFile = viteConfigFile(root)
163
+ const rootRun = rootFile === null ? null : await readRunConfig(rootFile, root)
164
+ const cacheOn = globalCache(rootRun)
165
+ const hooks = rootRun?.enablePrePostScripts !== false
166
+ const pnp = yarnPnp(root)
167
+
168
+ const runs = new Map<string, RunConfig | null>()
169
+ for (const m of metas) {
170
+ const file = viteConfigFile(m.dir)
171
+ runs.set(
172
+ m.name,
173
+ path.resolve(m.dir) === path.resolve(root)
174
+ ? rootRun
175
+ : file === null
176
+ ? null
177
+ : await readRunConfig(file, root),
178
+ )
179
+ }
180
+ const tasksOf = (m: ProjectMeta): Record<string, unknown> => runs.get(m.name)?.tasks ?? {}
181
+ const defines = (m: ProjectMeta, task: string): boolean =>
182
+ Object.hasOwn(tasksOf(m), task) || typeof packageScripts(m)[task] === 'string'
183
+ const byName = new Map(metas.map((m) => [m.name, m]))
184
+ /** Workspace members `m` lists under each field, by name. */
185
+ const depsIn = (m: ProjectMeta, field: string): string[] => {
186
+ const deps = (m.packageJson as unknown as Record<string, unknown>)[field]
187
+ if (typeof deps !== 'object' || deps === null) return []
188
+ return Object.keys(deps).filter((n) => byName.has(n) && n !== m.name)
189
+ }
190
+
191
+ const rootMeta = metas.find((m) => path.resolve(m.dir) === path.resolve(root))
192
+
193
+ /**
194
+ * A `vp run` inside a command: Vite Task inlines it into its graph. A
195
+ * leading chain of them becomes `dependsOn` edges, and the task a group
196
+ * when nothing else is left; a form with no edge spelling is a TODO.
197
+ */
198
+ function inlineRuns(t: GeneratedTask, self: ProjectMeta): void {
199
+ const exec = t.task?.['exec'] as { command?: unknown } | undefined
200
+ if (typeof exec?.command !== 'string') return
201
+ const parts = exec.command.split('&&').map((p) => p.trim())
202
+ if (!parts.some((p) => VP_RUN.test(p))) return
203
+ const edges: string[] = []
204
+ let i = 0
205
+ for (; i < parts.length && VP_RUN.test(parts[i]!); i++) {
206
+ const e = /[|;()`$<>'"\\\n]/.test(parts[i]!) ? null : runEdges(parts[i]!, self, t.name)
207
+ if (e === null) break
208
+ edges.push(...e)
209
+ }
210
+ if (parts.slice(i).some((p) => VP_RUN.test(p))) {
211
+ const seg = parts.find((p, j) => j >= i && VP_RUN.test(p))!
212
+ t.todos.push(
213
+ `\`${seg}\` runs Vite Task inside the command, which \`vp run\` inlined into its graph: name the tasks it runs under dependsOn and drop it from the command`,
214
+ )
215
+ return
216
+ }
217
+ const deps = (t.task!['dependsOn'] as string[] | undefined) ?? []
218
+ t.task!['dependsOn'] = [...new Set([...deps, ...edges])]
219
+ const rest = parts.slice(i).join(' && ')
220
+ if (rest === '') delete t.task!['exec']
221
+ else exec.command = rest
222
+ }
223
+
224
+ /** The edges one `vp run` stands for, or null when it has none. */
225
+ function runEdges(segment: string, self: ProjectMeta, own: string): string[] | null {
226
+ const words = segment.split(/\s+/)
227
+ words.splice(0, words.indexOf(words[0] === 'vpr' ? 'vpr' : 'run') + 1)
228
+ let recursive = false
229
+ let workspace = false
230
+ const filters: string[] = []
231
+ const specs: string[] = []
232
+ for (let k = 0; k < words.length; k++) {
233
+ const w = words[k]!
234
+ if (w === '-r' || w === '--recursive') recursive = true
235
+ else if (w === '-w' || w === '--workspace-root') workspace = true
236
+ else if (w === '-F' || w === '--filter') filters.push(words[++k] ?? '')
237
+ else if (w.startsWith('--filter=')) filters.push(w.slice('--filter='.length))
238
+ else if (w === '--log' || w === '--concurrency-limit') k++
239
+ else if (['-v', '--verbose', '--cache', '--no-cache', '--fail-if-no-match'].includes(w))
240
+ continue
241
+ else if (w.startsWith('-')) return null
242
+ else specs.push(w)
243
+ }
244
+ // One task and no forwarded arguments: an edge passes none.
245
+ if (specs.length !== 1) return null
246
+ const spec = specs[0]!
247
+ const hash = spec.indexOf('#')
248
+ if (hash !== -1) {
249
+ const m = byName.get(spec.slice(0, hash))
250
+ return recursive || workspace || filters.length > 0 || m === undefined ? null : [spec]
251
+ }
252
+ let picked: ProjectMeta[]
253
+ if (filters.length > 0) {
254
+ // Package names only: pnpm's directory, glob and graph selectors are not.
255
+ if (filters.some((f) => !byName.has(f))) return null
256
+ picked = filters.map((f) => byName.get(f)!)
257
+ } else if (recursive) picked = [...metas]
258
+ else if (workspace) {
259
+ if (rootMeta === undefined) return null
260
+ picked = [rootMeta]
261
+ } else return defines(self, spec) && spec !== own ? [spec] : null
262
+ // Vite Task prunes the task's own reference (a root `build: vp run -r build`).
263
+ return picked
264
+ .filter((m) => defines(m, spec) && !(m === self && spec === own))
265
+ .map((m) => `${m.name}#${spec}`)
266
+ .sort()
267
+ }
268
+
269
+ const projects: GeneratedProject[] = []
270
+ const wildUsed = new Map<string, number>()
271
+ for (const meta of metas) {
272
+ const pkgDir = relPosix(root, meta.dir)
273
+ const atRoot = pkgDir === '' || pkgDir === '.'
274
+ const tasks: GeneratedTask[] = []
275
+ const own = tasksOf(meta)
276
+ // A `*` env entry matches names in the run's environment; a written
277
+ // config lists the ones this package and its workspace dependencies spell.
278
+ const wild = Object.values(own).some((raw) => {
279
+ const def = (typeof raw === 'object' && raw !== null ? raw : {}) as TaskDef
280
+ const c = (typeof def.cache === 'object' && def.cache !== null ? def.cache : def) as CacheDef
281
+ return [...asStrings(c.env), ...asStrings(c.untrackedEnv)].some(
282
+ (e) => !e.startsWith('!') && e.includes('*'),
283
+ )
284
+ })
285
+ let spelled: readonly string[] | undefined
286
+ if (wild && opts.sourceNames !== undefined) {
287
+ const closure = new Set<ProjectMeta>([meta])
288
+ for (const p of closure)
289
+ for (const field of [...DEP_FIELDS, 'optionalDependencies'])
290
+ for (const n of depsIn(p, field)) closure.add(byName.get(n)!)
291
+ spelled = await opts.sourceNames([...closure].map((p) => p.dir))
292
+ }
293
+ for (const [name, raw] of Object.entries(own)) {
294
+ const def: TaskDef =
295
+ typeof raw === 'string' || Array.isArray(raw) ? { command: raw } : (raw ?? {})
296
+ tasks.push(
297
+ mapTask(name, def, {
298
+ pkgDir,
299
+ atRoot,
300
+ cacheOn: cacheOn.tasks,
301
+ dependsOn: (from) => fromDeps(meta, from),
302
+ opts,
303
+ dir: meta.dir,
304
+ spelled,
305
+ wildUsed,
306
+ }),
307
+ )
308
+ }
309
+
310
+ /**
311
+ * `{ task, from }`: the task in each direct member `from` lists that
312
+ * defines it. `^task` is that set while no member listed under another
313
+ * field defines it too (vx's `^` follows every field); else the edges.
314
+ */
315
+ function fromDeps(m: ProjectMeta, entry: { task: string; from: unknown }): string[] {
316
+ const fields = (Array.isArray(entry.from) ? entry.from : [entry.from]).filter(
317
+ (f): f is DepField => (DEP_FIELDS as readonly unknown[]).includes(f),
318
+ )
319
+ const chosen = new Set(fields.flatMap((f) => depsIn(m, f)))
320
+ const others = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']
321
+ .filter((f) => !(fields as string[]).includes(f))
322
+ .flatMap((f) => depsIn(m, f))
323
+ .filter((n) => !chosen.has(n) && defines(byName.get(n)!, entry.task))
324
+ if (others.length === 0) return [`^${entry.task}`]
325
+ return [...chosen]
326
+ .filter((n) => defines(byName.get(n)!, entry.task))
327
+ .sort()
328
+ .map((n) => `${n}#${entry.task}`)
329
+ }
330
+
331
+ const scripts = packageScripts(meta)
332
+ const has = (n: string): boolean => typeof scripts[n] === 'string' && scripts[n] !== ''
333
+ for (const name of Object.keys(scripts)) {
334
+ if (!has(name) || Object.hasOwn(own, name) || LIFECYCLE.test(name)) continue
335
+ // With hooks on, `vp run x` runs `prex` and `postx` around it: they
336
+ // ride inside x's command, as no task of their own.
337
+ const hookOf = hooks ? /^(pre|post)(.+)$/.exec(name) : null
338
+ if (hookOf !== null && has(hookOf[2]!) && !LIFECYCLE.test(hookOf[2]!)) continue
339
+ const command = scriptCommand(name, scripts[name] as string, hooks ? scripts : {}, pnp)
340
+ tasks.push({
341
+ name,
342
+ // Scripts are uncached unless `run.cache.scripts`, and then traced.
343
+ todos: cacheOn.scripts ? [autoTodo('inputs and outputs')] : [],
344
+ task: { exec: { command } },
345
+ })
346
+ }
347
+ for (const t of tasks) inlineRuns(t, meta)
348
+ if (tasks.length > 0) projects.push({ name: meta.name, dir: meta.dir, importLines: [], tasks })
349
+ }
350
+ const notes = [...wildUsed].map(
351
+ ([e, n]) =>
352
+ `env ${JSON.stringify(e)}: the configs list the names the files of each task's package and its ` +
353
+ `workspace dependencies spell (${n} task${n === 1 ? '' : 's'}) — add any other a task reads to ` +
354
+ 'cache.inputs.env and exec.env.passThrough',
355
+ )
356
+ return { projects, notes }
357
+ }
358
+
359
+ /** The TODO for what Vite Task traces and vx declares. */
360
+ function autoTodo(what: string, known?: Record<string, unknown>): string {
361
+ const have = known === undefined ? '' : ` (declared so far: \`${JSON.stringify(known)}\`)`
362
+ return (
363
+ `cache: Vite Task traced this task's ${what} and vx infers none — add ` +
364
+ `\`cache: { inputs: { files: [...] }, outputs: { files: [...] } }\` with the real ones${have}; ` +
365
+ 'without it the task always runs'
366
+ )
367
+ }
368
+
369
+ interface TaskCtx {
370
+ pkgDir: string
371
+ atRoot: boolean
372
+ cacheOn: boolean
373
+ dir: string
374
+ dependsOn: (entry: { task: string; from: unknown }) => string[]
375
+ opts: ViteTaskOptions
376
+ spelled: readonly string[] | undefined
377
+ /** Each `*` entry the spelled names stood in for, with its task count. */
378
+ wildUsed: Map<string, number>
379
+ }
380
+
381
+ function mapTask(name: string, def: TaskDef, ctx: TaskCtx): GeneratedTask {
382
+ const todos: string[] = []
383
+ const task: Record<string, unknown> = {}
384
+
385
+ const parts = typeof def.command === 'string' ? [def.command] : asStrings(def.command)
386
+ const body = parts.filter((p) => p.trim() !== '').join(' && ')
387
+ const cwd = typeof def.cwd === 'string' && def.cwd !== '' && def.cwd !== '.' ? def.cwd : null
388
+
389
+ const deps: string[] = []
390
+ for (const d of Array.isArray(def.dependsOn) ? def.dependsOn : []) {
391
+ if (typeof d === 'string') deps.push(d)
392
+ else if (
393
+ typeof d === 'object' &&
394
+ d !== null &&
395
+ typeof (d as { task?: unknown }).task === 'string'
396
+ )
397
+ deps.push(...ctx.dependsOn(d as { task: string; from: unknown }))
398
+ }
399
+ if (deps.length > 0 || body === '') task['dependsOn'] = [...new Set(deps)]
400
+ // `command: []` runs nothing: a group of its dependsOn.
401
+ if (body === '') return { name, todos, task }
402
+
403
+ const command = cwd === null ? body : `cd ${shellWord(cwd)} && ${body}`
404
+ const exec: Record<string, unknown> = { command }
405
+ task['exec'] = exec
406
+
407
+ const cache: CacheDef | null =
408
+ def.cache === false || !ctx.cacheOn
409
+ ? null
410
+ : typeof def.cache === 'object' && def.cache !== null
411
+ ? def.cache
412
+ : def
413
+
414
+ const env = envNames('cache.env', cache?.env, todos, ctx)
415
+ const pass = [
416
+ ...new Set([...env, ...envNames('cache.untrackedEnv', cache?.untrackedEnv, todos, ctx)]),
417
+ ]
418
+ if (pass.length > 0) exec['env'] = { passThrough: pass }
419
+ if (cache === null) return { name, todos, task }
420
+
421
+ const inputs = globs('input', cache.input, ctx, todos)
422
+ const outputs = globs('output', cache.output, ctx, todos)
423
+ if (inputs.auto || outputs.auto) {
424
+ const known: Record<string, unknown> = {}
425
+ if (!inputs.auto) known['inputs'] = inputs.lists
426
+ if (!outputs.auto) known['outputs'] = outputs.lists
427
+ const what =
428
+ inputs.auto && outputs.auto ? 'inputs and outputs' : inputs.auto ? 'inputs' : 'outputs'
429
+ todos.push(autoTodo(what, Object.keys(known).length > 0 ? known : undefined))
430
+ return { name, todos, task }
431
+ }
432
+ if (outputs.uncached) return { name, todos, task }
433
+
434
+ const outFiles = outputs.lists['files'] as string[]
435
+ const wild = wildcardOutput(outFiles, ctx.opts.tracked?.(ctx.pkgDir))
436
+ if (wild !== undefined) {
437
+ todos.push(wildcardTodo(wild))
438
+ return { name, todos, task }
439
+ }
440
+ const ownFile = ownFileOutput(outFiles, ctx.opts.ownConfig?.(ctx.dir))
441
+ if (ownFile !== undefined) {
442
+ todos.push(ownFileTodo(ownFile))
443
+ return { name, todos, task }
444
+ }
445
+ const inputLists: Record<string, unknown> = { ...inputs.lists }
446
+ if (env.length > 0) inputLists['env'] = env
447
+ task['cache'] = { inputs: inputLists, outputs: outputs.lists }
448
+ return { name, todos, task }
449
+ }
450
+
451
+ /**
452
+ * `env` / `untrackedEnv` as the exact names vx takes. `*` matches any run
453
+ * of characters and `!` takes names back, as Vite Task's patterns do; a `*`
454
+ * entry lists the names `spelled` holds that it matches (the bare prefix is
455
+ * no name), or is a TODO when none does.
456
+ */
457
+ function envNames(
458
+ field: string,
459
+ v: unknown,
460
+ todos: string[],
461
+ { spelled, wildUsed }: Pick<TaskCtx, 'spelled' | 'wildUsed'>,
462
+ ): string[] {
463
+ const re = (p: string): RegExp => new RegExp(`^${p.split('*').map(RegExp.escape).join('.*')}$`)
464
+ const names: string[] = []
465
+ const dropped: RegExp[] = []
466
+ for (const e of asStrings(v)) {
467
+ if (e.startsWith('!')) {
468
+ dropped.push(re(e.slice(1)))
469
+ continue
470
+ }
471
+ const hits =
472
+ e.includes('*') && spelled !== undefined
473
+ ? spelled.filter((n) => n !== e.replaceAll('*', '') && re(e).test(n))
474
+ : []
475
+ if (hits.length > 0) {
476
+ names.push(...hits)
477
+ wildUsed.set(e, (wildUsed.get(e) ?? 0) + 1)
478
+ continue
479
+ }
480
+ if (/[*?[\]{}=\0]/.test(e) || e === '') {
481
+ todos.push(
482
+ `${field} ${JSON.stringify(e)}: vx takes exact env names — list the ones the task reads in exec.env.passThrough${field === 'cache.env' ? ' and cache.inputs.env' : ''}`,
483
+ )
484
+ continue
485
+ }
486
+ names.push(e)
487
+ }
488
+ return [...new Set(names)].filter((n) => !dropped.some((d) => d.test(n)))
489
+ }
490
+
491
+ /**
492
+ * `cache.input` / `cache.output`: package-relative globs (a string, or
493
+ * `{ pattern, base: 'package' }`) are `files`, `base: 'workspace'` ones
494
+ * `workspaceFiles`; a root task's are the workspace's. Omitted or holding
495
+ * `{ auto: true }`, the list is traced, which vx does not do.
496
+ */
497
+ function globs(
498
+ kind: 'input' | 'output',
499
+ v: unknown,
500
+ ctx: TaskCtx,
501
+ todos: string[],
502
+ ): { auto: boolean; uncached: boolean; lists: Record<string, string[]> } {
503
+ if (!Array.isArray(v)) return { auto: true, uncached: false, lists: {} }
504
+ let auto = false
505
+ let uncached = false
506
+ const files: string[] = []
507
+ const ws: string[] = []
508
+ for (const e of v as Glob[]) {
509
+ let pattern: string
510
+ let workspace = ctx.atRoot
511
+ if (typeof e === 'string') pattern = e
512
+ else if (typeof e === 'object' && e !== null && 'auto' in e) {
513
+ if (e.auto === true) auto = true
514
+ continue
515
+ } else if (typeof e === 'object' && e !== null && typeof e.pattern === 'string') {
516
+ pattern = e.pattern
517
+ if (e.base === 'workspace') workspace = true
518
+ } else continue
519
+ const neg = pattern.startsWith('!')
520
+ const raw = neg ? pattern.slice(1) : pattern
521
+ const wax = minimatchToVx(raw, neg)
522
+ if (wax === null) {
523
+ todos.push(
524
+ `${kind} ${JSON.stringify(pattern)}: glob syntax vx cannot take — ` +
525
+ (kind === 'input'
526
+ ? 'widened to every package file'
527
+ : 'task runs uncached; declare the exact outputs in a vx.config to cache it'),
528
+ )
529
+ if (kind === 'output') uncached = true
530
+ else if (!neg) files.push('**/*')
531
+ continue
532
+ }
533
+ let body = wax.replace(/^(\.\/)+/, '')
534
+ if (!workspace && body.startsWith('../')) {
535
+ const anchored = path.posix.normalize(path.posix.join(ctx.pkgDir, body))
536
+ if (anchored.startsWith('../')) {
537
+ todos.push(`${kind} ${JSON.stringify(pattern)}: leaves the workspace — map manually`)
538
+ if (kind === 'output') uncached = true
539
+ continue
540
+ }
541
+ body = anchored
542
+ workspace = true
543
+ }
544
+ ;(workspace ? ws : files).push((neg ? '!' : '') + body)
545
+ }
546
+ const lists: Record<string, string[]> = {}
547
+ if (kind === 'input') {
548
+ // Exclusions alone narrow every file; core refuses a list of them alone.
549
+ if (files.length > 0 && files.every((f) => f.startsWith('!'))) files.unshift('**/*')
550
+ lists['files'] = withoutTakenBack(files)
551
+ if (ws.length > 0) lists['workspaceFiles'] = withoutTakenBack(ws)
552
+ } else {
553
+ lists['files'] = takingBack(files)
554
+ const w = takingBack(ws)
555
+ if (w.length > 0) lists['workspaceFiles'] = w
556
+ }
557
+ return { auto, uncached, lists }
558
+ }