@vzn/vx 0.0.16 → 0.0.21

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 (138) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +135 -124
  3. package/index.ts +10 -0
  4. package/launcher.mjs +10 -11
  5. package/package.json +11 -7
  6. package/src/bin.ts +14 -4
  7. package/src/cache/archive.ts +659 -0
  8. package/src/cache/cache.ts +769 -1101
  9. package/src/cache/chained-cache.ts +194 -0
  10. package/src/cache/config-evals.ts +89 -0
  11. package/src/cache/file-hashes.ts +262 -0
  12. package/src/cache/git-inputs.ts +637 -0
  13. package/src/cache/index.ts +24 -5
  14. package/src/cache/inputs.ts +373 -456
  15. package/src/cache/layer.ts +604 -0
  16. package/src/cache/layered-cache.ts +96 -43
  17. package/src/cache/output-index.ts +224 -0
  18. package/src/cache/policy.ts +84 -0
  19. package/src/cache/run-history.ts +147 -0
  20. package/src/cache/tar-stream.ts +358 -0
  21. package/src/cache/zstd.ts +160 -0
  22. package/src/cli/cache.ts +75 -21
  23. package/src/cli/completions.ts +104 -0
  24. package/src/cli/core-alias.ts +25 -0
  25. package/src/cli/help.ts +195 -128
  26. package/src/cli/index.ts +116 -46
  27. package/src/cli/info.ts +122 -63
  28. package/src/cli/init.ts +75 -0
  29. package/src/cli/last.ts +172 -0
  30. package/src/cli/lock.ts +2 -1
  31. package/src/cli/plan-format.ts +72 -6
  32. package/src/cli/plugin-commands.ts +103 -0
  33. package/src/cli/run.ts +245 -271
  34. package/src/cli/select.ts +332 -0
  35. package/src/cli/show.ts +144 -69
  36. package/src/cli/upgrade.ts +9 -7
  37. package/src/cli/watch.ts +858 -110
  38. package/src/cli/why.ts +223 -0
  39. package/src/cli/workspace-config.ts +126 -0
  40. package/src/config.ts +187 -150
  41. package/src/exec/env.ts +18 -1
  42. package/src/exec/executor.ts +224 -0
  43. package/src/exec/index.ts +24 -2
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +166 -40
  46. package/src/exec/sandbox-binds.ts +182 -0
  47. package/src/exec/sandbox-paths.ts +50 -0
  48. package/src/exec/sandbox-runtime.ts +642 -260
  49. package/src/exec/sandbox-violations.ts +280 -0
  50. package/src/graph/index.ts +4 -10
  51. package/src/graph/scheduler.ts +232 -203
  52. package/src/graph/task-graph.ts +222 -8
  53. package/src/index.ts +146 -153
  54. package/src/orchestrator/admission.ts +137 -0
  55. package/src/orchestrator/deferred-outputs.ts +147 -0
  56. package/src/orchestrator/doctor.ts +255 -0
  57. package/src/orchestrator/download-policy.ts +171 -0
  58. package/src/orchestrator/events.ts +72 -8
  59. package/src/orchestrator/execute-task.ts +374 -549
  60. package/src/orchestrator/failure-mode.ts +202 -0
  61. package/src/orchestrator/framed-output.ts +126 -69
  62. package/src/orchestrator/history.ts +126 -59
  63. package/src/orchestrator/hit-restore.ts +221 -0
  64. package/src/orchestrator/index.ts +77 -93
  65. package/src/orchestrator/local-shortcircuit.ts +22 -0
  66. package/src/orchestrator/lockfile-claim.ts +281 -0
  67. package/src/orchestrator/logger.ts +208 -35
  68. package/src/orchestrator/metrics.ts +102 -1388
  69. package/src/orchestrator/miss-save.ts +169 -0
  70. package/src/orchestrator/options.ts +55 -34
  71. package/src/orchestrator/persistent.ts +79 -0
  72. package/src/orchestrator/placement.ts +182 -0
  73. package/src/orchestrator/plan.ts +144 -1
  74. package/src/orchestrator/plugin-host.ts +357 -120
  75. package/src/orchestrator/plugin.ts +298 -58
  76. package/src/orchestrator/prepare.ts +202 -106
  77. package/src/orchestrator/projects.ts +259 -0
  78. package/src/orchestrator/remote-prefetch.ts +12 -8
  79. package/src/orchestrator/run-artifacts.ts +63 -16
  80. package/src/orchestrator/run-context.ts +167 -29
  81. package/src/orchestrator/run-records.ts +149 -0
  82. package/src/orchestrator/run-report.ts +74 -47
  83. package/src/orchestrator/run.ts +534 -417
  84. package/src/orchestrator/sandbox-request.ts +243 -0
  85. package/src/orchestrator/save-lane.ts +47 -0
  86. package/src/orchestrator/signals.ts +111 -0
  87. package/src/orchestrator/stable-keys.ts +129 -31
  88. package/src/orchestrator/status-line.ts +49 -9
  89. package/src/orchestrator/summary.ts +90 -26
  90. package/src/orchestrator/tally.ts +78 -35
  91. package/src/orchestrator/task-hash.ts +111 -15
  92. package/src/orchestrator/task-log-buffer.ts +288 -0
  93. package/src/orchestrator/telemetry-host.ts +34 -6
  94. package/src/orchestrator/telemetry.ts +182 -36
  95. package/src/orchestrator/upstream.ts +72 -31
  96. package/src/util/cgroup.ts +160 -0
  97. package/src/util/edit-distance.ts +70 -0
  98. package/src/util/errors.ts +15 -1
  99. package/src/util/index.ts +15 -2
  100. package/src/util/num.ts +37 -0
  101. package/src/util/paths.ts +64 -0
  102. package/src/util/settle.ts +67 -0
  103. package/src/util/size.ts +5 -1
  104. package/src/util/tail.ts +66 -0
  105. package/src/util/timing.ts +63 -0
  106. package/src/util/verbs.ts +39 -0
  107. package/src/workspace/affected.ts +250 -23
  108. package/src/workspace/config-cache.ts +313 -0
  109. package/src/workspace/config-eval.ts +217 -0
  110. package/src/workspace/config-imports.ts +162 -0
  111. package/src/workspace/config-schema.ts +837 -0
  112. package/src/workspace/filter.ts +65 -10
  113. package/src/workspace/fingerprint.ts +53 -4
  114. package/src/workspace/index.ts +42 -3
  115. package/src/workspace/lockfile.ts +25 -9
  116. package/src/workspace/migrate-scripts.ts +134 -0
  117. package/src/workspace/migration.ts +314 -0
  118. package/src/workspace/nested-dirs.ts +21 -7
  119. package/src/workspace/package-graph.ts +114 -41
  120. package/src/workspace/project-loader.ts +224 -450
  121. package/src/workspace/workspace.ts +298 -72
  122. package/src/cache/cas-backend.ts +0 -97
  123. package/src/cache/digest.ts +0 -47
  124. package/src/cache/tar.ts +0 -343
  125. package/src/cli/backend.ts +0 -23
  126. package/src/cli/mcp-rpc.ts +0 -292
  127. package/src/cli/mcp.ts +0 -92
  128. package/src/cli/migrate-nx.ts +0 -450
  129. package/src/cli/migrate-turbo.ts +0 -364
  130. package/src/cli/migrate.ts +0 -219
  131. package/src/orchestrator/devframe-surface.ts +0 -74
  132. package/src/orchestrator/predict.ts +0 -106
  133. package/src/orchestrator/protocol.ts +0 -156
  134. package/src/orchestrator/resources.ts +0 -59
  135. package/src/orchestrator/run-state.ts +0 -145
  136. package/src/orchestrator/verify.ts +0 -164
  137. package/src/orchestrator/wire-render.ts +0 -92
  138. package/src/orchestrator/wire.ts +0 -212
