@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.
@@ -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 owned = projectsContaining(args.workspaceRoot, changed, args.projects)
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 = realpathOr(p.dir)
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)) out.push(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(projects: readonly ProjectMeta[]): Promise<Map<string, string>> {
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
- const queue = [...roots.keys()]
250
- while (queue.length > 0) {
251
- const file = queue.pop()!
252
- if (visited.has(file)) continue
253
- visited.add(file)
254
- let source: string
255
- try {
256
- source = await Bun.file(file).text()
257
- } catch {
258
- continue // unreadable: no edges, and not this pass's problem to report
259
- }
260
- const loader = TS_EXT.has(path.extname(file)) ? 'ts' : 'js'
261
- for (const target of scanLocalImports(source, path.dirname(file), loader)) {
262
- if (!target.startsWith(workspaceRoot + path.sep)) continue
263
- if (target.split(path.sep).includes('node_modules')) continue
264
- const list = importedBy.get(target)
265
- if (list) list.push(file)
266
- else importedBy.set(target, [file])
267
- // Descend ONLY through unowned files — see the header.
268
- if (ownerOf(target, dirToName) === undefined) queue.push(target)
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