@vzn/vx-migrate 0.0.652 → 0.0.653

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.
@@ -294,10 +294,11 @@ export function resolveSharedOutputs(tasks: GeneratedTask[]): GeneratedTask[] {
294
294
  }
295
295
  dropped.add(c.index)
296
296
  const shared = c.files.find((gb) => clash.files.some((ga) => hits(clash, ga, gb)))!
297
+ const theirs = clash.files.find((ga) => hits(clash, ga, shared))!
297
298
  uncache(t.task!)
298
299
  t.todos.push(
299
- `declares the output ${JSON.stringify(shared)} that ${JSON.stringify(clash.name)} also ` +
300
- "declares — vx cleans a task's outputs before it runs and before a restore, so two " +
300
+ `declares the output ${sharing(shared, theirs, JSON.stringify(clash.name))} — ` +
301
+ "vx cleans a task's outputs before it runs and before a restore, so two " +
301
302
  "cached tasks on one path would delete each other's work; this one runs uncached. " +
302
303
  'Give it its own output path to cache it.',
303
304
  )
@@ -306,6 +307,13 @@ export function resolveSharedOutputs(tasks: GeneratedTask[]): GeneratedTask[] {
306
307
  return tasks
307
308
  }
308
309
 
310
+ /** `"<mine>" that <owner> also declares`, or both globs when they only overlap. */
311
+ function sharing(mine: string, theirs: string, owner: string): string {
312
+ return mine === theirs
313
+ ? `${JSON.stringify(mine)} that ${owner} also declares`
314
+ : `${JSON.stringify(mine)}, which overlaps the output ${JSON.stringify(theirs)} that ${owner} declares`
315
+ }
316
+
309
317
  /**
310
318
  * Two globs can match one path only when one's literal prefix is a
311
319
  * directory of the other's (`packages/*\/dist/**` and `packages/a/dist/**`),
@@ -397,10 +405,17 @@ function inputsOf(t: GeneratedTask): { files: string[]; workspaceFiles?: unknown
397
405
  * files git tracks, and a build's outputs are not, so the adopted tool
398
406
  * never keyed them either. A literal input another task writes cannot be
399
407
  * taken back (core refuses a `!` over a literal input), so that task runs
400
- * uncached with a todo. A reader whose `!` hides a committed file is the
401
- * tracked pass's (`spareTrackedOutputs`).
408
+ * uncached with a todo. Under `walksGlobs` (Turbo) so does a glob that
409
+ * names the output's place (`gen/**` beside a `gen` writing `gen/**`)
410
+ * when no `dependsOn` path reaches the writer: Turbo walks an explicit
411
+ * glob on disk, and with no edge the writer's key does not reach the
412
+ * reader's either, so taken back it replayed the old output. A glob led
413
+ * by `**` walks the whole package, as the default does, and keeps the
414
+ * `!`. Nx hashes no gitignored file, and its mapper drops an input under
415
+ * an untracked output before this pass. A reader whose `!` hides a
416
+ * committed file is the tracked pass's (`spareTrackedOutputs`).
402
417
  */