@@ -3,6 +3,13 @@
3
3
 
4
4
  export { run, planRun } from './run.js'
5
5
  export { prepareRun, type PreparedRun } from './prepare.js'
6
+ export {
7
+ loadProjects,
8
+ loadResolvedProjects,
9
+ loadWorkspacePlugins,
10
+ type LoadedProjects,
11
+ type LoadProjectsArgs,
12
+ } from './projects.js'
6
13
  export { computeTaskHash, createHashCache, type HashCache } from './task-hash.js'
7
14
  export type { RunOptions, RunSummary } from './options.js'
8
15
  // Re-surface the cache policy contract (defined in the cache module) so
@@ -11,13 +18,16 @@ export type { RunOptions, RunSummary } from './options.js'
11
18
  export { type CachePolicy, FULL_CACHE_POLICY, parseCachePolicy } from '../cache/index.js'
12
19
  export { defaultLogger, resolveOutputView } from './logger.js'
13
20
  export type { Logger, OutputView } from './logger.js'
14
- export type { RunPlan, PlannedTask, CacheStatus } from './plan.js'
21
+ export type { RunPlan, PlannedTask, PlanPrediction, CacheStatus } from './plan.js'
22
+ export { formatDuration } from './summary.js'
15
23
  export {
16
24
  createEventBus,
17
25
  wireForwarder,
18
26
  toWireEvent,
19
27
  projectNode,
20
28
  projectOutcome,
29
+ outcomeWord,
30
+ outcomeLabel,
21
31
  } from './events.js'
