@vzn/vx 0.0.594 → 0.0.595

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.
Files changed (54) hide show
  1. package/package.json +5 -5
  2. package/src/bin.ts +26 -11
  3. package/src/cache/archive.ts +2 -2
  4. package/src/cache/cache.ts +96 -90
  5. package/src/cache/config-evals.ts +19 -4
  6. package/src/cache/git-inputs.ts +19 -8
  7. package/src/cache/inputs.ts +110 -37
  8. package/src/cache/layered-cache.ts +25 -2
  9. package/src/cache/tar-stream.ts +1 -1
  10. package/src/cli/cache.ts +1 -11
  11. package/src/cli/completions.ts +42 -6
  12. package/src/cli/foreign-flags.ts +25 -4
  13. package/src/cli/help.ts +5 -3
  14. package/src/cli/init.ts +1 -1
  15. package/src/cli/plan-format.ts +9 -0
  16. package/src/cli/plugin-templates.ts +2 -2
  17. package/src/cli/run.ts +29 -27
  18. package/src/cli/select.ts +30 -3
  19. package/src/cli/show.ts +42 -3
  20. package/src/cli/watch-filter.ts +14 -1
  21. package/src/cli/watch-fs.ts +15 -7
  22. package/src/cli/watch-judge.ts +1 -1
  23. package/src/cli/watch.ts +18 -11
  24. package/src/config.ts +5 -5
  25. package/src/exec/env.ts +5 -0
  26. package/src/exec/index.ts +1 -0
  27. package/src/exec/proc-sample.ts +5 -1
  28. package/src/exec/runner.ts +98 -40
  29. package/src/exec/sandbox-paths.ts +11 -5
  30. package/src/exec/sandbox-runtime.ts +20 -4
  31. package/src/graph/index.ts +1 -0
  32. package/src/graph/scheduler.ts +1 -1
  33. package/src/index.ts +6 -0
  34. package/src/orchestrator/execute-task.ts +29 -7
  35. package/src/orchestrator/hit-restore.ts +6 -3
  36. package/src/orchestrator/local-shortcircuit.ts +4 -1
  37. package/src/orchestrator/lockfile-claim.ts +43 -46
  38. package/src/orchestrator/plan.ts +25 -1
  39. package/src/orchestrator/plugin-host.ts +25 -3
  40. package/src/orchestrator/plugin.ts +2 -1
  41. package/src/orchestrator/run.ts +52 -8
  42. package/src/orchestrator/signals.ts +5 -2
  43. package/src/orchestrator/summary.ts +14 -1
  44. package/src/util/index.ts +7 -1
  45. package/src/util/secret-mask.ts +36 -0
  46. package/src/util/settle.ts +15 -0
  47. package/src/workspace/affected.ts +30 -8
  48. package/src/workspace/config-cache.ts +25 -4
  49. package/src/workspace/config-imports.ts +1 -1
  50. package/src/workspace/config-schema.ts +48 -6
  51. package/src/workspace/migrate-scripts.ts +1 -1
  52. package/src/workspace/package-graph.ts +15 -3
  53. package/src/workspace/project-loader.ts +72 -0
  54. package/src/workspace/workspace.ts +82 -11
@@ -13,7 +13,13 @@
13
13
 
14
14
  import type { CacheLayer, CachePolicy, GitFilesCache } from '../cache/index.js'
15
15
  import { FULL_CACHE_POLICY } from '../cache/index.js'
16
- import { isGroupTask, runGraph, type TaskNode, type TaskOutcome } from '../graph/index.js'
16
+ import {
17
+ idleServers,
18
+ isGroupTask,
19
+ runGraph,
20
+ type TaskNode,
21
+ type TaskOutcome,
22
+ } from '../graph/index.js'
17
23
  import type { HistoryProvider } from './history.js'
18
24
  import { computeGroupKey, computeTaskHash } from './task-hash.js'
19
25
  import { keyUpstream } from './upstream.js'
@@ -24,6 +30,7 @@ export type CacheStatus =
24
30
  | 'miss' // caching enabled but no entry — would execute
25
31
  | 'no-cache' // task opts out of caching (no `cache` block) or --no-cache
