@vzn/vx 0.0.101 → 0.0.103
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/README.md +46 -15
- package/package.json +5 -5
- package/src/bin.ts +9 -1
- package/src/cache/inputs.ts +5 -3
- package/src/cli/help.ts +2 -1
- package/src/cli/index.ts +17 -0
- package/src/cli/last.ts +47 -6
- package/src/cli/run-id.ts +23 -2
- package/src/cli/show.ts +46 -5
- package/src/cli/watch.ts +14 -1
- package/src/cli/why.ts +19 -3
- package/src/exec/kill-tree.ts +2 -0
- package/src/exec/runner.ts +24 -25
- package/src/exec/sandbox-binds.ts +6 -6
- package/src/exec/sandbox-runtime.ts +149 -6
- package/src/orchestrator/events.ts +3 -1
- package/src/orchestrator/execute-task.ts +38 -11
- package/src/orchestrator/framed-output.ts +7 -2
- package/src/orchestrator/hit-restore.ts +7 -2
- package/src/orchestrator/sandbox-request.ts +10 -2
- package/src/orchestrator/telemetry.ts +3 -2
- package/src/util/errors.ts +15 -0
- package/src/util/index.ts +4 -1
- package/src/util/secret-mask.ts +145 -0
- package/src/util/which.ts +12 -0
- package/src/workspace/affected.ts +10 -2
- package/src/workspace/config-cache.ts +3 -1
- package/src/workspace/config-imports.ts +184 -15
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// Masking the values of secret-named variables in what vx prints, stores
|
|
2
|
+
// or hands to telemetry (L-11). A task that echoes `$NPM_TOKEN`, or a
|
|
3
|
+
// TS config that interpolates `process.env.API_KEY` into its command,
|
|
4
|
+
// put the value in the terminal, the cached stdout every later hit
|
|
5
|
+
// replays, and the spans a telemetry plugin exports. The masking is
|
|
6
|
+
// GitHub Actions' kind: a known value is replaced wherever it appears.
|
|
7
|
+
|
|
8
|
+
/** A variable whose value is masked, by the words its name holds. */
|
|
9
|
+
const SECRET_NAME = /TOKEN|SECRET|KEY|PASSWORD|PASSWD|CREDENTIAL/i
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Names that hold such a word but a value that is no secret: where a
|
|
13
|
+
* secret lives (`NPM_TOKEN_FILE`), or git's own config channel, whose
|
|
14
|
+
* `GIT_CONFIG_KEY_<n>` values are config names (`safe.directory`) that
|
|
15
|
+
* masking would blank out of ordinary output.
|
|
16
|
+
*/
|
|
17
|
+
const NOT_SECRET = /_(FILE|PATH|DIR)$|^GIT_CONFIG_KEY_\d+$/i
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Values shorter than this are not masked: `KEY=1` would mask every `1`
|
|
21
|
+
* in every line, and a value that short is no secret worth the noise.
|
|
22
|
+
*/
|
|
23
|
+
const MIN_SECRET_CHARS = 6
|
|
24
|
+
|
|
25
|
+
export const MASKED = '***'
|
|
26
|
+
|
|
27
|
+
export interface SecretMask {
|
|
28
|
+
/** `text` with every secret value replaced. */
|
|
29
|
+
mask(text: string): string
|
|
30
|
+
/**
|
|
31
|
+
* A mask for a stream of chunks: a value split across two chunks is
|
|
32
|
+
* still caught, by holding back only a tail that could begin one until
|
|
33
|
+
* the next chunk or `end`.
|
|
34
|
+
*/
|
|
35
|
+
stream(): { push(chunk: string): string; end(): string }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The mask for these variables (a process's env, a task's `define`), or
|
|
40
|
+
* null when none is secret-named with a value worth masking: the common
|
|
41
|
+
* run pays one name test per variable and nothing per byte.
|
|
42
|
+
*/
|
|
43
|
+
export function secretMask(
|
|
44
|
+
...sources: ReadonlyArray<Readonly<Record<string, string | undefined>> | undefined>
|
|
45
|
+
): SecretMask | null {
|
|
46
|
+
const values = new Set<string>()
|
|
47
|
+
for (const source of sources) {
|
|
48
|
+
if (source === undefined) continue
|
|
49
|
+
for (const name in source) {
|
|
50
|
+
const value = source[name]
|
|
51
|
+
if (
|
|
52
|
+
value !== undefined &&
|
|
53
|
+
value.length >= MIN_SECRET_CHARS &&
|
|
54
|
+
SECRET_NAME.test(name) &&
|
|
55
|
+
!NOT_SECRET.test(name)
|
|
56
|
+
) {
|
|
57
|
+
values.add(value)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (values.size === 0) return null
|
|
62
|
+
// Longest first, so a value holding another is replaced whole.
|
|
63
|
+
const sorted = [...values].sort((a, b) => b.length - a.length)
|
|
64
|
+
const pattern = new RegExp(
|
|
65
|
+
sorted.map((v) => v.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'),
|
|
66
|
+
'g',
|
|
67
|
+
)
|
|
68
|
+
const mask = (text: string): string => text.replace(pattern, MASKED)
|
|
69
|
+
/** The longest tail of `text` that is a proper prefix of a value. */
|
|
70
|
+
const partialTail = (text: string): number => {
|
|
71
|
+
let longest = 0
|
|
72
|
+
for (const v of sorted) {
|
|
73
|
+
for (let k = Math.min(v.length - 1, text.length); k > longest; k--) {
|
|
74
|
+
if (text.endsWith(v.slice(0, k))) {
|
|
75
|
+
longest = k
|
|
76
|
+
break
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return longest
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
mask,
|
|
84
|
+
stream() {
|
|
85
|
+
let carry = ''
|
|
86
|
+
return {
|
|
87
|
+
push(chunk) {
|
|
88
|
+
const text = carry + chunk
|
|
89
|
+
// Held back: only a tail that could begin a value, so output that
|
|
90
|
+
// cannot be one is never delayed (a `readyWhen` line, a marker).
|
|
91
|
+
let cut = text.length - partialTail(text)
|
|
92
|
+
pattern.lastIndex = 0
|
|
93
|
+
// Never cut through a value that is already whole.
|
|
94
|
+
for (let m = pattern.exec(text); m !== null; m = pattern.exec(text)) {
|
|
95
|
+
if (m.index < cut && m.index + m[0].length > cut) cut = m.index
|
|
96
|
+
}
|
|
97
|
+
carry = text.slice(cut)
|
|
98
|
+
return mask(text.slice(0, cut))
|
|
99
|
+
},
|
|
100
|
+
end() {
|
|
101
|
+
const rest = mask(carry)
|
|
102
|
+
carry = ''
|
|
103
|
+
return rest
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** A task's command as vx shows it: its secret-named values masked. */
|
|
111
|
+
export function maskedCommand(command: string, define?: Readonly<Record<string, string>>): string {
|
|
112
|
+
return secretMask(process.env, define)?.mask(command) ?? command
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* `mask.stream()` driving `emit`: what it holds back (a tail that could
|
|
117
|
+
* begin a value) is emitted `idleMs` after the last chunk when none
|
|
118
|
+
* follows, so a long task's last characters do not wait for its exit. A
|
|
119
|
+
* value is written whole or in chunks that arrive together; one split by
|
|
120
|
+
* a pause longer than that is not caught.
|
|
121
|
+
*/
|
|
122
|
+
export function maskedEmitter(
|
|
123
|
+
mask: SecretMask,
|
|
124
|
+
emit: (text: string) => void,
|
|
125
|
+
idleMs = 25,
|
|
126
|
+
): { push(chunk: string): void; end(): void } {
|
|
127
|
+
const s = mask.stream()
|
|
128
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
129
|
+
const end = (): void => {
|
|
130
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
131
|
+
timer = undefined
|
|
132
|
+
const rest = s.end()
|
|
133
|
+
if (rest) emit(rest)
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
push(chunk) {
|
|
137
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
138
|
+
const out = s.push(chunk)
|
|
139
|
+
if (out) emit(out)
|
|
140
|
+
timer = setTimeout(end, idleMs)
|
|
141
|
+
timer.unref?.()
|
|
142
|
+
},
|
|
143
|
+
end,
|
|
144
|
+
}
|
|
145
|
+
}
|
package/src/util/which.ts
CHANGED
|
@@ -46,3 +46,15 @@ export function executablePath(name: string): string {
|
|
|
46
46
|
found.set(name, at)
|
|
47
47
|
return at
|
|
48
48
|
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The argv that runs `script` in the task shell: `sh -c` on Linux and
|
|
52
|
+
* macOS, and Bun's own shell (`bun exec`) on Windows, which ships no `sh`
|
|
53
|
+
* (docs/design/windows-2026-09.md § Decisions 1). Bun's shell reads the
|
|
54
|
+
* same `&&`, `|`, `$(…)` and `VAR=x cmd` syntax that `bun run` uses there.
|
|
55
|
+
*/
|
|
56
|
+
export function shellArgv(script: string): string[] {
|
|
57
|
+
return process.platform === 'win32'
|
|
58
|
+
? [process.execPath, 'exec', script]
|
|
59
|
+
: [executablePath('sh'), '-c', script]
|
|
60
|
+
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// the base branch moved on with. Matches Turbo's `[<since>]` semantics.
|
|
8
8
|
|
|
9
9
|
import { realpathSync, statSync } from 'node:fs'
|
|
10
|
+
import { realpath } from 'node:fs/promises'
|
|
10
11
|
import path from 'node:path'
|
|
11
12
|
import {
|
|
12
13
|
asTrees,
|
|
@@ -240,7 +241,12 @@ export async function affectedProjects(args: AffectedArgs): Promise<Set<string>>
|
|
|
240
241
|
}
|
|
241
242
|
}
|
|
242
243
|
|
|
243
|
-
const
|
|
244
|
+
const realDirs = new Map(
|
|
245
|
+
await Promise.all(
|
|
246
|
+
args.projects.map(async (p) => [p.dir, await realpath(p.dir).catch(() => p.dir)] as const),
|
|
247
|
+
),
|
|
248
|
+
)
|
|
249
|
+
const owned = projectsContaining(args.workspaceRoot, changed, args.projects, realDirs)
|
|
244
250
|
for (const name of claimedOwned) owned.add(name)
|
|
245
251
|
|
|
246
252
|
// A manifest edit can drop an edge today's graph no longer shows: a
|
|
@@ -277,6 +283,7 @@ export async function affectedProjects(args: AffectedArgs): Promise<Set<string>>
|
|
|
277
283
|
projects: args.projects,
|
|
278
284
|
changed,
|
|
279
285
|
skip: owned,
|
|
286
|
+
realDirs,
|
|
280
287
|
})) {
|
|
281
288
|
owned.add(name)
|
|
282
289
|
}
|
|
@@ -659,6 +666,7 @@ function projectsContaining(
|
|
|
659
666
|
workspaceRoot: string,
|
|
660
667
|
changedRelPaths: readonly string[],
|
|
661
668
|
projects: readonly ProjectMeta[],
|
|
669
|
+
realDirs: ReadonlyMap<string, string>,
|
|
662
670
|
): Set<string> {
|
|
663
671
|
// Index projects by their (canonical) dir, then for each changed path walk
|
|
664
672
|
// its ancestor dirs bottom-up until one is a project dir. The FIRST hit is
|
|
@@ -676,7 +684,7 @@ function projectsContaining(
|
|
|
676
684
|
// too. One realpath per member, and only on an --affected run.
|
|
677
685
|
const realRoot = realpathOr(workspaceRoot)
|
|
678
686
|
for (const p of projects) {
|
|
679
|
-
const real =
|
|
687
|
+
const real = realDirs.get(p.dir) ?? p.dir
|
|
680
688
|
const rel = path.relative(realRoot, real)
|
|
681
689
|
if (rel.startsWith('..') || path.isAbsolute(rel)) continue
|
|
682
690
|
const spelled = path.resolve(workspaceRoot, rel)
|
|
@@ -141,8 +141,10 @@ const MAX_CLOSURE_FILES = 32
|
|
|
141
141
|
// passes: the gate stops accidental impurity, not a config written to
|
|
142
142
|
// defeat it. `random` is denied as a word, not as `Math.random`: a
|
|
143
143
|
// destructured `const { random } = Math` was cached as pure (item 1036).
|
|
144
|
+
// `prompt`, `confirm` and `alert` read the user's terminal, and a cached
|
|
145
|
+
// evaluation replayed the first answer (D-25).
|
|
144
146
|
const IMPURE_RE =
|
|
145
|
-
/\b(?:process|Bun|globalThis|global|self|fetch|Date|Temporal|Intl|crypto|performance|navigator|require|eval|Function|constructor|localeCompare|await|toLocale\w*|Reflect|getPrototypeOf|setPrototypeOf|getOwnPropertyNames|getOwnPropertyDescriptor|getOwnPropertyDescriptors|__proto__|prototype|__defineGetter__|__defineSetter__|__lookupGetter__|__lookupSetter__|random)\b|import\s*\.\s*meta|\bimport\s*\(/
|
|
147
|
+
/\b(?:process|Bun|globalThis|global|self|fetch|Date|Temporal|Intl|crypto|performance|navigator|require|eval|Function|constructor|localeCompare|await|toLocale\w*|Reflect|getPrototypeOf|setPrototypeOf|getOwnPropertyNames|getOwnPropertyDescriptor|getOwnPropertyDescriptors|__proto__|prototype|__defineGetter__|__defineSetter__|__lookupGetter__|__lookupSetter__|random|prompt|confirm|alert)\b|import\s*\.\s*meta|\bimport\s*\(/
|
|
146
148
|
|
|
147
149
|
/** Literal text that names a way to `Function` when used as a computed key. */
|
|
148
150
|
const IMPURE_LITERAL_RE = /constructor|__proto__|prototype/
|
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
//
|
|
19
19
|
// - RELATIVE specifiers only. A bare specifier is a package; it moves when
|
|
20
20
|
// the lockfile moves, which the workspace fingerprint already covers.
|
|
21
|
+
// A tsconfig `paths` / `baseUrl` alias is the exception: Bun loads it
|
|
22
|
+
// from disk (D-27).
|
|
21
23
|
// - Descend only through files owned by NO project. A config reaching into
|
|
22
24
|
// another project (say a site's `vx.config.ts` importing
|
|
23
25
|
// `../core/src/index.ts`) records that edge and STOPS there — following
|
|
@@ -25,7 +27,7 @@
|
|
|
25
27
|
// closure, and the containment channel already selects the project that
|
|
26
28
|
// owns it.
|
|
27
29
|
|
|
28
|
-
import { existsSync } from 'node:fs'
|
|
30
|
+
import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'
|
|
29
31
|
import { realpath } from 'node:fs/promises'
|
|
30
32
|
import { builtinModules } from 'node:module'
|
|
31
33
|
import path from 'node:path'
|
|
@@ -130,11 +132,158 @@ export function unprovidedBareImports(
|
|
|
130
132
|
if (up === dir) break
|
|
131
133
|
dir = up
|
|
132
134
|
}
|
|
133
|
-
if (!provided && !out.includes(spec))
|
|
135
|
+
if (!provided && tsconfigTarget(spec, fromDir) === undefined && !out.includes(spec)) {
|
|
136
|
+
out.push(spec)
|
|
137
|
+
}
|
|
134
138
|
}
|
|
135
139
|
return out
|
|
136
140
|
}
|
|
137
141
|
|
|
142
|
+
interface CompilerPaths {
|
|
143
|
+
paths?: Record<string, unknown>
|
|
144
|
+
/** What `paths` targets resolve against: `baseUrl`, else the config's own dir. */
|
|
145
|
+
pathsBase?: string
|
|
146
|
+
baseUrl?: string
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* `compilerOptions.paths` and `baseUrl` after `extends`, each resolved
|
|
151
|
+
* against the config that set it. A package `extends` is not followed: its
|
|
152
|
+
* paths are unseen, so a specifier only they map stays refused.
|
|
153
|
+
*/
|
|
154
|
+
function compilerPaths(file: string, memo?: TsconfigMemo, depth = 0): CompilerPaths {
|
|
155
|
+
const known = memo?.get(file)
|
|
156
|
+
if (known !== undefined) return known
|
|
157
|
+
let json: unknown
|
|
158
|
+
try {
|
|
159
|
+
json = Bun.JSONC.parse(readFileSync(file, 'utf8'))
|
|
160
|
+
} catch {
|
|
161
|
+
return {}
|
|
162
|
+
}
|
|
163
|
+
if (typeof json !== 'object' || json === null) return {}
|
|
164
|
+
const dir = path.dirname(file)
|
|
165
|
+
const out: CompilerPaths = {}
|
|
166
|
+
const ext = (json as { extends?: unknown }).extends
|
|
167
|
+
for (const base of typeof ext === 'string' ? [ext] : Array.isArray(ext) ? ext : []) {
|
|
168
|
+
if (depth > 8 || typeof base !== 'string' || !base.startsWith('.')) continue
|
|
169
|
+
const f = path.resolve(dir, base)
|
|
170
|
+
Object.assign(out, compilerPaths(f.endsWith('.json') ? f : f + '.json', memo, depth + 1))
|
|
171
|
+
}
|
|
172
|
+
const opts = (json as { compilerOptions?: unknown }).compilerOptions
|
|
173
|
+
if (typeof opts !== 'object' || opts === null) return out
|
|
174
|
+
const { baseUrl, paths } = opts as { baseUrl?: unknown; paths?: unknown }
|
|
175
|
+
if (typeof baseUrl === 'string') out.baseUrl = path.resolve(dir, baseUrl)
|
|
176
|
+
if (typeof paths === 'object' && paths !== null) {
|
|
177
|
+
out.paths = paths as Record<string, unknown>
|
|
178
|
+
out.pathsBase = out.baseUrl ?? dir
|
|
179
|
+
} else if (out.paths !== undefined && out.baseUrl !== undefined) {
|
|
180
|
+
out.pathsBase = out.baseUrl
|
|
181
|
+
}
|
|
182
|
+
memo?.set(file, out)
|
|
183
|
+
return out
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** The file Bun loads for an extensionless or extensioned path, or undefined. */
|
|
187
|
+
function fileAt(target: string): string | undefined {
|
|
188
|
+
for (const f of [
|
|
189
|
+
target,
|
|
190
|
+
...RESOLVED_EXTENSIONS.map((e) => target + e),
|
|
191
|
+
...RESOLVED_EXTENSIONS.map((e) => path.join(target, 'index' + e)),
|
|
192
|
+
]) {
|
|
193
|
+
try {
|
|
194
|
+
if (statSync(f).isFile()) return f
|
|
195
|
+
} catch {}
|
|
196
|
+
}
|
|
197
|
+
return undefined
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Paths per directory and per tsconfig file, memoised across one walk. */
|
|
201
|
+
type TsconfigMemo = Map<string, CompilerPaths>
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The paths of the nearest `tsconfig.json`, else `jsconfig.json`, at or
|
|
205
|
+
* above `dir`: Bun reads that one alone.
|
|
206
|
+
*/
|
|
207
|
+
function pathsAt(dir: string, memo?: TsconfigMemo): CompilerPaths {
|
|
208
|
+
const known = memo?.get(dir)
|
|
209
|
+
if (known !== undefined) return known
|
|
210
|
+
const file = ['tsconfig.json', 'jsconfig.json'].map((n) => path.join(dir, n)).find(existsSync)
|
|
211
|
+
const up = path.dirname(dir)
|
|
212
|
+
const cp = file !== undefined ? compilerPaths(file, memo) : up === dir ? {} : pathsAt(up, memo)
|
|
213
|
+
memo?.set(dir, cp)
|
|
214
|
+
return cp
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Whether `source` quotes a bare specifier the nearest tsconfig maps: the
|
|
219
|
+
* textual pass that keeps a config importing only packages unscanned, as
|
|
220
|
+
* D-23's keeps one importing nothing relative (D-27).
|
|
221
|
+
*/
|
|
222
|
+
function hasAliasCandidate(source: string, fromDir: string, memo: TsconfigMemo): boolean {
|
|
223
|
+
SPECIFIER.lastIndex = 0
|
|
224
|
+
for (let m = SPECIFIER.exec(source); m !== null; m = SPECIFIER.exec(source)) {
|
|
225
|
+
const spec = m[1] ?? m[2] ?? ''
|
|
226
|
+
if (needsLookup(spec) && tsconfigBases(spec, fromDir, memo).length > 0) return true
|
|
227
|
+
}
|
|
228
|
+
return false
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The paths a bare specifier names through the nearest tsconfig's `paths`
|
|
233
|
+
* (each matching target, in order) or `baseUrl`, before extensions: Bun
|
|
234
|
+
* tries them ahead of `node_modules`.
|
|
235
|
+
*/
|
|
236
|
+
function tsconfigBases(spec: string, fromDir: string, memo?: TsconfigMemo): string[] {
|
|
237
|
+
const { paths, pathsBase, baseUrl } = pathsAt(path.resolve(fromDir), memo)
|
|
238
|
+
const out: string[] = []
|
|
239
|
+
for (const [key, targets] of Object.entries(paths ?? {})) {
|
|
240
|
+
const star = key.indexOf('*')
|
|
241
|
+
let hole: string
|
|
242
|
+
if (star === -1) {
|
|
243
|
+
if (spec !== key) continue
|
|
244
|
+
hole = ''
|
|
245
|
+
} else {
|
|
246
|
+
const head = key.slice(0, star)
|
|
247
|
+
const tail = key.slice(star + 1)
|
|
248
|
+
if (spec.length < key.length - 1 || !spec.startsWith(head) || !spec.endsWith(tail)) continue
|
|
249
|
+
hole = spec.slice(head.length, spec.length - tail.length)
|
|
250
|
+
}
|
|
251
|
+
for (const t of Array.isArray(targets) ? targets : []) {
|
|
252
|
+
if (typeof t === 'string') out.push(path.resolve(pathsBase!, t.replace('*', hole)))
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (baseUrl !== undefined) out.push(path.resolve(baseUrl, spec))
|
|
256
|
+
return out
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The local file a bare specifier names through the nearest tsconfig, as
|
|
261
|
+
* Bun resolves it (D-26): an alias whose target exists loads from disk and
|
|
262
|
+
* never reaches the registry.
|
|
263
|
+
*/
|
|
264
|
+
function tsconfigTarget(spec: string, fromDir: string, memo?: TsconfigMemo): string | undefined {
|
|
265
|
+
for (const base of tsconfigBases(spec, fromDir, memo)) {
|
|
266
|
+
const hit = fileAt(base)
|
|
267
|
+
if (hit !== undefined) return hit
|
|
268
|
+
}
|
|
269
|
+
return undefined
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** A path as Bun names it: realpath'd when it exists. */
|
|
273
|
+
function realpathOr(file: string): string {
|
|
274
|
+
try {
|
|
275
|
+
return realpathSync(file)
|
|
276
|
+
} catch {
|
|
277
|
+
return file
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** A path and, bare of an extension, the files Bun would try for it. */
|
|
282
|
+
function expansions(named: string): string[] {
|
|
283
|
+
if (path.extname(named) !== '') return [named]
|
|
284
|
+
return [named, ...RESOLVED_EXTENSIONS.flatMap((e) => [named + e, path.join(named, 'index' + e)])]
|
|
285
|
+
}
|
|
286
|
+
|
|
138
287
|
/**
|
|
139
288
|
* A textual pass before the scan: a relative specifier opens with a quoted
|
|
140
289
|
* `./` or `../`, or spells one with an escape, and most configs hold
|
|
@@ -152,8 +301,14 @@ const RELATIVE_CANDIDATE = /["'`](?:\.\.?\/|[^"'`\n]*\\)/
|
|
|
152
301
|
* reason. `import type` is erased by `scanImports` and so contributes no edge,
|
|
153
302
|
* which is correct: an erased import cannot move a resolved value.
|
|
154
303
|
*/
|
|
155
|
-
function scanLocalImports(
|
|
156
|
-
|
|
304
|
+
function scanLocalImports(
|
|
305
|
+
source: string,
|
|
306
|
+
fromDir: string,
|
|
307
|
+
loader: 'ts' | 'js',
|
|
308
|
+
memo: TsconfigMemo,
|
|
309
|
+
): string[] {
|
|
310
|
+
const aliased = hasAliasCandidate(source, fromDir, memo)
|
|
311
|
+
if (!aliased && !RELATIVE_CANDIDATE.test(source)) return []
|
|
157
312
|
let specifiers: string[]
|
|
158
313
|
try {
|
|
159
314
|
specifiers = scanner(loader)
|
|
@@ -164,7 +319,16 @@ function scanLocalImports(source: string, fromDir: string, loader: 'ts' | 'js'):
|
|
|
164
319
|
}
|
|
165
320
|
const out: string[] = []
|
|
166
321
|
for (const spec of specifiers) {
|
|
167
|
-
if (!spec.startsWith('./') && !spec.startsWith('../'))
|
|
322
|
+
if (!spec.startsWith('./') && !spec.startsWith('../')) {
|
|
323
|
+
// A tsconfig alias is a local import Bun resolves by path (D-27); a
|
|
324
|
+
// target the change deleted still names its paths, as below.
|
|
325
|
+
if (!aliased || spec.startsWith('.') || spec.startsWith('/') || !needsLookup(spec)) continue
|
|
326
|
+
const bases = tsconfigBases(spec, fromDir, memo)
|
|
327
|
+
const hit = bases.map(fileAt).find((f) => f !== undefined)
|
|
328
|
+
if (hit !== undefined) out.push(realpathOr(hit))
|
|
329
|
+
else out.push(...bases.flatMap(expansions))
|
|
330
|
+
continue
|
|
331
|
+
}
|
|
168
332
|
try {
|
|
169
333
|
out.push(Bun.resolveSync(spec, fromDir))
|
|
170
334
|
} catch {
|
|
@@ -174,12 +338,7 @@ function scanLocalImports(source: string, fromDir: string, loader: 'ts' | 'js'):
|
|
|
174
338
|
// of an extension, the files Bun would have tried), so the deleted
|
|
175
339
|
// path in the diff still reaches its importer (item 958). Selection
|
|
176
340
|
// may widen; it is never hashed.
|
|
177
|
-
|
|
178
|
-
out.push(named)
|
|
179
|
-
if (path.extname(spec) === '') {
|
|
180
|
-
for (const ext of RESOLVED_EXTENSIONS)
|
|
181
|
-
out.push(named + ext, path.join(named, 'index' + ext))
|
|
182
|
-
}
|
|
341
|
+
out.push(...expansions(path.resolve(fromDir, spec)))
|
|
183
342
|
}
|
|
184
343
|
}
|
|
185
344
|
return out
|
|
@@ -198,6 +357,12 @@ export interface ConfigImportOwnersArgs {
|
|
|
198
357
|
changed: readonly string[]
|
|
199
358
|
/** Already-selected projects; their configs need no scan. */
|
|
200
359
|
skip: ReadonlySet<string>
|
|
360
|
+
/**
|
|
361
|
+
* Each project dir's realpath, when the caller already has them: the
|
|
362
|
+
* containment pass needs the same answers, and asking twice cost 5,000
|
|
363
|
+
* realpaths at 5,000 projects (D-24).
|
|
364
|
+
*/
|
|
365
|
+
realDirs?: ReadonlyMap<string, string>
|
|
201
366
|
}
|
|
202
367
|
|
|
203
368
|
/**
|
|
@@ -207,11 +372,14 @@ export interface ConfigImportOwnersArgs {
|
|
|
207
372
|
* `/var/folders/…` while its realpath is `/private/var/folders/…`; comparing
|
|
208
373
|
* the two matches nothing and fails exactly like "found no imports".
|
|
209
374
|
*/
|
|
210
|
-
async function realDirIndex(
|
|
375
|
+
async function realDirIndex(
|
|
376
|
+
projects: readonly ProjectMeta[],
|
|
377
|
+
known: ReadonlyMap<string, string> | undefined,
|
|
378
|
+
): Promise<Map<string, string>> {
|
|
211
379
|
const out = new Map<string, string>()
|
|
212
380
|
await Promise.all(
|
|
213
381
|
projects.map(async (p) => {
|
|
214
|
-
out.set(await realpath(p.dir).catch(() => p.dir), p.name)
|
|
382
|
+
out.set(known?.get(p.dir) ?? (await realpath(p.dir).catch(() => p.dir)), p.name)
|
|
215
383
|
}),
|
|
216
384
|
)
|
|
217
385
|
return out
|
|
@@ -249,10 +417,11 @@ export async function configImportOwners(a: ConfigImportOwnersArgs): Promise<Set
|
|
|
249
417
|
}),
|
|
250
418
|
)
|
|
251
419
|
if (roots.size === 0) return selected
|
|
252
|
-
const dirToName = await realDirIndex(a.projects)
|
|
420
|
+
const dirToName = await realDirIndex(a.projects, a.realDirs)
|
|
253
421
|
|
|
254
422
|
// target → the files that import it. Reversed up front so one BFS from the
|
|
255
423
|
// changed set answers every root at once, instead of a walk per root.
|
|
424
|
+
const tsconfigs: TsconfigMemo = new Map()
|
|
256
425
|
const importedBy = new Map<string, string[]>()
|
|
257
426
|
const visited = new Set<string>()
|
|
258
427
|
// A level at a time, its files read together: one awaited read per
|
|
@@ -275,7 +444,7 @@ export async function configImportOwners(a: ConfigImportOwnersArgs): Promise<Set
|
|
|
275
444
|
if (entry === null) continue
|
|
276
445
|
const { file, source } = entry
|
|
277
446
|
const loader = TS_EXT.has(path.extname(file)) ? 'ts' : 'js'
|
|
278
|
-
for (const target of scanLocalImports(source, path.dirname(file), loader)) {
|
|
447
|
+
for (const target of scanLocalImports(source, path.dirname(file), loader, tsconfigs)) {
|
|
279
448
|
if (!target.startsWith(workspaceRoot + path.sep)) continue
|
|
280
449
|
if (target.split(path.sep).includes('node_modules')) continue
|
|
281
450
|
const list = importedBy.get(target)
|