@vzn/vx 0.0.101 → 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 +46 -15
- package/package.json +5 -5
- package/src/bin.ts +9 -1
- package/src/cache/inputs.ts +5 -3
- package/src/cli/index.ts +17 -0
- package/src/cli/last.ts +18 -3
- 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 +131 -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 +113 -5
|
@@ -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,110 @@ 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
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return out
|
|
138
|
+
}
|
|
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
|
|
134
189
|
}
|
|
135
190
|
return out
|
|
136
191
|
}
|
|
137
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
|
+
|
|
138
237
|
/**
|
|
139
238
|
* A textual pass before the scan: a relative specifier opens with a quoted
|
|
140
239
|
* `./` or `../`, or spells one with an escape, and most configs hold
|
|
@@ -198,6 +297,12 @@ export interface ConfigImportOwnersArgs {
|
|
|
198
297
|
changed: readonly string[]
|
|
199
298
|
/** Already-selected projects; their configs need no scan. */
|
|
200
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>
|
|
201
306
|
}
|
|
202
307
|
|
|
203
308
|
/**
|
|
@@ -207,11 +312,14 @@ export interface ConfigImportOwnersArgs {
|
|
|
207
312
|
* `/var/folders/…` while its realpath is `/private/var/folders/…`; comparing
|
|
208
313
|
* the two matches nothing and fails exactly like "found no imports".
|
|
209
314
|
*/
|
|
210
|
-
async function realDirIndex(
|
|
315
|
+
async function realDirIndex(
|
|
316
|
+
projects: readonly ProjectMeta[],
|
|
317
|
+
known: ReadonlyMap<string, string> | undefined,
|
|
318
|
+
): Promise<Map<string, string>> {
|
|
211
319
|
const out = new Map<string, string>()
|
|
212
320
|
await Promise.all(
|
|
213
321
|
projects.map(async (p) => {
|
|
214
|
-
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)
|
|
215
323
|
}),
|
|
216
324
|
)
|
|
217
325
|
return out
|
|
@@ -249,7 +357,7 @@ export async function configImportOwners(a: ConfigImportOwnersArgs): Promise<Set
|
|
|
249
357
|
}),
|
|
250
358
|
)
|
|
251
359
|
if (roots.size === 0) return selected
|
|
252
|
-
const dirToName = await realDirIndex(a.projects)
|
|
360
|
+
const dirToName = await realDirIndex(a.projects, a.realDirs)
|
|
253
361
|
|
|
254
362
|
// target → the files that import it. Reversed up front so one BFS from the
|
|
255
363
|
// changed set answers every root at once, instead of a walk per root.
|