26
32
  | 'group' // no `exec`; just an aggregator
33
+ | 'not-started' // a server every dependant of which restores early: the run never spawns it
27
34
 
28
35
  export interface PlannedTask {
29
36
  node: TaskNode
@@ -108,6 +115,12 @@ export interface PlanArgs {
108
115
  history?: HistoryProvider
109
116
  /** Placement lookup (`placement.ts`): >1 executor declared, or a `noop` task. */
110
117
  executorOf?: (id: string) => string | undefined
118
+ /**
119
+ * The tasks the run's short-circuit would restore ahead of their deps on
120
+ * a local hit (stable key, no workspace-output reach). A server whose
121
+ * every dependant is one of those and a local hit is never spawned.
122
+ */
123
+ restorable?: ReadonlySet<string>
111
124
  }
112
125
 
113
126
  /**
@@ -171,6 +184,17 @@ export async function plan(args: PlanArgs): Promise<RunPlan> {
171
184
  },
172
185
  })
173
186
 
187
+ if (args.restorable !== undefined) {
188
+ const restoreTier = new Set(
189
+ [...args.restorable].filter((id) => cacheStatusById.get(id) === 'hit-local'),
190
+ )
191
+ const dependents = new Map<string, string[]>()
192
+ for (const node of args.nodes.values())
193
+ for (const dep of node.deps) dependents.set(dep, [...(dependents.get(dep) ?? []), node.id])
194
+ for (const id of idleServers(args.nodes, dependents, restoreTier))
195
+ cacheStatusById.set(id, 'not-started')
196
+ }
197
+
174
198
  const tasks: PlannedTask[] = []
175
199
  for (const id of args.nodes.keys()) {
176
200
  const o = outcomes.get(id)
@@ -107,6 +107,25 @@ export function hasHook(
107
107
  return false
108
108
  }
109
109
 
110
+ /**
111
+ * An in-place stage reads nothing a hook returns: Vite's `config` hook
112
+ * returns a partial config, and one written that way here loaded, changed
113
+ * nothing and said nothing. Returning the object it was handed is harmless.
114
+ */
115
+ function assertEditedInPlace(
116
+ plugin: VxPlugin,
117
+ hook: 'config' | 'project' | 'graph',
118
+ returned: unknown,
119
+ edited: unknown,
120
+ what: string,
121
+ ): void {
122
+ if (returned === undefined || returned === edited) return
123
+ throw new UserError(
124
+ `plugin '${plugin.name}' failed in ${hook}: returned ${describeValue(returned)}, which core ` +
125
+ `ignores — edit ${what} in place`,
126
+ )
127
+ }
128
+
110
129
  /** `config` stage: every plugin edits the workspace config in place. */
111
130
  export async function applyConfigHooks(
112
131
  plugins: readonly VxPlugin[],
@@ -117,7 +136,8 @@ export async function applyConfigHooks(
117
136
  ): Promise<void> {
118
137
  for (const plugin of plugins) {
119
138
  if (plugin.config === undefined) continue
120
- await safe(plugin, 'config', () => plugin.config!(workspace, ctx))
139
+ const returned = await safe(plugin, 'config', () => plugin.config!(workspace, ctx))
140
+ assertEditedInPlace(plugin, 'config', returned, workspace, 'the workspace config')
121
141
  afterEach?.(plugin)
122
142
  }
123
143
  }
@@ -158,7 +178,8 @@ export async function applyProjectHooks(
158
178
  ): Promise<void> {
159
179
  for (const plugin of plugins) {
160
180
  if (plugin.project === undefined) continue
161
- await safe(plugin, 'project', () => plugin.project!(config, ctx))
181
+ const returned = await safe(plugin, 'project', () => plugin.project!(config, ctx))
182
+ assertEditedInPlace(plugin, 'project', returned, config, "the project's config")
162
183
  afterEach?.(plugin)
163
184
  }
164
185
  }
@@ -184,7 +205,8 @@ export async function applyGraphHooks(
184
205
  let last: VxPlugin | undefined
185
206
  for (const plugin of plugins) {
186
207
  if (plugin.graph === undefined) continue
187
- await safe(plugin, 'graph', () => plugin.graph!(nodes, ctx))
208
+ const returned = await safe(plugin, 'graph', () => plugin.graph!(nodes, ctx))
209
+ assertEditedInPlace(plugin, 'graph', returned, nodes, 'the task graph')
188
210
  await safe(plugin, 'graph', () => checkNodeShapes(nodes))
189
211
  afterEach?.(plugin)
190
212
  last = plugin
@@ -440,7 +440,8 @@ function pluginPackageName(dir: string): string {
440
440
  for (let d = dir; ;) {
441
441
  let text: string | undefined
442
442
  try {
443
- text = readFileSync(path.join(d, 'package.json'), 'utf8')
443
+ // A byte-order mark is stripped, as npm, Node and discovery strip it.
444
+ text = readFileSync(path.join(d, 'package.json'), 'utf8').replace(/^\uFEFF/, '')
444
445
  } catch {
445
446
  /* not here; look one level up */
446
447
  }
@@ -87,8 +87,12 @@ import {
87
87
  detectCi,
88
88
  } from './run-context.js'
89
89
  import { startRemotePrefetch } from './remote-prefetch.js'
90
- import { startLocalShortCircuit, type ShortCircuit } from './local-shortcircuit.js'
91
- import { probesAfterWrites } from './stable-keys.js'
90
+ import {
91
+ restoreTierExclusions,
92
+ startLocalShortCircuit,
93
+ type ShortCircuit,
94
+ } from './local-shortcircuit.js'
95
+ import { deriveStableKeys, probesAfterWrites } from './stable-keys.js'
92
96
 
93
97
  import { assembleRunRecords } from './run-records.js'
94
98
  import { hasEnded, selectKeepAlive, shutdownPersistent } from './persistent.js'
@@ -1086,8 +1090,6 @@ async function runOnBus(
1086
1090
  ).filter((c) => endedBeforeStop?.has(persistentRegistry.get(c.id)!) ?? true)
1087
1091
 
1088
1092
  mark('run graph')
1089
- // Clear the status line for good before the summary prints.
1090
- log.runEnd?.()
1091
1093
 
1092
1094
  // A server that died on its own before the stop failed, whatever its
1093
1095
  // outcome said when it became ready: the footer counted it a success
@@ -1097,8 +1099,11 @@ async function runOnBus(
1097
1099
  const failServer = (id: string, code: number | string): void => {
1098
1100
  const o = outcomes.get(id)
1099
1101
  if (o === undefined) return
1100
- const exitCode = typeof code === 'number' ? code : signalExitCode(code)
1101
- outcomes.set(id, { ...o, status: 'failed', exitCode })
1102
+ // In place: the renderer holds this outcome (events carry live refs)
1103
+ // and closes the server's output block from it at runEnd, where a
1104
+ // copy left it reading `running` under its exit line (WD-10).
1105
+ o.status = 'failed'
1106
+ o.exitCode = typeof code === 'number' ? code : signalExitCode(code)
1102
1107
  }
1103
1108
  for (const c of crashedPersistent) failServer(c.id, c.code)
1104
1109
  keepAlive.nodes.forEach((n, i) => {
@@ -1107,6 +1112,8 @@ async function runOnBus(
1107
1112
  if (endedBeforeStop !== undefined && !endedBeforeStop.has(child)) return
1108
1113
  failServer(n.id, child.exitCode ?? child.signalCode ?? 'unknown')
1109
1114
  })
1115
+ // Clear the status line for good before the summary prints.
1116
+ log.runEnd?.()
1110
1117
  const list = [...outcomes.values()]
1111
1118
  const ok = list.every((o) => isPassStatus(o.status))
1112
1119
 
@@ -1396,7 +1403,9 @@ async function runOnBus(
1396
1403
  })
1397
1404
  })
1398
1405
  const node = keepAlive.nodes[first.i]!
1399
- const others = keepAlive.nodes.length - 1
1406
+ // Only the ones still up are stopped: a kept server already dead was
1407
+ // counted as one (WD-11).
1408
+ const others = keepAlive.children.filter((c, j) => j !== first.i && !hasEnded(c)).length
1400
1409
  // Not when the run was stopped: the server ended because the user
1401
1410
  // stopped it, and "exited with code 130" read as a crash after every
1402
1411
  // Ctrl-C once a stop let run() finish its own path (item 852).
@@ -1526,12 +1535,13 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
1526
1535
  // Its own mark: a dry run's plan (every task's hash, the cache lookups,
1527
1536
  // the history p50s) was booked under `close`, the next mark, and read
1528
1537
  // as 105 ms of closing a cache at 1,000 projects (item 601).
1538
+ const policy = effectiveCachePolicy(prepared.cachePolicy, prepared.hasRemoteLayer)
1529
1539
  const planned = await plan({
1530
1540
  nodes: prepared.nodes,
1531
1541
  workspaceRoot: prepared.workspaceRoot,
1532
1542
  workspaceFingerprint: prepared.workspaceFingerprint,
1533
1543
  cache: prepared.cache,
1534
- cachePolicy: effectiveCachePolicy(prepared.cachePolicy, prepared.hasRemoteLayer),
1544
+ cachePolicy: policy,
1535
1545
  forwardArgs: options.forwardArgs,
1536
1546
  nestedDirsByProject: prepared.nestedDirsByProject,
1537
1547
  gitFilesCache: prepared.gitFilesCache,
@@ -1548,6 +1558,7 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
1548
1558
  // the same plugin-factory call `prepareRun` already makes for the
1549
1559
  // cache capability, so plan mode gains no new class of side effect.
1550
1560
  ...(await planExecutorOf(prepared, log, options.download ?? 'all')),
1561
+ ...(await planRestorable(prepared, policy, options.forwardArgs)),
1551
1562
  })
1552
1563
  mark('plan')
1553
1564
  return planned
@@ -1564,6 +1575,39 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
1564
1575
  }
1565
1576
  }
1566
1577
 
1578
+ /**
1579
+ * What the run's short-circuit would restore ahead of its deps, so the plan
1580
+ * can tell a server nobody needs from one that starts: `--dry` called a
1581
+ * server every dependant of which was a local hit `would exec`, and the run
1582
+ * never spawned it. The run's own gates (`shouldShortCircuit`, stable keys,
1583
+ * workspace-output reach), asked only when a server could idle.
1584
+ */
1585
+ async function planRestorable(
1586
+ prepared: PreparedRun,
1587
+ policy: CachePolicy,
1588
+ forwardArgs: readonly string[] | undefined,
1589
+ ): Promise<{ restorable?: Set<string> }> {
1590
+ const { nodes } = prepared
1591
+ const serves = [...nodes.values()].some(
1592
+ (n) => n.config.exec?.persistent !== undefined && !n.requested && n.surfaced !== true,
1593
+ )
1594
+ if (!serves || !shouldShortCircuit(nodes, policy, prepared.cache)) return {}
1595
+ const keptOut = restoreTierExclusions(nodes, prepared.workspaceRoot)
1596
+ const stable = await deriveStableKeys({
1597
+ nodes,
1598
+ cache: prepared.cache,
1599
+ workspaceRoot: prepared.workspaceRoot,
1600
+ workspaceFingerprint: prepared.workspaceFingerprint,
1601
+ forwardArgs,
1602
+ nestedDirsByProject: prepared.nestedDirsByProject,
1603
+ gitFilesCache: prepared.gitFilesCache,
1604
+ hashCache: prepared.hashCache,
1605
+ })
1606
+ return {
1607
+ restorable: new Set(stable.map((k) => k.node.id).filter((id) => !keptOut.has(id))),
1608
+ }
1609
+ }
1610
+
1567
1611
  /** The names a project outside the run's selection declares, and how to reach them. */
1568
1612
  function elsewhereHint(names: readonly string[]): string {
1569
1613
  if (names.length === 0) return ''
@@ -14,7 +14,7 @@
14
14
  // them to the runner around every spawn.
15
15
 
16
16
  import { signalExitCode } from '../exec/index.js'
17
- import { killGraceMs, settleWithin } from '../util/index.js'
17
+ import { claimExitForSignal, killGraceMs, settleWithin } from '../util/index.js'
18
18
  import { holdGroups, killTree, untilGroupsGone } from '../exec/index.js'
19
19
  import type { Logger } from './logger.js'
20
20
 
@@ -69,7 +69,9 @@ export async function terminateChildren(
69
69
  signal: ForwardedSignal = 'SIGTERM',
70
70
  graceMs: number = killGraceMs(SIGNAL_SHUTDOWN_GRACE_MS),
71
71
  ): Promise<void> {
72
- const children = live()
72
+ // A ready server is in both of run()'s lists: signalled twice, it read
73
+ // the second as a repeated Ctrl-C (force quit).
74
+ const children = [...new Set(live())]
73
75
  // On the group guard's list until the sweep below is done: a shell that
74
76
  // dies on the signal lets its group go while what it forked runs out
75
77
  // the grace (kill-tree.ts, `holdGroups`).
@@ -131,6 +133,7 @@ export function forwardSignals(args: {
131
133
  // that ended the run.
132
134
  if (stopping !== undefined) exit(stopping)
133
135
  stopping = signal
136
+ claimExitForSignal()
134
137
  // Clear the live worker/status region BEFORE exiting so a TTY isn't
135
138
  // left with a frozen region in the scrollback. runEnd is idempotent
136
139
  // and a no-op for non-TTY loggers.
@@ -119,6 +119,8 @@ export interface SummaryStats {
119
119
  spread: { maxMs: number; minMs: number; sumMs: number; count: number } | null
120
120
  /** Tasks an `admit` policy held with a worker free, and the waits summed. Absent with no hold. */
121
121
  held?: { count: number; sumMs: number }
122
+ /** A run of groups only: their names, so `0 tasks` says why. */
123
+ emptyGroups?: readonly string[]
122
124
  }
123
125
 
124
126
  /**
@@ -213,7 +215,14 @@ export function formatSummarySection(
213
215
  // No legend line until the first bucket lands (live: all-gray bar).
214
216
  if (taskParts.length > 0) lines.push(legend(taskParts))
215
217
  } else {
216
- lines.push(row('tasks', dim('0 tasks')))
218
+ const groups = stats.emptyGroups ?? []
219
+ // `vx run build` where no project gives build a command runs the default
220
+ // group alone: say so, or it reads like a selection that matched nothing.
221
+ const why =
222
+ groups.length > 0
223
+ ? ` ${dim('\u00b7')} ${dim(`${groups.join(', ')} ${groups.length === 1 ? 'has' : 'have'} no command in these projects`)}`
224
+ : ''
225
+ lines.push(row('tasks', dim('0 tasks') + why))
217
226
  // A stop before any task finished still names what it took down.
218
227
  if (taskParts.length > 0) lines.push(legend(taskParts))
219
228
  }
@@ -368,6 +377,9 @@ export function formatRunSummary(
368
377
  .map((o) => o.durationMs)
369
378
  const heldOutcomes = outcomes.filter((o) => o.admissionHeldMs !== undefined)
370
379
  const notRun = outcomes.filter((o) => neverStarted(o) && !isGroupTask(o.node)).length
380
+ const emptyGroups = outcomes.every((o) => isGroupTask(o.node))
381
+ ? [...new Set(outcomes.map((o) => o.node.taskName))]
382
+ : []
371
383
  return formatSummarySection(
372
384
  {
373
385
  failed: t.failed,
@@ -398,6 +410,7 @@ export function formatRunSummary(
398
410
  },
399
411
  }
400
412
  : {}),
413
+ ...(emptyGroups.length > 0 ? { emptyGroups } : {}),
401
414
  },
402
415
  totalMs,
403
416
  colors,
package/src/util/index.ts CHANGED
@@ -49,7 +49,13 @@ export {
49
49
  wholeSubtreePrefixes,
50
50
  slashBraceExpansions,
51
51
  } from './paths.js'
52
- export { killGraceMs, settleWithin, teardownTimeoutMs } from './settle.js'
52
+ export {
53
+ claimExitForSignal,
54
+ exitClaimedBySignal,
55
+ killGraceMs,
56
+ settleWithin,
57
+ teardownTimeoutMs,
58
+ } from './settle.js'
53
59
  export { formatBytes, parseDuration, parseSize } from './size.js'
54
60
  export {
55
61
  cgroupCpuQuota,
@@ -49,6 +49,13 @@ export interface SecretMask {
49
49
  * the next chunk or `end`.
50
50
  */
51
51
  stream(): { push(chunk: string): string; end(): string }
52
+ /**
53
+ * `mask` for the two sides of a cut, where the text between them is
54
+ * gone: a value the cut split leaves a piece at the end of `head` or the
55
+ * start of `tail` that no whole-value match sees, so each piece is
56
+ * masked too.
57
+ */
58
+ maskCut(head: string, tail: string): [string, string]
52
59
  }
53
60
 
54
61
  /**
@@ -103,8 +110,37 @@ export function secretMask(
103
110
  }
104
111
  return longest
105
112
  }
113
+ /** The longest head of `text` that is a proper suffix of a value. */
114
+ const partialHead = (text: string): number => {
115
+ let longest = 0
116
+ for (const v of sorted) {
117
+ for (let k = Math.min(v.length - 1, text.length); k > longest; k--) {
118
+ if (text.startsWith(v.slice(v.length - k))) {
119
+ longest = k
120
+ break
121
+ }
122
+ }
123
+ }
124
+ return longest
125
+ }
106
126
  return {
107
127
  mask,
128
+ maskCut(head, tail) {
129
+ let h = head.length - partialTail(head)
130
+ pattern.lastIndex = 0
131
+ for (let m = pattern.exec(head); m !== null; m = pattern.exec(head)) {
132
+ if (m.index < h && m.index + m[0].length > h) h = m.index
133
+ }
134
+ let t = partialHead(tail)
135
+ pattern.lastIndex = 0
136
+ for (let m = pattern.exec(tail); m !== null; m = pattern.exec(tail)) {
137
+ if (m.index < t && m.index + m[0].length > t) t = m.index + m[0].length
138
+ }
139
+ return [
140
+ mask(head.slice(0, h)) + (h < head.length ? MASKED : ''),
141
+ (t > 0 ? MASKED : '') + mask(tail.slice(t)),
142
+ ]
143
+ },
108
144
  stream() {
109
145
  let carry = ''
110
146
  return {
@@ -68,3 +68,18 @@ export async function settleWithin(p: Promise<unknown>, ms: number): Promise<boo
68
68
  void p.catch(() => {})
69
69
  }
70
70
  }
71
+
72
+ // Who ends the process. bin.ts exits once its verb has settled and the
73
+ // streams have ended; a signal's handler exits by itself, with the
74
+ // signal's code, once the run it stopped is done. The verb settles first,
75
+ // with the run's verdict (1), so bin.ts asks before it exits.
76
+ let signalOwnsExit = false
77
+
78
+ /** A signal's handler will end the process: bin.ts leaves the exit to it. */
79
+ export function claimExitForSignal(): void {
80
+ signalOwnsExit = true
81
+ }
82
+
83
+ export function exitClaimedBySignal(): boolean {
84
+ return signalOwnsExit
85
+ }
@@ -176,6 +176,14 @@ export async function affectedChanges(
176
176
  `("${since.slice(0, range) || 'HEAD'}"); vx diffs it against the working tree.`,
177
177
  )
178
178
  }
179
+ // `^main` is rev-list's exclusion, not a ref: merge-base refused it, it
180
+ // verified, and `git diff ^main` diffed from main itself, so changes
181
+ // only main made were selected (`^` is illegal in a ref name).
182
+ if (since.startsWith('^')) {
183
+ throw new UserError(
184
+ `git ref "${since}" is an exclusion, not a ref: pass the base alone ("${since.replace(/^\^+/, '') || 'HEAD'}").`,
185
+ )
186
+ }
179
187
  // Diff from the MERGE BASE of `since` and HEAD, not from `since` itself:
180
188
  // on a branch whose base has moved on, `git diff <base>` reports every
181
189
  // file OTHER people changed on the base (over-selection that defeats a
@@ -432,7 +440,17 @@ async function dependentsAtBase(
432
440
  ) {
433
441
  continue
434
442
  }
435
- byDir.set(dir, { name: pkg.name, dir, packageJson: pkg, configPath: null })
443
+ // Today's catalogs, or both graphs would differ on every `catalog:`
444
+ // entry. A catalog edit is a `pnpm-workspace.yaml` or root manifest
445
+ // edit, which the fingerprint widening and the root's own change answer.
446
+ const catalogs = projects[0]?.catalogs
447
+ byDir.set(dir, {
448
+ name: pkg.name,
449
+ dir,
450
+ packageJson: pkg,
451
+ configPath: null,
452
+ ...(catalogs === undefined ? {} : { catalogs }),
453
+ })
436
454
  if (nameNow.get(dir) !== pkg.name) gone.add(pkg.name)
437
455
  }
438
456
  const now = buildPackageGraph([...projects])
@@ -468,8 +486,10 @@ function parentsOfNewNested(
468
486
  projects: readonly ProjectMeta[],
469
487
  ): Set<string> {
470
488
  const out = new Set<string>()
489
+ // Keyed NFC: macOS git reports paths NFC (core.precomposeunicode) while a
490
+ // dir discovered by readdir keeps the spelling it was created with.
471
491
  const dirToName = new Map<string, string>()
472
- for (const p of projects) dirToName.set(p.dir, p.name)
492
+ for (const p of projects) dirToName.set(p.dir.normalize('NFC'), p.name)
473
493
  for (const [rel, bytes] of atBase) {
474
494
  const dir = path.resolve(workspaceRoot, path.posix.dirname(rel))
475
495
  if (!dirToName.has(dir)) continue
@@ -829,8 +849,10 @@ function projectsContaining(
829
849
  // O(files · path-depth) instead of O(files · projects): independent of the
830
850
  // project count, which is what a big --affected diff on a 1000-project repo
831
851
  // pays for.
852
+ // Keyed NFC: macOS git reports paths NFC (core.precomposeunicode) while a
853
+ // dir discovered by readdir keeps the spelling it was created with.
832
854
  const dirToName = new Map<string, string>()
833
- for (const p of projects) dirToName.set(p.dir, p.name)
855
+ for (const p of projects) dirToName.set(p.dir.normalize('NFC'), p.name)
834
856
  // A member linked in from elsewhere in the tree (`pkgs/b -> ../ext/b`) is
835
857
  // indexed by its link, and git reports its files at their real place
836
858
  // (`ext/b/src/a.txt`), which resolved to no project: an edit there
@@ -841,18 +863,18 @@ function projectsContaining(
841
863
  const real = realDirs.get(p.dir) ?? p.dir
842
864
  const rel = path.relative(realRoot, real)
843
865
  if (rel.startsWith('..') || path.isAbsolute(rel)) continue
844
- const spelled = path.resolve(workspaceRoot, rel)
845
- if (spelled !== p.dir && !dirToName.has(spelled)) dirToName.set(spelled, p.name)
866
+ const spelled = path.resolve(workspaceRoot, rel).normalize('NFC')
867
+ if (!dirToName.has(spelled)) dirToName.set(spelled, p.name)
846
868
  }
847
869
  const owned = new Set<string>()
848
870
  for (const rel of changedRelPaths) {
849
- let dir = path.resolve(workspaceRoot, rel)
871
+ let dir = path.resolve(workspaceRoot, rel).normalize('NFC')
850
872
  let hit = false
851
873
  for (;;) {
852
874
  const name = dirToName.get(dir)
853
875
  if (name !== undefined) {
854
876
  owned.add(name)
855
- on?.path(name, relPosix(dir, path.resolve(workspaceRoot, rel)))
877
+ on?.path(name, relPosix(dir, path.resolve(workspaceRoot, rel).normalize('NFC')))
856
878
  hit = true
857
879
  break
858
880
  }
@@ -871,7 +893,7 @@ function projectsContaining(
871
893
  // this, `--affected` after an edit inside selected none of them.
872
894
  const abs = path.resolve(workspaceRoot, rel)
873
895
  if (isDirectory(abs)) {
874
- const prefix = abs + path.sep
896
+ const prefix = abs.normalize('NFC') + path.sep
875
897
  for (const [dir, name] of dirToName) {
876
898
  if (!dir.startsWith(prefix)) continue
877
899
  owned.add(name)
@@ -409,6 +409,16 @@ function keySeed(workspaceFingerprint: string): bigint {
409
409
 
410
410
  const EXPLICIT_EXT = /\.(?:m?[jt]s|cjs|cts)$/
411
411
 
412
+ /**
413
+ * A file's identity as the key folds it. `hashFile` names an executable
414
+ * `100755:<oid>`, the slow path's `hashBytes` the bare oid; the mode moves
415
+ * no evaluation, and with it the warm key of an executable config never
416
+ * met the stored one, so every load read and re-keyed it.
417
+ */
418
+ function contentIdentity(identity: string): string {
419
+ return identity.startsWith('100755:') ? identity.slice(7) : identity
420
+ }
421
+
412
422
  /**
413
423
  * The canonical directory Bun resolves `file`'s imports from: its REAL
414
424
  * path's, so a config linked in from elsewhere imports its neighbours
@@ -482,7 +492,7 @@ export async function configEvalKey(a: ConfigEvalKeyArgs): Promise<ConfigEvalKey
482
492
  const identity = a.hashBytes
483
493
  ? a.hashBytes(bytes, file)
484
494
  : a.hashFile
485
- ? await a.hashFile(file)
495
+ ? contentIdentity(await a.hashFile(file))
486
496
  : await hashOf(file, bytes)
487
497
  h = xxh3(`${file}\0${identity}`, h)
488
498
  closure.push(file)
@@ -542,6 +552,14 @@ export async function configEvalKey(a: ConfigEvalKeyArgs): Promise<ConfigEvalKey
542
552
  return { key: h.toString(16).padStart(16, '0'), closure, indexable }
543
553
  }
544
554
 
555
+ // `hashFile` names a symlink by its target string (git's mode 120000), not
556
+ // the bytes the evaluation read, so no stored key can match a fold of it.
557
+ // Only a linked config with no relative import is indexed at all; with no
558
+ // warm key it joins the round lookup on its slow key instead of paying a
559
+ // single lookup and an index rewrite per load. The identity is from the
560
+ // lstat `hashFiles` already took: no syscall is added.
561
+ const LINK_IDENTITY = '120000:'
562
+
545
563
  /**
546
564
  * The warm path: the key for a config whose ordered closure the store
547
565
  * remembers, from per-file identities alone — no read, no scan. The fold is
@@ -561,7 +579,10 @@ export async function configEvalKeyFromClosure(a: {
561
579
  } catch {
562
580
  return null
563
581
  }
564
- for (let i = 0; i < a.closure.length; i++) h = xxh3(`${a.closure[i]}\0${identities[i]}`, h)
582
+ if (identities.some((id) => id.startsWith(LINK_IDENTITY))) return null
583
+ for (let i = 0; i < a.closure.length; i++) {
584
+ h = xxh3(`${a.closure[i]}\0${contentIdentity(identities[i]!)}`, h)
585
+ }
565
586
  return h.toString(16).padStart(16, '0')
566
587
  }
567
588
 
@@ -574,8 +595,8 @@ export function configEvalKeyFromIdentities(a: {
574
595
  let h = keySeed(a.workspaceFingerprint)
575
596
  for (const file of a.closure) {
576
597
  const id = a.identities.get(file)
577
- if (id === undefined) return null
578
- h = xxh3(`${file}\0${id}`, h)
598
+ if (id === undefined || id.startsWith(LINK_IDENTITY)) return null
599
+ h = xxh3(`${file}\0${contentIdentity(id)}`, h)
579
600
  }
580
601
  return h.toString(16).padStart(16, '0')
581
602
  }
@@ -305,7 +305,7 @@ function selfReference(pkg: string, fromDir: string): boolean {
305
305
  const manifest = path.join(dir, 'package.json')
306
306
  if (existsSync(manifest)) {
307
307
  try {
308
- const json = JSON.parse(readFileSync(manifest, 'utf8')) as {
308
+ const json = JSON.parse(readFileSync(manifest, 'utf8').replace(/^\uFEFF/, '')) as {
309
309
  name?: unknown
310
310
  exports?: unknown
311
311
  }