@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.
- package/package.json +5 -5
- package/src/bin.ts +26 -11
- package/src/cache/archive.ts +2 -2
- package/src/cache/cache.ts +96 -90
- package/src/cache/config-evals.ts +19 -4
- package/src/cache/git-inputs.ts +19 -8
- package/src/cache/inputs.ts +110 -37
- package/src/cache/layered-cache.ts +25 -2
- package/src/cache/tar-stream.ts +1 -1
- package/src/cli/cache.ts +1 -11
- package/src/cli/completions.ts +42 -6
- package/src/cli/foreign-flags.ts +25 -4
- package/src/cli/help.ts +5 -3
- package/src/cli/init.ts +1 -1
- package/src/cli/plan-format.ts +9 -0
- package/src/cli/plugin-templates.ts +2 -2
- package/src/cli/run.ts +29 -27
- package/src/cli/select.ts +30 -3
- package/src/cli/show.ts +42 -3
- package/src/cli/watch-filter.ts +14 -1
- package/src/cli/watch-fs.ts +15 -7
- package/src/cli/watch-judge.ts +1 -1
- package/src/cli/watch.ts +18 -11
- package/src/config.ts +5 -5
- package/src/exec/env.ts +5 -0
- package/src/exec/index.ts +1 -0
- package/src/exec/proc-sample.ts +5 -1
- package/src/exec/runner.ts +98 -40
- package/src/exec/sandbox-paths.ts +11 -5
- package/src/exec/sandbox-runtime.ts +20 -4
- package/src/graph/index.ts +1 -0
- package/src/graph/scheduler.ts +1 -1
- package/src/index.ts +6 -0
- package/src/orchestrator/execute-task.ts +29 -7
- package/src/orchestrator/hit-restore.ts +6 -3
- package/src/orchestrator/local-shortcircuit.ts +4 -1
- package/src/orchestrator/lockfile-claim.ts +43 -46
- package/src/orchestrator/plan.ts +25 -1
- package/src/orchestrator/plugin-host.ts +25 -3
- package/src/orchestrator/plugin.ts +2 -1
- package/src/orchestrator/run.ts +52 -8
- package/src/orchestrator/signals.ts +5 -2
- package/src/orchestrator/summary.ts +14 -1
- package/src/util/index.ts +7 -1
- package/src/util/secret-mask.ts +36 -0
- package/src/util/settle.ts +15 -0
- package/src/workspace/affected.ts +30 -8
- package/src/workspace/config-cache.ts +25 -4
- package/src/workspace/config-imports.ts +1 -1
- package/src/workspace/config-schema.ts +48 -6
- package/src/workspace/migrate-scripts.ts +1 -1
- package/src/workspace/package-graph.ts +15 -3
- package/src/workspace/project-loader.ts +72 -0
- package/src/workspace/workspace.ts +82 -11
package/src/orchestrator/plan.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
}
|
package/src/orchestrator/run.ts
CHANGED
|
@@ -87,8 +87,12 @@ import {
|
|
|
87
87
|
detectCi,
|
|
88
88
|
} from './run-context.js'
|
|
89
89
|
import { startRemotePrefetch } from './remote-prefetch.js'
|
|
90
|
-
import {
|
|
91
|
-
|
|
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
|
-
|
|
1101
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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,
|
package/src/util/secret-mask.ts
CHANGED
|
@@ -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 {
|
package/src/util/settle.ts
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
}
|