@vzn/vx-migrate 0.0.645 → 0.0.648

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/src/package-fs.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // What packages.ts needs of the file system and of globs, as core has
2
2
  // them: core's copies are not on the façade, and these left core with the
3
- // package-manager discovery (M, 2026-10-14).
3
+ // package-manager discovery (2026-10-14).
4
4
 
5
5
  import { readlinkSync, realpathSync } from 'node:fs'
6
6
  import path from 'node:path'
@@ -123,6 +123,11 @@ export async function gitTop(dir: string): Promise<string | null> {
123
123
  if (await file.exists()) {
124
124
  const gitdir = await file.text().catch(() => '')
125
125
  if (!/(^|[\s/])\.git\/modules\//.test(gitdir)) return d || '/'
126
+ // A submodule's own worktree points there too, and is a top-level:
127
+ // git writes `commondir` in a worktree's gitdir alone.
128
+ const to = gitdir.replace(/^gitdir:\s*/, '').trim()
129
+ if (await Bun.file(`${to.startsWith('/') ? '' : `${d}/`}${to}/commondir`).exists())
130
+ return d || '/'
126
131
  }
127
132
  if (d === '') return null
128
133
  }
package/src/packages.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  // Package-manager workspaces: the members `package.json#workspaces` or
2
2
  // `pnpm-workspace.yaml` list, and the root a migration writes
3
- // `.vx/workspace.ts` at. Core reads neither (M, 2026-10-14): a project is a
3
+ // `.vx/workspace.ts` at. Core reads neither (2026-10-14): a project is a
4
4
  // directory holding a vx.config.
5
5
  import { constants, existsSync, type Dirent } from 'node:fs'
6
6
  import { access, readdir, realpath, stat } from 'node:fs/promises'
7
7
  import path from 'node:path'
8
- import { PROJECT_CONFIG_FILENAMES, UserError } from '@vzn/vx'
8
+ import { PROJECT_CONFIG_FILENAMES, ui, UserError } from '@vzn/vx'
9
9
  import {
10
10
  BUN_GLOB_WILDCARDS,
11
11
  EXTGLOB,
@@ -75,7 +75,7 @@ const decoder = new TextDecoder()
75
75
  * Inside git the walk never goes above the git top-level ({@link gitTop}),
76
76
  * so a manifest above the checkout is never even read: a `package.json`
77
77
  * planted in a shared directory above it claimed it, and its workspace
78
- * config ran as you (owner, 2026-10-13: "find the root based on git").
78
+ * config ran as you (2026-10-13: "find the root based on git").
79
79
  *
80
80
  * Outside git the walk stops above `start` at a manifest another user
81
81
  * owns (root aside), the same case without a top-level to stop at
@@ -641,8 +641,9 @@ async function readManifest(root: string, dir: string, file: string): Promise<st
641
641
  () => false,
642
642
  )
643
643
  if (searchable) unreadable(err, file)
644
- process.stderr.write(
645
- `vx: ${shownDir(root, dir)} is not readable by this user — skipped, with any project in it\n`,
644
+ ui.say(
645
+ 'warn',
646
+ `vx: ${shownDir(root, dir)} is not readable by this user — skipped, with any project in it`,
646
647
  )
647
648
  return null
648
649
  }
@@ -768,8 +769,9 @@ export async function discoverPackages(
768
769
  shared.sort()
769
770
  const shown =
770
771
  shared.slice(0, 2).join('; ') + (shared.length > 2 ? `; and ${shared.length - 2} more` : '')
771
- process.stderr.write(
772
- `vx: left out, as no project can be addressed by a name several manifests share (none has a vx config): ${shown}\n`,
772
+ ui.say(
773
+ 'warn',
774
+ `vx: left out, as no project can be addressed by a name several manifests share (none has a vx config): ${shown}`,
773
775
  )
774
776
  }
775
777
 
@@ -1,11 +1,13 @@
1
1
  // What a mapping could not say, reported once by `vx lock`, the pass that
2
- // checks configs (a run checks nothing, M 2026-10-15). What this machine
2
+ // checks configs (a run checks nothing, 2026-10-15). What this machine
3
3
  // lacks to run the mapping (a bin, a graph export) is said on runs too. One line per DISTINCT gap, not one per task: n8n has a
4
4
  // `dev` and a `watch` in most of its 84 packages, and astro's `build`
5
5
  // carries the same `!vendor/**` output in every package — the per-task
6
6
  // form was a hundred identical lines before the first frame (2026-09-11).
7
7
  // Shared by `turbo()` and `nx()` since the second one existed.
8
8
 
9
+ import { taskNameProblem } from '@vzn/vx'
10
+
9
11
  /** What both mappers' projects have in common: a name and tasks with todos. */
10
12
  interface MappedProject {
11
13
  readonly name: string
@@ -39,25 +41,49 @@ export function collectGaps(
39
41
  return { notes, todos, live }
40
42
  }
41
43
 
42
- /** At `vx lock` every gap; on a run only the live notes. */
44
+ /**
45
+ * At `vx lock` every gap; on a run only the live notes. Each says `[vx]`
46
+ * first, as every plugin warning does, then the package's short name.
47
+ */
43
48
  export function warnGaps(
44
49
  warn: (line: string) => void,
45
50
  pluginName: string,
46
51
  gaps: Gaps,
47
52
  lock: boolean,
48
53
  ): void {
49
- for (const note of gaps.live) warn(`[${pluginName}] ${note}`)
54
+ const tag = `[vx] ${pluginName.replace(/^@vzn\//, '')}:`
55
+ for (const note of gaps.live) warn(`${tag} ${note}`)
50
56
  if (!lock) return
51
- for (const note of gaps.notes) warn(`[${pluginName}] ${note}`)
57
+ for (const note of gaps.notes) warn(`${tag} ${note}`)
52
58
  for (const [todo, ids] of gaps.todos) {
53
59
  if (ids.length === 1) {
54
- warn(`[${pluginName}] ${ids[0]}: ${todo}`)
60
+ warn(`${tag} ${ids[0]}: ${todo}`)
55
61
  continue
56
62
  }
57
63
  const names = [...new Set(ids.map((id) => id.slice(id.indexOf('#') + 1)))]
58
64
  const packages = new Set(ids.map((id) => id.slice(0, id.indexOf('#'))))
59
65
  warn(
60
- `[${pluginName}] ${ids.length} task(s) (${names.join(', ')} across ${packages.size} package(s)): ${todo}`,
66
+ `${tag} ${ids.length} tasks (${names.join(', ')} across ${packages.size} package${packages.size === 1 ? '' : 's'}): ${todo}`,
61
67
  )
62
68
  }
63
69
  }
70
+
71
+ /**
72
+ * A task whose name the schema refuses (`lint#fix`, `^up`) becomes a TODO,
73
+ * as the scripts migrator does (item 1033): written as a task, core
74
+ * refused the whole config on the next command.
75
+ */
76
+ export function refuseTaskNames(
77
+ projects: readonly { tasks: { name: string; todos: string[]; task: object | null }[] }[],
78
+ what: string,
79
+ ): void {
80
+ for (const p of projects)
81
+ for (const t of p.tasks) {
82
+ const why = t.task === null ? null : taskNameProblem(t.name)
83
+ if (why === null) continue
84
+ t.task = null
85
+ t.todos = [
86
+ `${what} ${JSON.stringify(t.name)} not migrated: its name ${why} — rename it, or add it by hand under another name`,
87
+ ]
88
+ }
89
+ }
@@ -1,6 +1,6 @@
1
1
  // The remote cache a Turbo or Nx repo already uses, as the repo shows it,
2
2
  // so the workspace file a migration writes keeps sharing it: native or
3
- // keep, from `vx init` or `bunx @vzn/vx-migrate` (M, 2026-10-14). Nx
3
+ // keep, from `vx init` or `bunx @vzn/vx-migrate` (2026-10-14). Nx
4
4
  // Cloud's wire is Nx's own, so it is named, never declared.
5
5
 
6
6
  import { readFileSync } from 'node:fs'
@@ -28,6 +28,7 @@ export function remoteCacheSignal(root: string, runner: 'turbo' | 'nx'): string
28
28
  rc = (
29
29
  Bun.JSONC.parse(readFileSync(path.join(root, f), 'utf8')) as { remoteCache?: unknown }
30
30
  )?.remoteCache
31
+ // quiet: a local config file: none read is none declared
31
32
  } catch {
32
33
  continue
33
34
  }
@@ -42,6 +43,7 @@ export function remoteCacheSignal(root: string, runner: 'turbo' | 'nx'): string
42
43
  let linked: unknown
43
44
  try {
44
45
  linked = JSON.parse(readFileSync(path.join(root, '.turbo', 'config.json'), 'utf8'))
46
+ // quiet: a local config file: none read is none linked
45
47
  } catch {}
46
48
  if (
47
49
  typeof linked === 'object' &&
@@ -63,6 +65,7 @@ export function remoteCacheSignal(root: string, runner: 'turbo' | 'nx'): string
63
65
  let text: string
64
66
  try {
65
67
  text = readFileSync(path.join(root, f), 'utf8')
68
+ // quiet: a local config file: none read is none declared
66
69
  } catch {
67
70
  continue
68
71
  }
@@ -84,6 +87,7 @@ function usesNxCloud(root: string): boolean {
84
87
  } | null
85
88
  try {
86
89
  json = Bun.JSONC.parse(readFileSync(path.join(root, 'nx.json'), 'utf8')) as typeof json
90
+ // quiet: a local config file: none read is none declared
87
91
  } catch {
88
92
  return false
89
93
  }
@@ -11,9 +11,15 @@
11
11
  */
12
12
  export function deadline(timeoutMs: number): AbortSignal {
13
13
  const controller = new AbortController()
14
+ // A timer past 2^31-1 ms fires after 1 ms: a 30-day
15
+ // TURBO_REMOTE_CACHE_TIMEOUT aborted every request as it started. Turbo
16
+ // defines that value, so it is held here; an option is refused at the call.
14
17
  setTimeout(
15
18
  () => controller.abort(new Error(`no answer within ${timeoutMs} ms`)),
16
- timeoutMs,
19
+ Math.min(timeoutMs, MAX_TIMER_MS),
17
20
  ).unref()
18
21
  return controller.signal
19
22
  }
23
+
24
+ /** The longest delay a timer holds. */
25
+ export const MAX_TIMER_MS = 2 ** 31 - 1
@@ -0,0 +1,31 @@
1
+ /** How long the body's first chunk is waited for: a refusal is not held on a body that never comes. */
2
+ const BODY_WAIT_MS = 1_000
3
+
4
+ /**
5
+ * A remote's refusal, named by its status and its own words: "HTTP 500"
6
+ * alone said nothing, and a batch query Vercel refused with 400 "Too many
7
+ * hashes. Limit is 100." read as no answer and was never seen (main
8
+ * f71b377, 2026-10-23). One line of the body, bounded: it is remote text.
9
+ * Shared by `turboCache()` and `nxCache()`.
10
+ */
11
+ export async function refusal(res: Response, what = ''): Promise<Error> {
12
+ let said = ''
13
+ try {
14
+ const reader = res.body?.getReader()
15
+ if (reader !== undefined) {
16
+ let timer: ReturnType<typeof setTimeout> | undefined
17
+ const first = await Promise.race([
18
+ reader.read(),
19
+ new Promise<undefined>((r) => {
20
+ timer = setTimeout(() => r(undefined), BODY_WAIT_MS)
21
+ }),
22
+ ]).finally(() => clearTimeout(timer))
23
+ // quiet: read no further; a cancel that fails has nothing left to free
24
+ void reader.cancel().catch(() => undefined)
25
+ said = new TextDecoder().decode(first?.value?.subarray(0, 512)).replace(/\s+/g, ' ').trim()
26
+ }
27
+ } catch {
28
+ // quiet: the status alone still names the refusal
29
+ }
30
+ return new Error(`${what}HTTP ${res.status}${said === '' ? '' : `: ${said.slice(0, 200)}`}`)
31
+ }
@@ -24,7 +24,8 @@ export class Cooldown {
24
24
  * One request with Turbo's retry rule (`turborepo-api-client/src/retry.rs`):
25
25
  * a 5xx other than 501, or a request that never connected, is sent again
26
26
  * after 2 s, `retries` times. A 429 opens the remote's `cooldown` for its
27
- * `Retry-After` (capped at 10 s a wait, 2 s without one), and the request
27
+ * `Retry-After` (capped at 10 s a wait, 2 s without one or for a `0`
28
+ * after the first), and the request
28
29
  * is resent once it ends, as often as fits in 20 s from its first 429: a
29
30
  * limit longer than one capped wait lost every hit before (Growth's
30
31
  * parity run, 2026-10-16). A spent deadline is not retried, unlike
@@ -55,8 +56,12 @@ export async function withRetry(
55
56
  continue
56
57
  }
57
58
  if (res.status === 429) {
59
+ // `Retry-After: 0` resends at once, but only once in a row: each
60
+ // answered so sent again at once, hundreds a second for 20 s.
61
+ const asked = retryAfterMs(res)
62
+ const delay =
63
+ asked === undefined || (asked === 0 && firstLimit !== undefined) ? BACKOFF_MS : asked
58
64
  firstLimit ??= now()
59
- const delay = retryAfterMs(res) ?? BACKOFF_MS
60
65
  if (retries === 0 || now() + delay - firstLimit > RATE_LIMIT_BUDGET_MS) return res
61
66
  cooldown.extend(delay)
62
67
  await res.body?.cancel()
@@ -77,10 +82,10 @@ function retryable(status: number): boolean {
77
82
  return status >= 500 && status !== 501
78
83
  }
79
84
 
80
- /** A refused port or an unresolved host, as Bun's `fetch` codes them (measured on 1.4.2). */
85
+ /** A refused port or an unresolved host, as Bun's `fetch` codes them (1.4.4 took errno names). */
81
86
  function neverConnected(err: unknown): boolean {
82
87
  const code = (err as { code?: unknown } | null)?.code
83
- return code === 'ConnectionRefused' || code === 'ENOTFOUND'
88
+ return code === 'ECONNREFUSED' || code === 'ENOTFOUND'
84
89
  }
85
90
 
86
91
  /** `Retry-After` as seconds or an HTTP date, capped as Turbo caps it. */
@@ -0,0 +1,28 @@
1
+ import { UserError } from '@vzn/vx'
2
+
3
+ /**
4
+ * A remote cache's URL, refused as `from` names it (an option, a variable,
5
+ * a config file). Shared by `turboCache()` and `nxCache()`, at the call for
6
+ * the option and when the cache opens for every source.
7
+ *
8
+ * Plain http:// to any host is taken, where vxCache() takes it only to
9
+ * loopback: Turbo and Nx both send their token over it, and their
10
+ * self-hosted servers document such URLs (`TURBO_API=http://cache:3000`
11
+ * beside a CI service container; nx-cache-server's
12
+ * `NX_SELF_HOSTED_REMOTE_CACHE_SERVER=http://your-server:3000`). These
13
+ * plugins run such a repo unchanged.
14
+ */
15
+ export function checkRemoteUrl(url: string, from: string): void {
16
+ // With no scheme every request failed on its own, as a bare "Invalid URL".
17
+ if (!/^https?:\/\//.test(url))
18
+ throw new UserError(`${from} must be an http(s) URL, got ${JSON.stringify(url)}`)
19
+ // The URL is printed in every refusal line, so a `user:pass@` in it would
20
+ // leak to the log. Credentials go in the token.
21
+ if (URL.canParse(url)) {
22
+ const u = new URL(url)
23
+ if (u.username !== '' || u.password !== '')
24
+ throw new UserError(
25
+ `${from} carries credentials (user:pass@); pass them as the token instead`,
26
+ )
27
+ }
28
+ }
@@ -20,9 +20,10 @@
20
20
  // the shims `yarn run` writes, so inlined, `node -e "require('left-pad')"`
21
21
  // was MODULE_NOT_FOUND (2026-09-28). Every script runs as `yarn run <name>`.
22
22
 
23
- import { readFileSync } from 'node:fs'
23
+ import { existsSync, readFileSync } from 'node:fs'
24
24
  import path from 'node:path'
25
25
  import { foldScriptHooks } from '@vzn/vx'
26
+ import { shellQuote } from './nx-command.js'
26
27
 
27
28
  const YARN_RUN_BUILTIN = /(^|&&|\|\||;|\(|\|)\s*run\s/
28
29
  const LIFECYCLE = /^(pre|post)(install|publish|pack|version)$|^(prepare|prepublishOnly|install)$/
@@ -36,15 +37,22 @@ export function scriptCommand(
36
37
  body: string,
37
38
  scripts: Readonly<Record<string, unknown>> = {},
38
39
  pnp = false,
40
+ /** `runsScriptHooks`: false where the manager runs no `pre` / `post` hook. */
41
+ hooks = true,
39
42
  ): string {
40
- if (pnp) return `yarn run ${name}`
43
+ if (pnp) return yarnRun(name)
41
44
  const hook = (h: string): string | undefined =>
42
- !LIFECYCLE.test(h) && usable(scripts[h]) ? scripts[h] : undefined
45
+ hooks && !LIFECYCLE.test(h) && usable(scripts[h]) ? scripts[h] : undefined
43
46
  const parts = [hook(`pre${name}`), body, hook(`post${name}`)]
44
- if (parts.some((p) => p !== undefined && YARN_RUN_BUILTIN.test(p))) return `yarn run ${name}`
47
+ if (parts.some((p) => p !== undefined && YARN_RUN_BUILTIN.test(p))) return yarnRun(name)
45
48
  return foldScriptHooks(parts[0], body, parts[2])
46
49
  }
47
50
 
51
+ /** `yarn run <name>`, the name one shell word: `a b` ran script `a` with an argument. */
52
+ export function yarnRun(name: string): string {
53
+ return `yarn run ${shellQuote(name)}`
54
+ }
55
+
48
56
  /**
49
57
  * Yarn ≥ 2 links Plug'n'Play unless `.yarnrc.yml` names another `nodeLinker`.
50
58
  * Read once per mapping; synchronous so every mapper, the synchronous ones
@@ -67,3 +75,101 @@ export async function yarnrcText(root: string): Promise<string> {
67
75
  .text()
68
76
  .catch(() => '\0absent')
69
77
  }
78
+
79
+ /**
80
+ * The package manager that owns `dir`, when it runs `pre` / `post` hooks
81
+ * around a script; null when it runs none. The manager is the nearest `packageManager` field or
82
+ * lockfile, and its settings are read beside it, as each reads them for a
83
+ * member too. Probed with a `prebuild` / `build` / `postbuild` trio: Yarn
84
+ * 2+ runs none (D-31); npm runs none under `ignore-scripts=true` in
85
+ * `.npmrc`; pnpm runs none under `enable-pre-post-scripts=false` there or
86
+ * `enablePrePostScripts: false` in `pnpm-workspace.yaml` (D-33); Bun and
87
+ * Yarn 1 run them whatever those say. No manager found is npm's default.
88
+ */
89
+ export function runsScriptHooks(dir: string, memo: Map<string, Owner>): string | null {
90
+ const { manager, at } = ownerOf(dir, memo)
91
+ const read = (f: string): string | undefined => {
92
+ try {
93
+ return readFileSync(path.join(at, f), 'utf8')
94
+ } catch {
95
+ return undefined
96
+ }
97
+ }
98
+ const npmrc = read('.npmrc') ?? ''
99
+ const set = (key: string, value: string): boolean =>
100
+ new RegExp(`^\\s*${key}\\s*=\\s*${value}\\s*$`, 'm').test(npmrc)
101
+ const runs =
102
+ manager === 'berry'
103
+ ? false
104
+ : manager === 'npm'
105
+ ? !set('ignore-scripts', 'true')
106
+ : manager === 'pnpm'
107
+ ? !set('enable-pre-post-scripts', 'false') && !pnpmSkipsHooks(read('pnpm-workspace.yaml'))
108
+ : true
109
+ return runs ? manager : null
110
+ }
111
+
112
+ /**
113
+ * `enablePrePostScripts: false` in `pnpm-workspace.yaml`, read as pnpm reads
114
+ * it, as YAML: a line regex missed `false # hooks off` and a flow mapping,
115
+ * which pnpm 10 honours, and folded hooks pnpm does not run.
116
+ */
117
+ function pnpmSkipsHooks(yaml: string | undefined): boolean {
118
+ if (yaml === undefined) return false
119
+ try {
120
+ const parsed = Bun.YAML.parse(yaml) as { enablePrePostScripts?: unknown } | null
121
+ return parsed?.enablePrePostScripts === false
122
+ } catch {
123
+ return false
124
+ }
125
+ }
126
+
127
+ /** The package manager that owns a directory, and the directory that says so. */
128
+ export interface Owner {
129
+ manager: string
130
+ at: string
131
+ }
132
+
133
+ /**
134
+ * The nearest `packageManager` field or lockfile from `dir` up: `berry` for
135
+ * Yarn 2+, `yarn` for Yarn 1, else the manager's name; none found is npm.
136
+ */
137
+ export function ownerOf(dir: string, memo: Map<string, Owner>): Owner {
138
+ const known = memo.get(dir)
139
+ if (known !== undefined) return known
140
+ const read = (f: string): string | undefined => {
141
+ try {
142
+ return readFileSync(path.join(dir, f), 'utf8')
143
+ } catch {
144
+ return undefined
145
+ }
146
+ }
147
+ let manager: string | undefined
148
+ try {
149
+ // A byte-order mark is stripped, as npm, pnpm and discovery strip it:
150
+ // JSON.parse threw on one and the field went unread.
151
+ const manifest = (read('package.json') ?? '').replace(/^\uFEFF/, '')
152
+ const pm = (JSON.parse(manifest) as { packageManager?: unknown }).packageManager
153
+ // A manager vx knows nothing of (zod's `nub@0.8.3`) says nothing about
154
+ // hooks: the lockfile beside it does, and "nub ran `postbuild`" was a
155
+ // claim nothing had checked (D-96).
156
+ if (typeof pm === 'string' && /^(npm|pnpm|yarn|bun)@/.test(pm))
157
+ manager = /^yarn@([2-9]|\d{2,})/.test(pm) ? 'berry' : pm.split('@')[0]
158
+ } catch {}
159
+ if (manager === undefined) {
160
+ const yarnLock = read('yarn.lock')
161
+ if (yarnLock !== undefined) manager = yarnLock.includes('\n__metadata:') ? 'berry' : 'yarn'
162
+ else if (existsSync(path.join(dir, 'pnpm-lock.yaml'))) manager = 'pnpm'
163
+ else if (existsSync(path.join(dir, 'package-lock.json'))) manager = 'npm'
164
+ else if (['bun.lock', 'bun.lockb'].some((f) => existsSync(path.join(dir, f)))) manager = 'bun'
165
+ }
166
+ const up = path.dirname(dir)
167
+ const owner =
168
+ manager !== undefined
169
+ ? { manager, at: dir }
170
+ : up === dir
171
+ ? { manager: 'npm', at: dir }
172
+ : ownerOf(up, memo)
173
+ memo.set(dir, owner)
174
+ return owner
175
+ }
@@ -23,6 +23,7 @@
23
23
  // file exists to prevent (item 445).
24
24
 
25
25
  import { isLiteralPattern, outputsOverlap, type GeneratedTask } from '@vzn/vx'
26
+ import { withoutTakenBack } from './glob-grammar.js'
26
27
  import { relPosix } from './paths.js'
27
28
  import type { TrackedKinds } from './tracked-outputs.js'
28
29
 
@@ -192,18 +193,24 @@ function cachedOutputs(t: GeneratedTask, index: number): Cached | null {
192
193
  }
193
194
 
194
195
  /**
195
- * Can the keeper's `ga` give `gb`'s tree back? Only when `ga` is a
196
- * directory at any depth (`**\/dist`, `**\/dist/**`) and `gb`'s literal
197
- * head names no such directory: the two meet only at a `dist` nested in
198
- * `gb`'s tree, which is the sibling's. solid's build outputs `**\/dist`
199
- * beside types' `types/**`; Turbo caches both, and uncaching types made
200
- * vx the slower of the two.
196
+ * Can the keeper's `ga` give `gb`'s tree back? Only when one of the two is
197
+ * a directory at any depth (`**\/dist`, `**\/dist/**`) and the other's
198
+ * literal head names no such directory: they meet only where one tree
199
+ * nests in the other, which neither task writes. solid's build outputs
200
+ * `dist/**` and `**\/dist` beside types' `types/**` and `**\/types`; Turbo
201
+ * caches both, and uncaching types made vx the slower of the two.
201
202
  */
202
- function separable(ga: string, gb: string): boolean {
203
- const name = /^\*\*\/([^*?[\]{}()!/\\]+)(?:\/\*\*)?$/.exec(ga)?.[1]
203
+ const DIR_ANYWHERE = /^\*\*\/([^*?[\]{}()!/\\]+)(?:\/\*\*)?$/
204
+
205
+ const separable = (ga: string, gb: string): boolean => apart(ga, gb) || apart(gb, ga)
206
+
207
+ function apart(dir: string, other: string): boolean {
208
+ const name = DIR_ANYWHERE.exec(dir)?.[1]
204
209
  if (name === undefined) return false
210
+ const otherName = DIR_ANYWHERE.exec(other)?.[1]
211
+ if (otherName !== undefined) return otherName !== name
205
212
  const head: string[] = []
206
- for (const seg of gb.replace(/^(\.\/)+/, '').split('/')) {
213
+ for (const seg of other.replace(/^(\.\/)+/, '').split('/')) {
207
214
  if (!isLiteralPattern(seg) || seg === '') break
208
215
  head.push(seg)
209
216
  }
@@ -348,7 +355,7 @@ function prefixIndex(): {
348
355
  }
349
356
 
350
357
  /**
351
- * A keyed group (`cache`, no `exec`: the `nx-input:<name>` twins, Turbo's
358
+ * A keyed group (`cache`, no `exec`: the `inputs:<name>` twins, Turbo's
352
359
  * transit nodes) runs nothing, so core lets its key read what a task
353
360
  * writes (`detectInputOverlaps` exempts it). Uncaching one left `{}`, a
354
361
  * task core refuses, and every verb failed.
@@ -606,7 +613,12 @@ export function excludeWorkspaceOutputs(
606
613
  }
607
614
  }
608
615
 
609
- /** A `!` output with no positive beside it takes back nothing, and core refuses a list of them alone. */
616
+ /**
617
+ * A `!` output with no positive beside it takes back nothing, and core
618
+ * refuses a list of them alone; a literal one takes back was never saved,
619
+ * and core refuses it too (trpc's `package.json`, taken back).
620
+ */
610
621
  export function takingBack(globs: readonly string[]): string[] {
611
- return globs.some((g) => !g.startsWith('!')) ? [...globs] : []
622
+ const kept = withoutTakenBack(globs)
623
+ return kept.some((g) => !g.startsWith('!')) ? kept : []
612
624
  }
@@ -11,7 +11,7 @@ import { lstat, readFile } from 'node:fs/promises'
11
11
  import path from 'node:path'
12
12
  import { isLiteralPattern, outputsOverlap } from '@vzn/vx'
13
13
  import { relPosix } from './paths.js'
14
- import { isKeyedGroup, unkey } from './shared-outputs.js'
14
+ import type * as SharedOutputs from './shared-outputs.js'
15
15
 
16
16
  /** The files git tracks under `root`, root-relative; null outside a repo or without git. */
17
17
  export async function trackedFiles(root: string): Promise<string[] | null> {
@@ -135,11 +135,13 @@ export async function headStamp(root: string): Promise<string> {
135
135
  const m = /^gitdir: (.*)$/m.exec(await readFile(dotGit, 'utf8').catch(() => ''))
136
136
  if (m !== null) gitDir = path.resolve(dir, m[1]!.trim())
137
137
  }
138
- const head = await readFile(path.join(gitDir, 'HEAD'), 'utf8').catch(() => '')
139
- const log = await lstat(path.join(gitDir, 'logs', 'HEAD')).catch(() => null)
140
- const index = await lstat(path.join(gitDir, 'index')).catch(() => null)
141
- // A worktree's own stack holds its HEAD; the common one, its branches.
142
- const common = await readFile(path.join(gitDir, 'commondir'), 'utf8').catch(() => null)
138
+ const [head, log, index, common] = await Promise.all([
139
+ readFile(path.join(gitDir, 'HEAD'), 'utf8').catch(() => ''),
140
+ lstat(path.join(gitDir, 'logs', 'HEAD')).catch(() => null),
141
+ lstat(path.join(gitDir, 'index')).catch(() => null),
142
+ // A worktree's own stack holds its HEAD; the common one, its branches.
143
+ readFile(path.join(gitDir, 'commondir'), 'utf8').catch(() => null),
144
+ ])
143
145
  const tables = await Promise.all(
144
146
  [gitDir, ...(common === null ? [] : [path.resolve(gitDir, common.trim())])].map((d) =>
145
147
  readFile(path.join(d, 'reftable', 'tables.list'), 'utf8').catch(() => ''),
@@ -220,7 +222,7 @@ interface Outputs {
220
222
  /**
221
223
  * Fit a mapped task's outputs to the committed files they cover, in place,
222
224
  * and return one `[pkg#task, todo]` per task this leaves uncached. Core
223
- * never cleans a committed file (owner, 2026-10-14), so
225
+ * never cleans a committed file (2026-10-14), so
224
226
  * the outputs keep them; what is left is the readers' `!` that hides one.
225
227
  * `tracked` is root-relative.
226
228
  */
@@ -236,6 +238,8 @@ export function spareTrackedOutputs(
236
238
  }[],
237
239
  tracked: readonly string[],
238
240
  ): [string, string][] {
241
+ // Loaded here, on a remap only: a kept mapping's run never reads it.
242
+ const { isKeyedGroup, unkey } = require('./shared-outputs.js') as typeof SharedOutputs
239
243
  const todos: [string, string][] = []
240
244
  const trackedSet = new Set(tracked)
241
245
  /** Drop the `!` entries readers carry for a committed file no output names any more. */