22
32
  export type {
23
33
  EventBus,
@@ -27,39 +37,7 @@ export type {
27
37
  TaskView,
28
38
  OutcomeView,
29
39
  } from './events.js'
30
- export { createVxSurface } from './devframe-surface.js'
31
- export { createWireRenderer } from './wire-render.js'
32
- export { optionsToRequest, requestToOptions } from './protocol.js'
33
- export type { ClientMessage, RunBackend, RunRequest, RunResult, ServerMessage } from './protocol.js'
34
- export { formatRunReportMarkdown } from './run-report.js'
35
- export {
36
- clientMessageToEnvelope,
37
- decodeEnvelope,
38
- encodeForNDJSON,
39
- encodeForSSE,
40
- encodeForWS,
41
- ENVELOPE_ERRORS,
42
- envelopeToClientMessage,
43
- envelopeToServerMessage,
44
- isEnvelope,
45
- isNotification,
46
- isRequest,
47
- makeError,
48
- makeNotification,
49
- makeRequest,
50
- makeResponse,
51
- serverMessageToEnvelope,
52
- WIRE_CHANNELS,
53
- WIRE_PROTOCOL_VERSION,
54
- } from './wire.js'
55
- export type {
56
- Envelope,
57
- ErrorResponse,
58
- Notification,
59
- Request as WireRequest,
60
- Response as WireResponse,
61
- WireChannel,
62
- } from './wire.js'
40
+ export { escapeMarkdownCell, formatRunReportMarkdown, type RunResult } from './run-report.js'
63
41
  export {
64
42
  EmptyHistoryProvider,
65
43
  type HistoryProvider,
@@ -67,24 +45,74 @@ export {
67
45
  LocalHistoryProvider,
68
46
  type TaskHistory,
69
47
  } from './history.js'
70
- export { computePredictedPriorities } from './predict.js'
48
+ export {
49
+ detectFlaky,
50
+ type FailureMode,
51
+ type FlakyCandidate,
52
+ type FlakyFinding,
53
+ type FlakyTask,
54
+ flakyTasks,
55
+ } from './failure-mode.js'
71
56
  export {
72
57
  installPlugins,
73
- type BackendContext,
74
58
  type CacheContext,
75
- type EventSink,
76
- type EventSinkContext,
59
+ type ExecutorContext,
60
+ type CommandContext,
61
+ type GraphHookContext,
62
+ type KeyHookContext,
63
+ type PluginCommand,
64
+ type ProjectHookContext,
65
+ type ScheduleHookContext,
66
+ type AdmitContext,
67
+ type WorkspaceHookContext,
77
68
  type InstallPluginsArgs,
78
69
  type Plugin,
79
70
  type PluginContext,
80
71
  type PluginHookHandlers,
81
72
  type PluginHookName,
82
73
  type PluginSetupContext,
74
+ type FingerprintChange,
75
+ type FingerprintClaim,
76
+ type FingerprintContext,
83
77
  type VxPlugin,
78
+ definePlugin,
79
+ type PluginHooks,
80
+ type PluginOrigin,
84
81
  } from './plugin.js'
85
- export { resolveBackend, resolveCache, subscribeEventSinks } from './plugin-host.js'
82
+ export {
83
+ applyConfigHooks,
84
+ applyGraphHooks,
85
+ applyKeyHooks,
86
+ applyProjectHooks,
87
+ applyScheduleHooks,
88
+ claimedAffected,
89
+ fingerprintClaims,
90
+ hasHook,
91
+ CACHE_LAYER_METHODS,
92
+ resolveCache,
93
+ resolveExecutors,
94
+ } from './plugin-host.js'
95
+ export {
96
+ lockfileClaim,
97
+ reachDigests,
98
+ type LockfileClaimHooks,
99
+ type LockfileClaimOptions,
100
+ type ReachGraph,
101
+ } from './lockfile-claim.js'
86
102
  export { subscribeTelemetry, type TelemetryHandle } from './telemetry-host.js'
87
- export { createTelemetrySource, deriveCacheSource, TELEMETRY_SCHEMA_VERSION } from './telemetry.js'
103
+ // The bounded log-capture buffer every telemetry sink shares — see the
104
+ // module header for why one implementation, not one per sink.
105
+ export { LOG_WIRE_VERSION, TaskLogBuffer } from './task-log-buffer.js'
106
+ export type { TaskLogBundle, TaskLogEntry } from './task-log-buffer.js'
107
+ export {
108
+ assembleRunSummary,
109
+ createTelemetrySource,
110
+ deriveCacheSource,
111
+ isCacheHit,
112
+ isPassStatus,
113
+ TASK_STATUSES,
114
+ TELEMETRY_SCHEMA_VERSION,
115
+ } from './telemetry.js'
88
116
  export type {
89
117
  CacheSource,
90
118
  RunContextRecord,
@@ -95,80 +123,36 @@ export type {
95
123
  TelemetrySink,
96
124
  TelemetrySource,
97
125
  } from './telemetry.js'
98
- // `diffOutputTrees` is the ONE tree-diff implementation — the verify verdict
99
- // and a serve's cross-machine fingerprint diff must never drift on it.
100
- export { diffOutputTrees } from './verify.js'
101
126
  export { deriveStableKeys } from './stable-keys.js'
102
127
  export type { DeriveStableKeysArgs, StableKey } from './stable-keys.js'
103
- export { captureGitContext, captureWorkspaceIdentity, resolveCacheScope } from './run-context.js'
104
- export type { GitContext, WorkspaceIdentity } from './run-context.js'
128
+ export {
129
+ captureDefaultBranch,
130
+ captureGitContext,
131
+ captureHostContext,
132
+ captureWorkspaceIdentity,
133
+ detectCi,
134
+ } from './run-context.js'
135
+ export type { CiContext, GitContext, HostContext, WorkspaceIdentity } from './run-context.js'
105
136
  export {
106
137
  cacheKeyDiff,
107
- compareRuns,
108
138
  explainCacheKey as explainCacheKeyQuery,
109
- getBottlenecks,
110
- getCacheBreakdown,
111
- getCacheSavings,
112
- getCacheStatsSql,
113
- getFlakiestTasks,
114
- getHistory,
115
- getHitRateSplit,
116
139
  getInvocation,
117
- getParallelismHistory,
118
- getPeriodComparison,
119
- getPrunableEntries,
120
- getRecentFailures,
121
- getRegressions,
122
140
  getRun,
123
- getRunHeatmap,
124
- getRunTrends,
125
- getStorageGrowth,
126
- getTaskDetail,
127
- getTopTimeBurners,
128
- listCacheEntries,
129
141
  listInvocations,
130
- listProjects,
131
142
  listRuns,
132
143
  whyDidThisRerun as whyDidThisRerunQuery,
133
144
  } from './metrics.js'
134
145
  export type {
135
- BottleneckRow,
136
146
  CacheEntryRow,
137
147
  CacheKeyDiff,
138
148
  CacheKeyExplanation,
139
- CacheProjectRow,
140
- CacheSavings,
141
- CacheStatsResult,
142
- CompareRuns,
143
- CompareTaskRow,
144
- CompareTaskSide,
145
- FailureRow,
146
- FlakyTask,
147
- GetHistoryArgs,
148
- HeatmapCell,
149
- HitRateSplit,
150
149
  InputDiffEntry,
151
150
  InvocationDetail,
152
151
  InvocationRow,
153
- ListCacheEntriesArgs,
154
152
  ListInvocationsArgs,
155
153
  ListRunsArgs,
156
- ParallelismPoint,
157
- PeriodComparison,
158
- PeriodComparisonArgs,
159
- PeriodStats,
160
- PrunableEntry,
161
- ProjectRollup,
162
- RegressedTask,
163
- RegressionArgs,
164
- TaskMover,
165
154
  RunDetail,
166
155
  RunSummaryRow,
167
- StoragePoint,
168
- TaskDetail,
169
- TaskHistoryRow,
170
- TopTaskRow,
171
- TrendBucket,
172
- TrendPoint,
173
156
  WhyDidThisRerun,
174
157
  } from './metrics.js'
158
+ export { collectInfo, type CollectInfoOptions, type InfoFacts } from './doctor.js'
@@ -38,6 +38,7 @@
38
38
  // conservative exclusion). Those tasks still get an exec-tier probe
39
39
  // reuse entry so there's no double work.
40
40
 
41
+ import { span } from '../util/index.js'
41
42
  import type { CacheEntry, CacheLayer, GitFilesCache } from '../cache/index.js'
42
43
  import type { TaskNode } from '../graph/index.js'
43
44
  import { deriveStableKeys } from './stable-keys.js'
@@ -95,10 +96,13 @@ const EMPTY: ShortCircuit = { preProbed: new Map(), restoreTier: new Set() }
95
96
  */
96
97
  export async function startLocalShortCircuit(args: ShortCircuitArgs): Promise<ShortCircuit> {
97
98
  let stableKeys
99
+ const endKeys = span('stable keys')
98
100
  try {
99
101
  stableKeys = await deriveStableKeys(args)
100
102
  } catch {
101
103
  return EMPTY
104
+ } finally {
105
+ endKeys()
102
106
  }
103
107
  const candidates = stableKeys.filter(({ node }) => node.config.cache !== undefined)
104
108
  if (candidates.length === 0) return EMPTY
@@ -125,6 +129,24 @@ export async function startLocalShortCircuit(args: ShortCircuitArgs): Promise<Sh
125
129
  // it); a miss stays exec-tier with a known-miss reuse entry. The pool
126
130
  // keeps the up-front pass from being a serial pre-phase on a wide warm
127
131
  // graph — the probes parallelize at the run's concurrency.
132
+ // A layer with a batched `get` answers every probe in a couple of
133
+ // queries; the pool below is for layers without one.
134
+ if (args.cache.getMany !== undefined) {
135
+ try {
136
+ const endProbe = span('probe')
137
+ const hits = await args.cache.getMany(candidates.map((c) => c.hash))
138
+ endProbe()
139
+ for (const { hash, node } of candidates) {
140
+ const hit = hits.get(hash) ?? null
141
+ preProbed.set(node.id, { hash, hit })
142
+ if (hit !== null && !anyWorkspaceOutputs) restoreTier.add(node.id)
143
+ }
144
+ return { preProbed, restoreTier }
145
+ } catch {
146
+ // Fall through to the per-hash pool, which isolates a failing probe
147
+ // to its own task.
148
+ }
149
+ }
128
150
  let next = 0
129
151
  const workers = Math.max(1, Math.min(args.concurrency, candidates.length))
130
152
  const pump = async (): Promise<void> => {
@@ -0,0 +1,281 @@
1
+ // The claimant's shell: what every lockfile plugin needs around its
2
+ // parser. A plugin that keys each project on its own dependency closure
3
+ // from a lockfile (`@vzn/vx-lockfile`) claims the file
4
+ // (`VxPlugin.fingerprint`), folds one digest per project through `key`,
5
+ // and answers `--affected` by digesting both sides of a change. Only the
6
+ // parser differs per package manager; the memo, the per-run gate, the
7
+ // fallback for a project the file does not list and the `--affected`
8
+ // diff are one implementation here, so a third lockfile is a parser.
9
+ //
10
+ // The digests are memoised on disk under the cache dir by the file's
11
+ // xxh3, so a warm run pays one read + hash + a small JSON read, never a
12
+ // parse; within one process (a run, a watch cycle) the file's size and
13
+ // mtime gate even the read; and per run the read happens once (a WeakMap
14
+ // on the context core hands every `key` call of a run).
15
+
16
+ import path from 'node:path'
17
+ import { rename, stat } from 'node:fs/promises'
18
+ import { xxh3hex } from '../util/index.js'
19
+ import type { TaskNode } from '../graph/index.js'
20
+ import type {
21
+ FingerprintChange,
22
+ FingerprintClaim,
23
+ FingerprintContext,
24
+ KeyHookContext,
25
+ } from './plugin.js'
26
+
27
+ export interface LockfileClaimOptions {
28
+ /** The root-relative lockfile name, one of `WORKSPACE_FINGERPRINT_FILES`. */
29
+ readonly file: string
30
+ /**
31
+ * Every project's digest from the file's text: importer directory
32
+ * (root-relative POSIX, `.` for the root) → digest. Called once per
33
+ * distinct content; must be deterministic and throw on a file it
34
+ * cannot read (the plugin's name heads the error).
35
+ */
36
+ readonly digest: (text: string) => ReadonlyMap<string, string>
37
+ /** Folded into the memo's identity: bump when `digest` folds differently. */
38
+ readonly version: number
39
+ /**
40
+ * `'project'` (default): each task folds its own project's digest.
41
+ * `'workspace'`: the whole file's hash into every task — the coarse key
42
+ * core would fold, through the plugin.
43
+ */
44
+ readonly scope?: 'project' | 'workspace'
45
+ /**
46
+ * The key part's name, as `vx why` shows it under the plugin's name
47
+ * (`@vzn/vx-lockfile/pnpm`). Default `'deps'`.
48
+ */
49
+ readonly part?: string
50
+ }
51
+
52
+ /** The two hooks a lockfile plugin spreads into `definePlugin`. */
53
+ export interface LockfileClaimHooks {
54
+ readonly fingerprint: FingerprintClaim
55
+ key(task: TaskNode, ctx: KeyHookContext): Promise<Readonly<Record<string, string>> | undefined>
56
+ }
57
+
58
+ interface Digests {
59
+ /** xxh3 of the lockfile bytes; '' when the file is absent. */
60
+ readonly lock: string
61
+ readonly importers: ReadonlyMap<string, string>
62
+ }
63
+
64
+ interface Memo {
65
+ version: number
66
+ lock: string
67
+ importers: Record<string, string>
68
+ }
69
+
70
+ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks {
71
+ const { file, digest, version } = options
72
+ const scope = options.scope ?? 'project'
73
+ const part = options.part ?? 'deps'
74
+ if (scope !== 'project' && scope !== 'workspace') {
75
+ throw new Error(`scope must be 'project' or 'workspace', not ${JSON.stringify(scope)}`)
76
+ }
77
+ // Per workspace root: what the last read saw. `size` + `mtimeMs` gate a
78
+ // re-read; the content hash decides whether the digests are current.
79
+ const seen = new Map<string, { size: number; mtimeMs: number; digests: Digests }>()
80
+ // Per run: every `key` call of one run gets the same context object.
81
+ const perRun = new WeakMap<object, Promise<Digests>>()
82
+
83
+ const load = async (workspaceRoot: string, cacheDir: string): Promise<Digests> => {
84
+ const full = path.join(workspaceRoot, file)
85
+ let st: { size: number; mtimeMs: number }
86
+ try {
87
+ st = await stat(full)
88
+ } catch {
89
+ const digests: Digests = { lock: '', importers: new Map() }
90
+ seen.set(workspaceRoot, { size: -1, mtimeMs: -1, digests })
91
+ return digests
92
+ }
93
+ const last = seen.get(workspaceRoot)
94
+ if (last !== undefined && last.size === st.size && last.mtimeMs === st.mtimeMs) {
95
+ return last.digests
96
+ }
97
+ const bytes = await Bun.file(full).bytes()
98
+ const lock = xxh3hex(bytes)
99
+ if (last !== undefined && last.digests.lock === lock) {
100
+ seen.set(workspaceRoot, { size: st.size, mtimeMs: st.mtimeMs, digests: last.digests })
101
+ return last.digests
102
+ }
103
+ const memoFile = path.join(cacheDir, 'lockfile-claims', `${file}.json`)
104
+ const importers =
105
+ scope === 'workspace'
106
+ ? new Map<string, string>()
107
+ : ((await readMemo(memoFile, version, lock)) ??
108
+ (await computeAndMemo(memoFile, version, lock, digest(decode(bytes)))))
109
+ const digests: Digests = { lock, importers }
110
+ seen.set(workspaceRoot, { size: st.size, mtimeMs: st.mtimeMs, digests })
111
+ return digests
112
+ }
113
+ const loadOnce = (ctx: KeyHookContext): Promise<Digests> => {
114
+ let p = perRun.get(ctx)
115
+ if (p === undefined) {
116
+ p = load(ctx.workspaceRoot, ctx.cacheDir)
117
+ perRun.set(ctx, p)
118
+ }
119
+ return p
120
+ }
121
+
122
+ return {
123
+ fingerprint: {
124
+ files: [file],
125
+ affected(change: FingerprintChange, ctx: FingerprintContext) {
126
+ // One side absent: the file appeared or went — every project's
127
+ // installed tree is in question, and core's answer (all) is right.
128
+ if (change.before === null || change.after === null) return undefined
129
+ if (scope === 'workspace') return undefined
130
+ const before = digest(decode(change.before))
131
+ const after = digest(decode(change.after))
132
+ const names: string[] = []
133
+ for (const p of ctx.projects) {
134
+ const importer = importerOf(ctx.workspaceRoot, p.dir)
135
+ if (digestFor(before, importer) !== digestFor(after, importer)) names.push(p.name)
136
+ }
137
+ return names
138
+ },
139
+ },
140
+ async key(task, ctx) {
141
+ const digests = await loadOnce(ctx)
142
+ if (digests.lock === '') return undefined
143
+ if (scope === 'workspace') return { [part]: digests.lock }
144
+ return {
145
+ [part]: digestFor(digests.importers, importerOf(ctx.workspaceRoot, task.projectDir)),
146
+ }
147
+ },
148
+ }
149
+ }
150
+
151
+ /**
152
+ * A project's digest: its own importer's, or — for a project the lockfile
153
+ * has no importer for (outside the workspace's `packages`) — the root
154
+ * importer's, the only installed tree it can resolve from. A file with
155
+ * neither folds a constant.
156
+ */
157
+ function digestFor(importers: ReadonlyMap<string, string>, importer: string): string {
158
+ return importers.get(importer) ?? importers.get('.') ?? 'no-importer'
159
+ }
160
+
161
+ /** The lockfile's importer path for a project directory: `.` for the root, POSIX otherwise. */
162
+ function importerOf(workspaceRoot: string, projectDir: string): string {
163
+ const rel = path.relative(workspaceRoot, projectDir)
164
+ return rel === '' ? '.' : rel.split(path.sep).join('/')
165
+ }
166
+
167
+ async function readMemo(
168
+ memoFile: string,
169
+ version: number,
170
+ lock: string,
171
+ ): Promise<Map<string, string> | undefined> {
172
+ try {
173
+ const memo = (await Bun.file(memoFile).json()) as Memo
174
+ if (memo.version !== version || memo.lock !== lock) return undefined
175
+ return new Map(Object.entries(memo.importers))
176
+ } catch {
177
+ return undefined
178
+ }
179
+ }
180
+
181
+ async function computeAndMemo(
182
+ memoFile: string,
183
+ version: number,
184
+ lock: string,
185
+ importers: ReadonlyMap<string, string>,
186
+ ): Promise<ReadonlyMap<string, string>> {
187
+ const memo: Memo = { version, lock, importers: Object.fromEntries(importers) }
188
+ // Write-then-rename: a reader never sees a half-written memo, and two
189
+ // concurrent runs each land a whole one.
190
+ const tmp = `${memoFile}.tmp-${process.pid}-${Date.now()}`
191
+ try {
192
+ await Bun.write(tmp, JSON.stringify(memo))
193
+ await rename(tmp, memoFile)
194
+ } catch {
195
+ // The memo is a speed-up, never a requirement: the digests are
196
+ // computed either way, and the next run computes them again.
197
+ }
198
+ return importers
199
+ }
200
+
201
+ function decode(bytes: Uint8Array): string {
202
+ return new TextDecoder().decode(bytes)
203
+ }
204
+
205
+ // --- Reach digests -----------------------------------------------------------
206
+
207
+ /** A dependency graph: one material string per node and its out-edges by index. */
208
+ export interface ReachGraph {
209
+ readonly material: readonly string[]
210
+ readonly edges: ReadonlyArray<readonly number[]>
211
+ }
212
+
213
+ /**
214
+ * One digest per node over everything the node reaches, Merkle-style: a
215
+ * change anywhere in a node's reach moves its digest, a change elsewhere
216
+ * does not. Lockfiles carry dependency cycles, so the unit is the
217
+ * strongly connected component: Tarjan's walk (iterative — a dependency
218
+ * chain can be thousands deep) emits components children-first, and each
219
+ * folds its members' material (sorted) and its child components' digests
220
+ * (sorted). O(nodes + edges): 1000 importers over 3000 packages digest in
221
+ * ~20 ms where one traversal per importer took 400.
222
+ */
223
+ export function reachDigests(g: ReachGraph): string[] {
224
+ const n = g.material.length
225
+ const idx = new Int32Array(n).fill(-1)
226
+ const low = new Int32Array(n)
227
+ const onStack = new Uint8Array(n)
228
+ const stack: number[] = []
229
+ const comp = new Int32Array(n).fill(-1)
230
+ const compHash: string[] = []
231
+ let counter = 0
232
+ for (let root = 0; root < n; root++) {
233
+ if (idx[root] !== -1) continue
234
+ const frames: Array<[number, number]> = [[root, 0]]
235
+ idx[root] = low[root] = counter++
236
+ stack.push(root)
237
+ onStack[root] = 1
238
+ while (frames.length > 0) {
239
+ const frame = frames[frames.length - 1]!
240
+ const v = frame[0]
241
+ const out = g.edges[v]!
242
+ if (frame[1] < out.length) {
243
+ const w = out[frame[1]++]!
244
+ if (idx[w] === -1) {
245
+ idx[w] = low[w] = counter++
246
+ stack.push(w)
247
+ onStack[w] = 1
248
+ frames.push([w, 0])
249
+ } else if (onStack[w] === 1) {
250
+ low[v] = Math.min(low[v]!, idx[w]!)
251
+ }
252
+ continue
253
+ }
254
+ frames.pop()
255
+ if (frames.length > 0) {
256
+ const u = frames[frames.length - 1]![0]
257
+ low[u] = Math.min(low[u]!, low[v]!)
258
+ }
259
+ if (low[v] !== idx[v]) continue
260
+ const members: number[] = []
261
+ for (;;) {
262
+ const w = stack.pop()!
263
+ onStack[w] = 0
264
+ comp[w] = compHash.length
265
+ members.push(w)
266
+ if (w === v) break
267
+ }
268
+ const id = compHash.length
269
+ const children = new Set<string>()
270
+ for (const m of members) {
271
+ for (const w of g.edges[m]!) if (comp[w] !== id) children.add(compHash[comp[w]!]!)
272
+ }
273
+ let h = Bun.hash.xxHash3(`members:${members.length}`)
274
+ for (const m of members.map((m) => g.material[m]!).sort()) h = Bun.hash.xxHash3(`${m}\n`, h)
275
+ h = Bun.hash.xxHash3(`children:${children.size}`, h)
276
+ for (const c of [...children].sort()) h = Bun.hash.xxHash3(`${c}\n`, h)
277
+ compHash.push(h.toString(16).padStart(16, '0'))
278
+ }
279
+ }
280
+ return Array.from({ length: n }, (_, i) => compHash[comp[i]!]!)
281
+ }