@vzn/vx 0.0.100 → 0.0.102
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 +60 -29
- package/package.json +5 -5
- package/src/bin.ts +9 -1
- package/src/cache/inputs.ts +5 -3
- package/src/cli/cache.ts +2 -2
- package/src/cli/help.ts +4 -1
- package/src/cli/index.ts +17 -0
- package/src/cli/init.ts +50 -3
- package/src/cli/last.ts +20 -4
- package/src/cli/plugin-templates.ts +51 -0
- package/src/cli/run-id.ts +23 -2
- package/src/cli/run.ts +22 -7
- package/src/cli/show.ts +48 -6
- package/src/cli/watch-set.ts +182 -0
- package/src/cli/watch.ts +16 -181
- package/src/cli/why.ts +21 -4
- 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 +131 -6
- package/src/orchestrator/admission.ts +4 -3
- 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 +151 -25
package/src/util/errors.ts
CHANGED
|
@@ -49,6 +49,21 @@ export function isDiskFull(err: unknown): err is NodeJS.ErrnoException {
|
|
|
49
49
|
return code === 'ENOSPC' || code === 'EDQUOT'
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* `EMFILE` or `ENFILE`: the process or the system is out of file
|
|
54
|
+
* descriptors. macOS starts a shell at 256, which a large workspace's
|
|
55
|
+
* config load and input walk can reach; the stack it printed named a file
|
|
56
|
+
* that was fine.
|
|
57
|
+
*/
|
|
58
|
+
export function isOutOfFds(err: unknown): err is NodeJS.ErrnoException {
|
|
59
|
+
if (!(err instanceof Error)) return false
|
|
60
|
+
const code = (err as NodeJS.ErrnoException).code
|
|
61
|
+
return code === 'EMFILE' || code === 'ENFILE'
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const OUT_OF_FDS_HINT =
|
|
65
|
+
'the process is out of file descriptors; raise the limit (ulimit -n 4096) and re-run'
|
|
66
|
+
|
|
52
67
|
/** A file system refusing a write for a reason no code path caused: permission or space. */
|
|
53
68
|
export function isFsRefusal(err: unknown): err is NodeJS.ErrnoException {
|
|
54
69
|
return isPermissionError(err) || isDiskFull(err)
|
package/src/util/index.ts
CHANGED
|
@@ -14,6 +14,8 @@ export {
|
|
|
14
14
|
UserError,
|
|
15
15
|
gitSpawnRefusal,
|
|
16
16
|
isExecutableMissing,
|
|
17
|
+
isOutOfFds,
|
|
18
|
+
OUT_OF_FDS_HINT,
|
|
17
19
|
} from './errors.js'
|
|
18
20
|
export { xxh3, xxh3hex } from './hash.js'
|
|
19
21
|
export { mark, printTimings, span } from './timing.js'
|
|
@@ -47,5 +49,6 @@ export { splitTaskId } from './task-id.js'
|
|
|
47
49
|
export { editDistance, nearMatches, nearest } from './edit-distance.js'
|
|
48
50
|
export { CORE_VERBS, MOVED_VERBS } from './verbs.js'
|
|
49
51
|
export { isUnsupportedBun, MIN_BUN, unsupportedBunMessage } from './bun-version.js'
|
|
50
|
-
export { executablePath } from './which.js'
|
|
52
|
+
export { executablePath, shellArgv } from './which.js'
|
|
51
53
|
export { procfsIsOwn } from './procfs.js'
|
|
54
|
+
export { maskedCommand, maskedEmitter, MASKED, secretMask, type SecretMask } from './secret-mask.js'
|
|
@@ -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/
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
// closure, and the containment channel already selects the project that
|
|
26
26
|
// owns it.
|
|
27
27
|
|
|
28
|
-
import { existsSync } from 'node:fs'
|
|
28
|
+
import { existsSync, readFileSync, statSync } from 'node:fs'
|
|
29
29
|
import { realpath } from 'node:fs/promises'
|
|
30
30
|
import { builtinModules } from 'node:module'
|
|
31
31
|
import path from 'node:path'
|
|
@@ -130,11 +130,118 @@ export function unprovidedBareImports(
|
|
|
130
130
|
if (up === dir) break
|
|
131
131
|
dir = up
|
|
132
132
|
}
|
|
133
|
-
if (!provided && !out.includes(spec))
|
|
133
|
+
if (!provided && tsconfigTarget(spec, fromDir) === undefined && !out.includes(spec)) {
|
|
134
|
+
out.push(spec)
|
|
135
|
+
}
|
|
134
136
|
}
|
|
135
137
|
return out
|
|
136
138
|
}
|
|
137
139
|
|
|
140
|
+
/** The nearest `tsconfig.json`, else `jsconfig.json`, at or above `dir`: Bun reads that one alone. */
|
|
141
|
+
function nearestTsconfig(dir: string): string | undefined {
|
|
142
|
+
for (let d = path.resolve(dir); ;) {
|
|
143
|
+
for (const name of ['tsconfig.json', 'jsconfig.json']) {
|
|
144
|
+
if (existsSync(path.join(d, name))) return path.join(d, name)
|
|
145
|
+
}
|
|
146
|
+
const up = path.dirname(d)
|
|
147
|
+
if (up === d) return undefined
|
|
148
|
+
d = up
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
interface CompilerPaths {
|
|
153
|
+
paths?: Record<string, unknown>
|
|
154
|
+
/** What `paths` targets resolve against: `baseUrl`, else the config's own dir. */
|
|
155
|
+
pathsBase?: string
|
|
156
|
+
baseUrl?: string
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* `compilerOptions.paths` and `baseUrl` after `extends`, each resolved
|
|
161
|
+
* against the config that set it. A package `extends` is not followed: its
|
|
162
|
+
* paths are unseen, so a specifier only they map stays refused.
|
|
163
|
+
*/
|
|
164
|
+
function compilerPaths(file: string, depth = 0): CompilerPaths {
|
|
165
|
+
let json: unknown
|
|
166
|
+
try {
|
|
167
|
+
json = Bun.JSONC.parse(readFileSync(file, 'utf8'))
|
|
168
|
+
} catch {
|
|
169
|
+
return {}
|
|
170
|
+
}
|
|
171
|
+
if (typeof json !== 'object' || json === null) return {}
|
|
172
|
+
const dir = path.dirname(file)
|
|
173
|
+
const out: CompilerPaths = {}
|
|
174
|
+
const ext = (json as { extends?: unknown }).extends
|
|
175
|
+
for (const base of typeof ext === 'string' ? [ext] : Array.isArray(ext) ? ext : []) {
|
|
176
|
+
if (depth > 8 || typeof base !== 'string' || !base.startsWith('.')) continue
|
|
177
|
+
const f = path.resolve(dir, base)
|
|
178
|
+
Object.assign(out, compilerPaths(f.endsWith('.json') ? f : f + '.json', depth + 1))
|
|
179
|
+
}
|
|
180
|
+
const opts = (json as { compilerOptions?: unknown }).compilerOptions
|
|
181
|
+
if (typeof opts !== 'object' || opts === null) return out
|
|
182
|
+
const { baseUrl, paths } = opts as { baseUrl?: unknown; paths?: unknown }
|
|
183
|
+
if (typeof baseUrl === 'string') out.baseUrl = path.resolve(dir, baseUrl)
|
|
184
|
+
if (typeof paths === 'object' && paths !== null) {
|
|
185
|
+
out.paths = paths as Record<string, unknown>
|
|
186
|
+
out.pathsBase = out.baseUrl ?? dir
|
|
187
|
+
} else if (out.paths !== undefined && out.baseUrl !== undefined) {
|
|
188
|
+
out.pathsBase = out.baseUrl
|
|
189
|
+
}
|
|
190
|
+
return out
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The file Bun loads for an extensionless or extensioned path, or undefined. */
|
|
194
|
+
function fileAt(target: string): string | undefined {
|
|
195
|
+
for (const f of [
|
|
196
|
+
target,
|
|
197
|
+
...RESOLVED_EXTENSIONS.map((e) => target + e),
|
|
198
|
+
...RESOLVED_EXTENSIONS.map((e) => path.join(target, 'index' + e)),
|
|
199
|
+
]) {
|
|
200
|
+
try {
|
|
201
|
+
if (statSync(f).isFile()) return f
|
|
202
|
+
} catch {}
|
|
203
|
+
}
|
|
204
|
+
return undefined
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The local file a bare specifier names through the nearest tsconfig's
|
|
209
|
+
* `paths` or `baseUrl`, as Bun resolves it (D-26): an alias whose target
|
|
210
|
+
* exists loads from disk and never reaches the registry.
|
|
211
|
+
*/
|
|
212
|
+
function tsconfigTarget(spec: string, fromDir: string): string | undefined {
|
|
213
|
+
const cfg = nearestTsconfig(fromDir)
|
|
214
|
+
if (cfg === undefined) return undefined
|
|
215
|
+
const { paths, pathsBase, baseUrl } = compilerPaths(cfg)
|
|
216
|
+
for (const [key, targets] of Object.entries(paths ?? {})) {
|
|
217
|
+
const star = key.indexOf('*')
|
|
218
|
+
let hole: string
|
|
219
|
+
if (star === -1) {
|
|
220
|
+
if (spec !== key) continue
|
|
221
|
+
hole = ''
|
|
222
|
+
} else {
|
|
223
|
+
const head = key.slice(0, star)
|
|
224
|
+
const tail = key.slice(star + 1)
|
|
225
|
+
if (spec.length < key.length - 1 || !spec.startsWith(head) || !spec.endsWith(tail)) continue
|
|
226
|
+
hole = spec.slice(head.length, spec.length - tail.length)
|
|
227
|
+
}
|
|
228
|
+
for (const t of Array.isArray(targets) ? targets : []) {
|
|
229
|
+
if (typeof t !== 'string') continue
|
|
230
|
+
const hit = fileAt(path.resolve(pathsBase!, t.replace('*', hole)))
|
|
231
|
+
if (hit !== undefined) return hit
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return baseUrl === undefined ? undefined : fileAt(path.resolve(baseUrl, spec))
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* A textual pass before the scan: a relative specifier opens with a quoted
|
|
239
|
+
* `./` or `../`, or spells one with an escape, and most configs hold
|
|
240
|
+
* neither, so the scan is skipped for them (D-23). A candidate in a comment
|
|
241
|
+
* or string still goes to the scan, which decides.
|
|
242
|
+
*/
|
|
243
|
+
const RELATIVE_CANDIDATE = /["'`](?:\.\.?\/|[^"'`\n]*\\)/
|
|
244
|
+
|
|
138
245
|
/**
|
|
139
246
|
* Absolute resolved targets of the RELATIVE specifiers in `source`; one that
|
|
140
247
|
* does not resolve contributes the paths it names.
|
|
@@ -145,6 +252,7 @@ export function unprovidedBareImports(
|
|
|
145
252
|
* which is correct: an erased import cannot move a resolved value.
|
|
146
253
|
*/
|
|
147
254
|
function scanLocalImports(source: string, fromDir: string, loader: 'ts' | 'js'): string[] {
|
|
255
|
+
if (!RELATIVE_CANDIDATE.test(source)) return []
|
|
148
256
|
let specifiers: string[]
|
|
149
257
|
try {
|
|
150
258
|
specifiers = scanner(loader)
|
|
@@ -189,6 +297,12 @@ export interface ConfigImportOwnersArgs {
|
|
|
189
297
|
changed: readonly string[]
|
|
190
298
|
/** Already-selected projects; their configs need no scan. */
|
|
191
299
|
skip: ReadonlySet<string>
|
|
300
|
+
/**
|
|
301
|
+
* Each project dir's realpath, when the caller already has them: the
|
|
302
|
+
* containment pass needs the same answers, and asking twice cost 5,000
|
|
303
|
+
* realpaths at 5,000 projects (D-24).
|
|
304
|
+
*/
|
|
305
|
+
realDirs?: ReadonlyMap<string, string>
|
|
192
306
|
}
|
|
193
307
|
|
|
194
308
|
/**
|
|
@@ -198,11 +312,14 @@ export interface ConfigImportOwnersArgs {
|
|
|
198
312
|
* `/var/folders/…` while its realpath is `/private/var/folders/…`; comparing
|
|
199
313
|
* the two matches nothing and fails exactly like "found no imports".
|
|
200
314
|
*/
|
|
201
|
-
async function realDirIndex(
|
|
315
|
+
async function realDirIndex(
|
|
316
|
+
projects: readonly ProjectMeta[],
|
|
317
|
+
known: ReadonlyMap<string, string> | undefined,
|
|
318
|
+
): Promise<Map<string, string>> {
|
|
202
319
|
const out = new Map<string, string>()
|
|
203
320
|
await Promise.all(
|
|
204
321
|
projects.map(async (p) => {
|
|
205
|
-
out.set(await realpath(p.dir).catch(() => p.dir), p.name)
|
|
322
|
+
out.set(known?.get(p.dir) ?? (await realpath(p.dir).catch(() => p.dir)), p.name)
|
|
206
323
|
}),
|
|
207
324
|
)
|
|
208
325
|
return out
|
|
@@ -240,32 +357,41 @@ export async function configImportOwners(a: ConfigImportOwnersArgs): Promise<Set
|
|
|
240
357
|
}),
|
|
241
358
|
)
|
|
242
359
|
if (roots.size === 0) return selected
|
|
243
|
-
const dirToName = await realDirIndex(a.projects)
|
|
360
|
+
const dirToName = await realDirIndex(a.projects, a.realDirs)
|
|
244
361
|
|
|
245
362
|
// target → the files that import it. Reversed up front so one BFS from the
|
|
246
363
|
// changed set answers every root at once, instead of a walk per root.
|
|
247
364
|
const importedBy = new Map<string, string[]>()
|
|
248
365
|
const visited = new Set<string>()
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
366
|
+
// A level at a time, its files read together: one awaited read per
|
|
367
|
+
// config put 5,000 of them 200 ms behind (D-23).
|
|
368
|
+
let frontier = [...roots.keys()]
|
|
369
|
+
while (frontier.length > 0) {
|
|
370
|
+
const read = await Promise.all(
|
|
371
|
+
frontier.map(async (file) => {
|
|
372
|
+
if (visited.has(file)) return null
|
|
373
|
+
visited.add(file)
|
|
374
|
+
try {
|
|
375
|
+
return { file, source: await Bun.file(file).text() }
|
|
376
|
+
} catch {
|
|
377
|
+
return null // unreadable: no edges, and not this pass's problem to report
|
|
378
|
+
}
|
|
379
|
+
}),
|
|
380
|
+
)
|
|
381
|
+
frontier = []
|
|
382
|
+
for (const entry of read) {
|
|
383
|
+
if (entry === null) continue
|
|
384
|
+
const { file, source } = entry
|
|
385
|
+
const loader = TS_EXT.has(path.extname(file)) ? 'ts' : 'js'
|
|
386
|
+
for (const target of scanLocalImports(source, path.dirname(file), loader)) {
|
|
387
|
+
if (!target.startsWith(workspaceRoot + path.sep)) continue
|
|
388
|
+
if (target.split(path.sep).includes('node_modules')) continue
|
|
389
|
+
const list = importedBy.get(target)
|
|
390
|
+
if (list) list.push(file)
|
|
391
|
+
else importedBy.set(target, [file])
|
|
392
|
+
// Descend ONLY through unowned files — see the header.
|
|
393
|
+
if (ownerOf(target, dirToName) === undefined) frontier.push(target)
|
|
394
|
+
}
|
|
269
395
|
}
|
|
270
396
|
}
|
|
271
397
|
|