403
- export function excludeSiblingOutputs(tasks: GeneratedTask[]): GeneratedTask[] {
418
+ export function excludeSiblingOutputs(tasks: GeneratedTask[], walksGlobs = false): GeneratedTask[] {
404
419
  const writers = tasks.flatMap((t, index) => {
405
420
  const c = cachedOutputs(t, index)
406
421
  return c === null ? [] : [c]
@@ -427,6 +442,18 @@ export function excludeSiblingOutputs(tasks: GeneratedTask[]): GeneratedTask[] {
427
442
  )
428
443
  return
429
444
  }
445
+ const named = walksGlobs
446
+ ? read.find((g) => !g.replace(/^(\.\/)+/, '').startsWith('**'))
447
+ : undefined
448
+ if (named !== undefined && !isKeyedGroup(t) && !reaches(tasks, index, w.index)) {
449
+ unkey(t.task!)
450
+ t.todos.push(
451
+ `reads ${JSON.stringify(named)}, which ${JSON.stringify(w.name)} writes and no ` +
452
+ 'dependsOn reaches — vx keys a task only on files no other task writes, so it runs ' +
453
+ 'uncached; add the edge or read the source instead in a vx.config to cache it',
454
+ )
455
+ return
456
+ }
430
457
  inputs.files.push(`!${out}`)
431
458
  }
432
459
  }
@@ -434,6 +461,26 @@ export function excludeSiblingOutputs(tasks: GeneratedTask[]): GeneratedTask[] {
434
461
  return tasks
435
462
  }
436
463
 
464
+ /** Does a same-project `dependsOn` path lead from task `from` to task `to`? */
465
+ function reaches(tasks: readonly GeneratedTask[], from: number, to: number): boolean {
466
+ const byName = new Map(tasks.map((t, i) => [t.name, i]))
467
+ const seen = new Set([from])
468
+ const queue = [from]
469
+ for (let i = queue.shift(); i !== undefined; i = queue.shift()) {
470
+ const deps = tasks[i]!.task?.['dependsOn']
471
+ if (!Array.isArray(deps)) continue
472
+ for (const d of deps) {
473
+ const next = typeof d === 'string' ? byName.get(d) : undefined
474
+ if (next === to) return true
475
+ if (next !== undefined && !seen.has(next)) {
476
+ seen.add(next)
477
+ queue.push(next)
478
+ }
479
+ }
480
+ }
481
+ return false
482
+ }
483
+
437
484
  /**
438
485
  * The same across projects, for workspace outputs: cal.com's shared
439
486
  * `post-install` writes `../../node_modules/@prisma/client/**` from every
@@ -476,11 +523,19 @@ export function resolveSharedWorkspaceOutputs(
476
523
  a.find((g) => b.some((h) => outputsOverlap(h, g)))
477
524
  let clash: (typeof kept)[number] | undefined
478
525
  let shared: string | undefined
526
+ let theirs: string | undefined
479
527
  for (const i of candidates([...ws, ...own])) {
480
528
  const k = kept[i]!
481
- shared =
482
- overlap(ws, [...k.ws, ...k.own]) ??
483
- overlap(own, k.project === p.name ? k.ws : [...k.ws, ...k.own])
529
+ const pools: [string[], string[]][] = [
530
+ [ws, [...k.ws, ...k.own]],
531
+ [own, k.project === p.name ? k.ws : [...k.ws, ...k.own]],
532
+ ]
533
+ for (const [mine, other] of pools) {
534
+ shared = overlap(mine, other)
535
+ if (shared === undefined) continue
536
+ theirs = other.find((h) => outputsOverlap(h, shared!))
537
+ break
538
+ }
484
539
  if (shared !== undefined) {
485
540
  clash = k
486
541
  break
@@ -493,7 +548,7 @@ export function resolveSharedWorkspaceOutputs(
493
548
  }
494
549
  uncache(t.task!)
495
550
  t.todos.push(
496
- `declares the workspace output ${JSON.stringify(shared)} that ${clash.id} also declares — ` +
551
+ `declares the workspace output ${sharing(shared!, theirs!, clash.id)} — ` +
497
552
  "vx cleans a task's outputs before it runs and before a restore, so two cached tasks " +
498
553
  "on one path would delete each other's work; this one runs uncached. Give it its own " +
499
554
  'output path to cache it.',
@@ -72,6 +72,47 @@ export async function gitIgnored(root: string, rels: readonly string[]): Promise
72
72
  }
73
73
  }
74
74
 
75
+ /**
76
+ * What git ignores under the fixed prefixes of the root-relative `globs`:
77
+ * each ignored file, and a directory ignored whole (or holding only
78
+ * ignored files) as one `dir/` entry (`--directory`: `dist/` is never
79
+ * walked). Entries under `node_modules` (the lockfile keys an install) or
80
+ * vx's own `.vx/` are left out. One spawn; empty outside a repo or
81
+ * without git.
82
+ */
83
+ export async function gitIgnoredUnder(root: string, globs: readonly string[]): Promise<string[]> {
84
+ if (globs.length === 0) return []
85
+ const prefixes = [...new Set(globs.map((g) => literalPrefix(g) || '.'))].sort()
86
+ // A prefix under another adds nothing; `ls-files` takes no pathspec file.
87
+ const kept = prefixes.includes('.')
88
+ ? ['.']
89
+ : prefixes.filter((p, i) => !prefixes.slice(0, i).some((q) => p.startsWith(`${q}/`)))
90
+ try {
91
+ const p = Bun.spawn(
92
+ [
93
+ 'git',
94
+ 'ls-files',
95
+ '-z',
96
+ '--others',
97
+ '--ignored',
98
+ '--exclude-standard',
99
+ '--directory',
100
+ '--no-empty-directory',
101
+ '--',
102
+ ...kept,
103
+ ],
104
+ { cwd: root, stdout: 'pipe', stderr: 'ignore' },
105
+ )
106
+ const out = await new Response(p.stdout).text()
107
+ if ((await p.exited) !== 0) return []
108
+ return out
109
+ .split('\0')
110
+ .filter((f) => f !== '' && !f.startsWith('.vx/') && !f.split('/').includes('node_modules'))
111
+ } catch {
112
+ return []
113
+ }
114
+ }
115
+
75
116
  /**
76
117
  * What a project's tracked files are: their extensions (lower case, no
77
118
  * dot), directory names at any depth, and top-level entry names.
@@ -165,18 +206,20 @@ function literalPrefix(glob: string): string {
165
206
  return out.join('/')
166
207
  }
167
208
 
168
- /** The tracked `files` (relative to the same base as `globs`) the positive globs cover and no `!` takes back. */
209
+ /**
210
+ * The tracked `files` (relative to the same base as `globs`) the positive
211
+ * globs cover, even one the globs' own `!` takes back: a reader's take-back
212
+ * is of the positives alone (`excludeSiblingOutputs`), so its `!*\/**`
213
+ * beside a `*\/**` minus `!src/**` output still hid src.
214
+ */
169
215
  function coveredTracked(globs: readonly string[], files: readonly string[]): string[] {
170
- const positive = globs.filter((g) => !g.startsWith('!'))
171
- const taken = globs.filter((g) => g.startsWith('!')).map((g) => g.slice(1))
172
216
  const hit = new Set<string>()
173
- for (const g of positive) {
217
+ for (const g of globs) {
218
+ if (g.startsWith('!')) continue
174
219
  const prefix = literalPrefix(g)
175
220
  for (const f of files) {
176
221
  if (prefix !== '' && f !== prefix && !f.startsWith(`${prefix}/`)) continue
177
- if (!outputsOverlap(g, f)) continue
178
- if (taken.some((t) => outputsOverlap(t, f))) continue
179
- hit.add(f)
222
+ if (outputsOverlap(g, f)) hit.add(f)
180
223
  }
181
224
  }
182
225
  return [...hit].sort()
@@ -275,20 +318,16 @@ export function spareTrackedOutputs(
275
318
  ] as const) {
276
319
  const globs = outputs[key]
277
320
  if (!Array.isArray(globs) || globs.length === 0) continue
278
- let strings = globs.filter((g): g is string => typeof g === 'string')
321
+ const strings = globs.filter((g): g is string => typeof g === 'string')
279
322
  // An output naming one committed file (eslint's
280
- // `eslint-suppressions.json`) is a source: the entry goes, and so
281
- // does the `!` a reader's inputs got for it, or every reader ran
282
- // uncached (hono).
323
+ // `eslint-suppressions.json`, a codegen's `gen.ts`) is also a
324
+ // source: the `!` a reader's inputs got for it goes, or every
325
+ // reader ran uncached (hono). The entry stays: dropped, a task
326
+ // that rewrites the file never saved.
283
327
  const rootedOf = (g: string) => (key === 'files' && rel !== '' ? `${rel}/${g}` : g)
284
- const sources = new Set(
285
- strings.filter((g) => isLiteralPattern(g) && trackedSet.has(rootedOf(g))),
286
- )
287
- if (sources.size > 0) {
288
- strings = strings.filter((g) => !sources.has(g.startsWith('!') ? g.slice(1) : g))
289
- outputs[key] = strings
290
- for (const g of sources) unhide(key === 'files' ? p : null, rootedOf(g))
291
- }
328
+ for (const g of strings)
329
+ if (isLiteralPattern(g) && trackedSet.has(rootedOf(g)))
330
+ unhide(key === 'files' ? p : null, rootedOf(g))
292
331
  const hit = coveredTracked(strings, base)
293
332
  if (hit.length > 0) hits.push([key, hit])
294
333
  }
@@ -18,7 +18,7 @@ import { refuseUnknownOptions, UserError, type PluginOptionKinds } from '@vzn/vx
18
18
  import { type AdoptionRun, adoptionPlugin } from '../adoption-plugin.js'
19
19
  import type { AdoptionMapping } from '../mapping-cache.js'
20
20
  import { collectGaps } from '../plugin-gaps.js'
21
- import { gitIgnored, trackedKinds } from '../tracked-outputs.js'
21
+ import { gitIgnored, gitIgnoredUnder, trackedKinds } from '../tracked-outputs.js'
22
22
  import { packageScripts, relPosix } from '../paths.js'
23
23
  import { onceReader, type ReadText, tscBuildInfo } from '../tsc-buildinfo.js'
24
24
  import type * as TurboMap from './turbo-map.js'
@@ -317,6 +317,7 @@ async function mapAll(
317
317
  ...(tracked === null ? {} : { tracked: trackedKinds(tracked) }),
318
318
  ownConfig: (rel) => configs.get(rel === '.' ? '' : rel) ?? null,
319
319
  ignored: (rels) => gitIgnored(root, rels),
320
+ ignoredUnder: (globs) => gitIgnoredUnder(root, globs),
320
321
  buildInfo: (rel, command) => tscBuildInfo(read, path.join(root, rel), command),
321
322
  })
322
323
  const byName = new Map<string, TurboMappedProject>()
@@ -11,7 +11,7 @@
11
11
  // The consumer decides what a global becomes through `splice`.
12
12
 
13
13
  import path from 'node:path'
14
- import { isLiteralPattern, pruneOrphanPersistentNotes, UserError } from '@vzn/vx'
14
+ import { isLiteralPattern, outputsOverlap, pruneOrphanPersistentNotes, UserError } from '@vzn/vx'
15
15
  import type { PackageMeta } from '../packages.js'
16
16
  import { refuseTaskNames } from '../plugin-gaps.js'
17
17
  import { apartTakeBacks, literalGlob, minimatchToVx, withoutTakenBack } from '../glob-grammar.js'
@@ -280,6 +280,12 @@ export interface MapTurboOptions {
280
280
  * path among them is keyed by a probe. Absent, none is.
281
281
  */
282
282
  ignored?: (rels: readonly string[]) => Promise<ReadonlySet<string>>
283
+ /**
284
+ * What git ignores under the root-relative globs' fixed prefixes
285
+ * (`gitIgnoredUnder`): files, and `dir/` for a directory ignored whole.
286
+ * Absent, an explicit glob keys none of them.
287
+ */
288
+ ignoredUnder?: (globs: readonly string[]) => Promise<readonly string[]>
283
289
  /**
284
290
  * The `.tsbuildinfo` files a root-relative package's command writes,
285
291
  * relative to the package (`tscBuildInfo`). Absent, none.
@@ -295,6 +301,12 @@ export interface MapTurboOptions {
295
301
  interface Literals {
296
302
  readonly inputs: Record<string, unknown>
297
303
  readonly rels: ReadonlyMap<string, Named>
304
+ /** The explicit globs, root-relative, with the list that holds them. */
305
+ readonly globs: ReadonlyMap<string, 'files' | 'workspaceFiles'>
306
+ /** The global `!` entries, which a preset spread may hide from the lists. */
307
+ readonly taken: readonly string[]
308
+ /** The package, root-relative (`files` entries are relative to it). */
309
+ readonly pkgDir: string
298
310
  }
299
311
  type Named = readonly ['files' | 'workspaceFiles' | null, string]
300
312
 
@@ -313,6 +325,18 @@ function zeroSegments(glob: string): string | null {
313
325
  return head === '' || isLiteralPattern(head.replace(/\\./g, '_')) ? null : head
314
326
  }
315
327
 
328
+ /**
329
+ * A glob with its `.` and `..` segments collapsed, as Turbo cleans the path
330
+ * before it walks (turborepo-globwalk's `collapse_path`): `src/../../shared/**`
331
+ * is `../shared/**`, `./src/**` is `src/**`. A `$TURBO_ROOT$/` prefix is
332
+ * the root, not a segment.
333
+ */
334
+ function collapseDots(glob: string): string {
335
+ if (glob.startsWith('$TURBO_ROOT$/'))
336
+ return '$TURBO_ROOT$/' + collapseDots(glob.slice('$TURBO_ROOT$/'.length))
337
+ return path.posix.normalize(glob)
338
+ }
339
+
316
340
  /** A glob, `!` or not, and the X its `X/**` also matches in Turbo (`zeroSegments`). */
317
341
  function withZeroSegments(glob: string): string[] {
318
342
  const neg = glob.startsWith('!') ? '!' : ''
@@ -363,6 +387,70 @@ function keyIgnored(
363
387
  inputs.workspaceRuntime = [...probes, ignoredFilesProbe(hits.map(([rel]) => rel))]
364
388
  }
365
389
 
390
+ /**
391
+ * Turbo walks an explicit input glob on disk, gitignored files and all
392
+ * (`$TURBO_DEFAULT$` alone skips them); core's globs filter what git
393
+ * lists, so `src/**` over an ignored `src/gen.ts` keyed nothing and an
394
+ * edit replayed the old output. Each ignored path a glob matches is named
395
+ * outright, which core reads from disk: a file as itself, a directory
396
+ * ignored whole as the glob's rest under it (`src/out/**`). A file a `!`
397
+ * takes back stays out (core refuses a literal one covers).
398
+ */
399
+ function keyIgnoredGlobbed(
400
+ l: Literals,
401
+ under: readonly string[],
402
+ projectDirs: readonly string[],
403
+ ): void {
404
+ // The lists as they stand: a sibling's output taken back since counts.
405
+ const current = (['files', 'workspaceFiles'] as const).flatMap((kind) =>
406
+ ((l.inputs[kind] as unknown[] | undefined) ?? []).flatMap((e) =>
407
+ typeof e === 'string' && e.startsWith('!')
408
+ ? [kind === 'files' && l.pkgDir !== '' ? `${l.pkgDir}/${e.slice(1)}` : e.slice(1)]
409
+ : [],
410
+ ),
411
+ )
412
+ const taken = [...l.taken, ...current].map((t) => new Bun.Glob(t))
413
+ for (const [glob, kind] of l.globs) {
414
+ const matcher = new Bun.Glob(glob)
415
+ const at = kind === 'files' && l.pkgDir !== '' ? `${l.pkgDir}/` : ''
416
+ for (const e of under) {
417
+ if (!e.startsWith(at)) continue
418
+ // A package nested in this one is another project, which core's
419
+ // globs stop at: its path is named from the workspace root.
420
+ const nested = at !== '' && projectDirs.some((d) => d.startsWith(at) && e.startsWith(`${d}/`))
421
+ const base = nested ? '' : at
422
+ const entry = e.endsWith('/')
423
+ ? globUnder(glob.slice(at.length), e.slice(at.length, -1))
424
+ : matcher.match(e) && !taken.some((t) => t.match(e))
425
+ ? literalGlob(e.slice(base.length))
426
+ : null
427
+ if (entry === null) continue
428
+ const into = nested ? 'workspaceFiles' : kind
429
+ const named = nested && e.endsWith('/') ? `${l.pkgDir}/${entry}` : entry
430
+ const list = (l.inputs[into] as unknown[] | undefined) ?? []
431
+ if (!list.includes(named)) l.inputs[into] = [...list, named]
432
+ }
433
+ }
434
+ }
435
+
436
+ /**
437
+ * `glob`'s rest below `dir`, which it reaches into (`a/**\/*.ts` under
438
+ * `a/gen`: `a/gen/**\/*.ts`), else null: the paths of the glob under it.
439
+ */
440
+ function globUnder(glob: string, dir: string): string | null {
441
+ // A brace holding a `/` spans segments; such a glob is not split here.
442
+ if (/\{[^}]*\//.test(glob)) return null
443
+ const gs = glob.split('/')
444
+ const ds = dir.split('/')
445
+ for (let i = 0; i < ds.length; i++) {
446
+ const g = gs[i]
447
+ if (g === undefined) return null
448
+ if (g === '**') return [...ds.map(literalGlob), ...gs.slice(i)].join('/')
449
+ if (!new Bun.Glob(g).match(ds[i]!)) return null
450
+ }
451
+ return gs.length > ds.length ? [...ds.map(literalGlob), ...gs.slice(ds.length)].join('/') : null
452
+ }
453
+
366
454
  async function readTurboJson(file: string, root: string): Promise<TurboJson> {
367
455
  const text = await Bun.file(file).text()
368
456
  let parsed: unknown
@@ -1174,6 +1262,8 @@ export async function mapTurboWorkspace(
1174
1262
  // whose own edges lack `^name` (opencode's `build: { dependsOn: [] }`)
1175
1263
  // is key-only too: Turbo's `^build` stops at it, and core's walked past
1176
1264
  // it to the builds below, running and keying ones Turbo never waits on.
1265
+ // `cache: false` does not stop it: a key-only node stores nothing, and
1266
+ // Turbo's no-op hashes the package's files cached or not.
1177
1267
  const keyOnly = new Map<string, Set<string>>()
1178
1268
  for (const meta of metas) {
1179
1269
  const scripts = packageScripts(meta)
@@ -1185,7 +1275,7 @@ export async function mapTurboWorkspace(
1185
1275
  if (!(caretSelf.has(name) || stops) || !withScript.has(name) || transit.has(name)) continue
1186
1276
  if (scripts[name] !== undefined || commandOverride(def) !== undefined) continue
1187
1277
  if (sidecarGroups.has(`${meta.name}#${name}`)) continue
1188
- if (def?.cache === false || def?.persistent === true) continue
1278
+ if (def?.persistent === true) continue
1189
1279
  emitted.get(meta.name)!.add(name)
1190
1280
  runnable.get(meta.name)!.add(name)
1191
1281
  let set = keyOnly.get(meta.name)
@@ -1315,8 +1405,12 @@ export async function mapTurboWorkspace(
1315
1405
  // Core gives a project with no `build` this very node (a group behind
1316
1406
  // `^build`, keyed on the project's files), so writing it is noise:
1317
1407
  // solid's three script-less packages each got a key-only `build`.
1408
+ // Written configs only: a bare `build` skips core's node (X-102), so
1409
+ // under turbo(), which writes nothing, `vx run build` scoped to such a
1410
+ // package was refused where Turbo runs its dependencies' builds.
1318
1411
  const noopEdges = defFor(name)!.dependsOn ?? []
1319
1412
  if (
1413
+ opts.envNames === undefined &&
1320
1414
  noop &&
1321
1415
  name === 'build' &&
1322
1416
  noopEdges.includes('^build') &&
@@ -1445,7 +1539,7 @@ export async function mapTurboWorkspace(
1445
1539
  npmScriptEnv(mapped.task, name, command === script, meta, opts)
1446
1540
  tasks.push(mapped)
1447
1541
  }
1448
- excludeSiblingOutputs(resolveSharedOutputs(tasks))
1542
+ excludeSiblingOutputs(resolveSharedOutputs(tasks), true)
1449
1543
  const tags = packageTags(meta === rootMeta ? rootCfg : files.get(meta.name))
1450
1544
  projects.push({ name: meta.name, dir: meta.dir, ...(tags.length > 0 ? { tags } : {}), tasks })
1451
1545
  }
@@ -1512,7 +1606,7 @@ export async function mapTurboWorkspace(
1512
1606
  if (pm === undefined) continue
1513
1607
  const pdef = definitions(pm).defFor(task)
1514
1608
  if (pdef?.cache !== false) continue
1515
- if (!sel.startsWith('^')) unfold.add(`${pm.name}#${task}`)
1609
+ unfold.add(`${pm.name}#${task}`)
1516
1610
  const at = relPosix(root, pm.dir)
1517
1611
  for (const g of dep.globs.length > 0 ? dep.globs : (pdef.outputs ?? []))
1518
1612
  if (!g.startsWith('!')) rels.push(at === '' ? g : `${at}/${g}`)
@@ -1532,11 +1626,15 @@ export async function mapTurboWorkspace(
1532
1626
  inputs['workspaceRuntime'] = [...probes, probe]
1533
1627
  // Folded, the producer's key "covers" what it writes, and core answers
1534
1628
  // the probe before it runs: the previous run's bytes. Left out of the
1535
- // fold, the probe waits for it (core's late probes, X-34).
1629
+ // fold, the probe waits for it (core's late probes, X-34). A `^gen`
1630
+ // edge reaches it too, beside its cached siblings: those stay folded.
1536
1631
  const deps = (t.task!['dependsOn'] as string[] | undefined) ?? []
1537
- const id = (d: string) => (d.startsWith('^') || d.includes('#') ? d : `${meta.name}#${d}`)
1538
- if (deps.some((d) => unfold.has(id(d))))
1539
- inputs['tasks'] = deps.filter((d) => !unfold.has(id(d)))
1632
+ const ids = (d: string): string[] =>
1633
+ d.startsWith('^')
1634
+ ? metas.filter((m) => declares(meta, m.name)).map((m) => `${m.name}#${d.slice(1)}`)
1635
+ : [d.includes('#') ? d : `${meta.name}#${d}`]
1636
+ if (deps.some((d) => ids(d).some((i) => unfold.has(i))))
1637
+ inputs['tasks'] = [...deps, ...[...unfold].map((i) => `!${i}`)]
1540
1638
  }
1541
1639
  }
1542
1640
 
@@ -1575,6 +1673,14 @@ export async function mapTurboWorkspace(
1575
1673
  for (const e of opts.splice('inputs', globals.inputs))
1576
1674
  if (typeof e !== 'string') opaque.set(JSON.stringify(e), globals.inputs)
1577
1675
  excludeWorkspaceOutputs(root, projects, (e) => opaque.get(JSON.stringify(e)) ?? [])
1676
+ // After every take-back: a path one takes back is not named back.
1677
+ if (opts.ignoredUnder !== undefined) {
1678
+ const under = await opts.ignoredUnder([
1679
+ ...new Set(literals.tasks.flatMap((l) => [...l.globs.keys()])),
1680
+ ])
1681
+ const dirs = projects.map((p) => relPosix(root, p.dir)).filter((d) => d !== '')
1682
+ if (under.length > 0) for (const l of literals.tasks) keyIgnoredGlobbed(l, under, dirs)
1683
+ }
1578
1684
  pruneUnreachedPersistentNotes(projects, metas, opts.persistentTodo)
1579
1685
  pruneOrphanPersistentNotes(projects, opts.persistentTodo)
1580
1686
  refuseTaskNames(projects, 'task')
@@ -2082,12 +2188,13 @@ function buildTask(
2082
2188
  // build (item 1031). A positive glob with no safe
2083
2189
  // form widens to every package file; a negation with none is dropped
2084
2190
  // (it would exclude fewer files, never more).
2085
- const wax = minimatchToVx(neg ? i.slice(1) : i, neg)
2086
- if (wax === null) {
2191
+ const vx = minimatchToVx(neg ? i.slice(1) : i, neg)
2192
+ if (vx === null) {
2087
2193
  todos.push(`input ${JSON.stringify(i)}: glob syntax vx cannot take — map manually`)
2088
2194
  if (!neg) files.push('**/*')
2089
2195
  continue
2090
2196
  }
2197
+ const wax = collapseDots(vx)
2091
2198
  const translated = (neg ? '!' : '') + wax
2092
2199
  const body = wax
2093
2200
  const up = climbed(translated)
@@ -2172,8 +2279,17 @@ function buildTask(
2172
2279
  if (keyEnv.length > 0) inputs.env = keyEnv
2173
2280
  if (pkgDotenv) inputs.runtime = [pkgDotenvDeep ? DOTENV_PROBE : DOTENV_PROBE_TOP]
2174
2281
  if (wsDotenv.length > 0) inputs.workspaceRuntime = [dotenvGlobsProbe(wsDotenv) ?? DOTENV_PROBE]
2175
- for (const rel of rels.keys()) if (!isLiteral(rel)) rels.delete(rel)
2176
- literals.tasks.push({ inputs, rels })
2282
+ const globs = new Map<string, 'files' | 'workspaceFiles'>()
2283
+ for (const [rel, [kind]] of rels)
2284
+ if (!isLiteral(rel)) {
2285
+ rels.delete(rel)
2286
+ if (kind !== null) globs.set(rel, kind)
2287
+ }
2288
+ const taken: string[] = []
2289
+ for (const g of globalKept)
2290
+ if (g.startsWith('!')) taken.push(g.slice(1))
2291
+ else if (!isLiteral(g) && !globs.has(g)) globs.set(g, 'workspaceFiles')
2292
+ literals.tasks.push({ inputs, rels, globs, taken, pkgDir: atRoot ? '' : pkgDir })
2177
2293
  // Turbo keys every task on its inputs, cached or not, and a dependant
2178
2294
  // folds that key: an uncached task's inputs still move its dependants.
2179
2295
  task.inputs = inputs
@@ -2182,6 +2298,8 @@ function buildTask(
2182
2298
  const outFiles: string[] = []
2183
2299
  const zeroed = new Set<string>()
2184
2300
  const wsOutFiles: string[] = []
2301
+ // The user's `!` outputs, not the take-backs added below.
2302
+ const ownNegated = new Set<string>()
2185
2303
  for (const raw of def.outputs ?? []) {
2186
2304
  // Turbo's grammar: an extglob in `dist/**/*.@(js|mjs)` matched
2187
2305
  // nothing, so a hit restored nothing (item 1031).
@@ -2197,12 +2315,13 @@ function buildTask(
2197
2315
  // `.next/**` minus `!.next/cache/**` saved the cache and cleaned it
2198
2316
  // before every run while vx outputs could not exclude.
2199
2317
  const neg = mapped.startsWith('!') ? '!' : ''
2200
- const o = mapped.slice(neg.length)
2318
+ const o = collapseDots(mapped.slice(neg.length))
2201
2319
  const signed = (g: string) => {
2202
2320
  const head = zeroSegments(g)
2203
- if (head === null) return [neg + g]
2204
- zeroed.add(neg + head)
2205
- return [neg + g, neg + head]
2321
+ const out = head === null ? [neg + g] : [neg + g, neg + head]
2322
+ if (head !== null) zeroed.add(neg + head)
2323
+ if (neg) for (const e of out) ownNegated.add(e)
2324
+ return out
2206
2325
  }
2207
2326
  if (o.startsWith('$TURBO_ROOT$/')) {
2208
2327
  wsOutFiles.push(...signed(o.slice('$TURBO_ROOT$/'.length)))
@@ -2253,13 +2372,29 @@ function buildTask(
2253
2372
  const ws = takingBack(wsOutFiles).sort(negationsLast)
2254
2373
  if (ws.length > 0) outputs.workspaceFiles = ws
2255
2374
  task.outputs = outputs
2256
- task.cache = true
2257
- if (rewritesManifest) {
2258
- task.inputs = {
2259
- ...(task.inputs as Record<string, unknown>),
2260
- stability: { strategy: 'mutable' },
2261
- }
2375
+ // A path a `!` output takes back is still written by the task, often
2376
+ // not gitignored (`dist/skip`): as its own input it changed after the
2377
+ // key was taken, so no run was saved, and no dependant's either. One
2378
+ // over a committed file is source (`*/**` minus `!src/**`, medusa's):
2379
+ // Turbo keys it, and taken back an edit there replayed the old build.
2380
+ // Without git's list, no `!` is known to hold only what the task writes.
2381
+ const subtract = (field: 'files' | 'workspaceFiles', from: readonly string[]) => {
2382
+ const list = inputs[field] as unknown[] | undefined
2383
+ const tracked = opts.tracked?.(field === 'files' ? pkgDir : '').files
2384
+ if (tracked === undefined) return
2385
+ const negs = from.filter(
2386
+ (g) =>
2387
+ ownNegated.has(g) &&
2388
+ !list?.includes(g) &&
2389
+ !tracked.some((f) => outputsOverlap(g.slice(1), f)),
2390
+ )
2391
+ if (list !== undefined && negs.length > 0) inputs[field] = withoutTakenBack([...list, ...negs])
2262
2392
  }
2393
+ subtract('files', outputs.files as string[])
2394
+ subtract('workspaceFiles', ws)
2395
+ task.cache = true
2396
+ // In place: the ignored-path passes hold this object.
2397
+ if (rewritesManifest) inputs.stability = { strategy: 'mutable' }
2263
2398
 
2264
2399
  return { name, todos, task, uses }
2265
2400
  }