trusty-mpm 1.8.2

trusty-mpm: unified multi-agent orchestration platform (core, daemon, CLI, TUI, Telegram)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
//! Monorepo workspace-member discovery for marker detection (#4760).
//!
//! Why: marker detection probes the PROJECT ROOT. That is correct for a
//! single-package repo and wrong for a monorepo, where the root manifest
//! declares members and the interesting files live inside them. A turborepo or
//! pnpm-workspaces root declares neither `"react"` nor `"next"` and carries no
//! `next.config.*`, so root-only probing loses `react-engineer`,
//! `nextjs-engineer`, and `svelte-engineer` on a Next.js monorepo that had them
//! before #4760 tightened the gates. An Elixir umbrella loses
//! `phoenix-engineer` the same way. Narrowing the gates was authorised for
//! single-package projects that never declared the framework; it was not
//! authorised for monorepos that do.
//! What: [`probe_roots`] returns the project root followed by every workspace
//! member directory the ROOT MANIFEST ITSELF declares — npm/yarn `workspaces`,
//! `pnpm-workspace.yaml` `packages:`, an Elixir umbrella's `apps_path:`, and
//! Cargo `[workspace] members` (#7781). Members a root manifest does NOT
//! declare are found by the nested walk in `super::nested`, not here.
//! Declared globs are honoured rather than conventional paths being assumed, so
//! a workspace using `libs/*` or `services/*` is covered without this module
//! guessing. [`ProbeBudget`] bounds the whole walk.
//!
//! **Bounds, and why these numbers.** A launch-path probe must not stall on a
//! pathological repository, so every dimension is capped:
//!
//! | Bound | Value | Rationale |
//! |---|---|---|
//! | Glob depth | one `*` segment, no recursive descent | npm/pnpm globs are overwhelmingly one level (`packages/*`, `apps/*`). Expanding one level makes enumeration O(entries in one directory) and never exponential. A `**` is treated as a single `*`. |
//! | Patterns read | [`MAX_WORKSPACE_PATTERNS`] = 64 | A manifest declaring more member globs than this is malformed, not large. |
//! | Members probed | [`MAX_WORKSPACE_MEMBERS`] = 256 | Bounds directory enumeration. Detection is a boolean OR — one matching member decides the answer — so the marginal member past 256 almost never changes the result. |
//! | Bytes read | [`MAX_WORKSPACE_PROBE_BYTES`] = 16 MiB per detection call | A real 256-member monorepo with few-KB manifests reads well under 1 MiB; 16 MiB leaves headroom while capping a hostile repo at a fraction of a second of I/O. |
//!
//! Exhausting a bound is **fail-closed**: probing stops and the unread members
//! are treated as carrying no marker, exactly as an unreadable file is. It never
//! errors and never blocks a launch. Failing closed SILENTLY is a different
//! thing, and #7781 removed it: a member or pattern cap that drops a declared
//! member now sets [`WorkspaceProbe::truncated`] and logs one WARN naming the
//! bound, so `super::framework::StackDetection::truncated` covers every
//! resource cap either discovery path applies. The byte budget is not one of
//! them — an unaffordable read is the same absence as an unreadable file.
//!
//! #7809: fail-closed-with-a-flag is correct for an in-process boolean OR over
//! one detection call, but it is not enough once a caller PERSISTS the answer
//! — e.g. writing a negative `skillOverrides` entry from a "marker absent"
//! result. A caller that writes persisted state from this module's output
//! must carry [`WorkspaceProbe::truncated`] alongside the boolean rather than
//! collapsing it away, so a later read can tell "genuinely absent" apart from
//! "budget exhausted, unknown" instead of baking a false negative into stored
//! state. This module already returns that state; applying it at every
//! persisted-write call site is tracked separately (#7781).
//! Test: `crates/trusty-mpm/src/core/manifest/workspace_tests.rs`.

use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::path::{Path, PathBuf};

/// Maximum member globs read from a single root manifest.
pub(crate) const MAX_WORKSPACE_PATTERNS: usize = 64;

/// Maximum workspace member directories probed for one detection call.
pub(crate) const MAX_WORKSPACE_MEMBERS: usize = 256;

/// Maximum total bytes read while discovering and probing workspace members.
pub(crate) const MAX_WORKSPACE_PROBE_BYTES: u64 = 16 * 1024 * 1024;

/// The largest single file this module will read.
///
/// Why: shared with `project_lang::MAX_MARKER_PROBE_BYTES` in intent — a
/// manifest larger than this is pathological, not large.
/// What: 1 MiB, in bytes.
pub(crate) const MAX_MANIFEST_BYTES: u64 = 1024 * 1024;

/// A shared, decrementing byte allowance for one detection call.
///
/// Why: the walk reads many small files. Capping each file individually still
/// permits `members × per-file-cap` in aggregate, which for 256 members and a
/// 1 MiB cap is 256 MiB — enough to stall a session launch. A single budget
/// threaded through the whole call bounds the aggregate, which is the quantity
/// that actually matters.
/// What: a `Cell<u64>` of remaining bytes. [`ProbeBudget::take`] reserves an
/// amount and reports whether it was available; once exhausted every subsequent
/// request fails, so the walk degrades to "no further markers found". It also
/// carries the per-path read memo (#7806) — see [`read_bounded`].
/// Test: `budget_exhaustion_stops_probing`, `budget_is_shared_across_members`,
/// `a_second_read_of_one_path_costs_no_budget`.
#[derive(Debug)]
pub(crate) struct ProbeBudget {
    remaining: Cell<u64>,
    /// #7806: what each path returned the first time it was read, so a second
    /// question about the same file costs neither I/O nor budget. Bounded by
    /// the budget itself — nothing is remembered that was not affordable to
    /// read, so the memo cannot hold more than `remaining`'s initial value.
    seen: RefCell<HashMap<PathBuf, Option<String>>>,
}

impl ProbeBudget {
    /// A budget of [`MAX_WORKSPACE_PROBE_BYTES`].
    pub(crate) fn new() -> Self {
        Self::of(MAX_WORKSPACE_PROBE_BYTES)
    }

    /// A budget of exactly `bytes` — for tests that need to force exhaustion.
    #[cfg(test)]
    pub(crate) fn with_bytes(bytes: u64) -> Self {
        Self::of(bytes)
    }

    /// A budget of `bytes` with an empty read memo.
    fn of(bytes: u64) -> Self {
        Self {
            remaining: Cell::new(bytes),
            seen: RefCell::new(HashMap::new()),
        }
    }

    /// Reserve `bytes`, returning `false` when the budget cannot cover it.
    pub(crate) fn take(&self, bytes: u64) -> bool {
        let left = self.remaining.get();
        if bytes > left {
            self.remaining.set(0);
            return false;
        }
        self.remaining.set(left - bytes);
        true
    }

    /// What `path` returned on an earlier [`read_bounded`], if it was asked.
    fn recall(&self, path: &Path) -> Option<Option<String>> {
        self.seen.borrow().get(path).cloned()
    }

    /// Record what `path` returned, so the next ask is answered from memory.
    fn remember(&self, path: &Path, body: Option<String>) {
        self.seen.borrow_mut().insert(path.to_path_buf(), body);
    }
}

/// Read a file if it is regular, within [`MAX_MANIFEST_BYTES`], and affordable.
///
/// Why: every read in this module goes through one place so the size cap, the
/// shared budget, and the fail-closed rule cannot drift apart.
/// What: `None` unless `path` is a regular file no larger than
/// [`MAX_MANIFEST_BYTES`], the budget covers its length, and it is valid UTF-8.
///
/// #7806: the answer — including a `None` — is remembered on `budget`, because
/// a content probe asks about the same `package.json` once per needle
/// (`"next"`, `"react"`, `"svelte"`), which read and charged for one file three
/// times and could exhaust the shared budget before the later framework checks
/// ran. The memo lives on the budget, so it is scoped to one detection call and
/// a re-read after an invalidated memo still sees the current file.
/// Test: `oversized_manifest_is_not_read`, `budget_exhaustion_stops_probing`,
/// `a_second_read_of_one_path_costs_no_budget`.
pub(crate) fn read_bounded(path: &Path, budget: &ProbeBudget) -> Option<String> {
    if let Some(remembered) = budget.recall(path) {
        return remembered;
    }
    let body = read_uncached(path, budget);
    budget.remember(path, body.clone());
    body
}

/// [`read_bounded`] without the memo — the actual I/O and the two bounds.
fn read_uncached(path: &Path, budget: &ProbeBudget) -> Option<String> {
    let meta = std::fs::metadata(path).ok()?;
    if !meta.is_file() || meta.len() > MAX_MANIFEST_BYTES {
        return None;
    }
    if !budget.take(meta.len()) {
        return None;
    }
    std::fs::read_to_string(path).ok()
}

/// Log the resource cap that dropped part of a bounded stack-detection scan.
///
/// Why: a `truncated` flag says detection is incomplete; only the log says
/// WHICH ceiling to raise, and raising the wrong one fixes nothing. WARN is
/// right here and only here: a repository large enough to exhaust a resource
/// cap is the exception, so the level matches how often an operator should
/// look. Shared with `super::nested` (#7781 round-3) so the declared-member
/// pass and the nested walk report a tripped cap in one voice.
/// What: one WARN naming the bound and the project it tripped on. The nested
/// walk's DEPTH bound does NOT come through here — see
/// `super::nested::debug_depth_limited`.
/// Test: `scanned_dirs_bound_reports_truncation`,
/// `declared_member_cap_is_reported_to_the_caller`.
pub(crate) fn warn_truncated(project_dir: &Path, bound: &str) {
    tracing::warn!(
        bound,
        project = %project_dir.display(),
        "#7781: stack detection was truncated by a scan bound; a project past \
         it is undetected"
    );
}

/// The directories marker detection should probe, and whether a cap cut them short.
///
/// Why: every bound in this module fails closed by returning FEWER roots, so a
/// capped list is byte-identical to a genuinely small workspace — the same
/// silent-nothing `super::nested` already reports on, left unreported here.
/// #7781 round-3: `super::framework::StackDetection::truncated` documents
/// itself as covering every resource cap, so the declared-member pass has to
/// contribute to it.
/// What: `roots` is the project root followed by the in-cap member directories;
/// `truncated` is true when [`MAX_WORKSPACE_PATTERNS`] or
/// [`MAX_WORKSPACE_MEMBERS`] dropped a declaration, beside one
/// [`warn_truncated`] naming that bound. `truncated` is OR-ed into
/// `super::project_lang::MarkerProbe::truncated`.
/// Test: `member_count_is_capped`, `pattern_count_is_capped`,
/// `single_package_project_probes_only_root`,
/// `declared_member_cap_is_reported_to_the_caller`.
pub(crate) struct WorkspaceProbe {
    /// The project root first, then each declared member directory that exists.
    pub(crate) roots: Vec<PathBuf>,
    /// True when a resource cap dropped a declared member, so `roots` is partial.
    pub(crate) truncated: bool,
}

/// The directories marker detection should probe: the root, then its members.
///
/// Why: the single entry point detection calls, so "where do we look" is stated
/// once. The root is always first, so a single-package project behaves exactly
/// as it did before this module existed.
/// What: a [`WorkspaceProbe`] whose roots are `[project_dir]` followed by every
/// declared workspace member directory that exists, de-duplicated, capped at
/// [`MAX_WORKSPACE_MEMBERS`], in deterministic (sorted-per-pattern) order, and
/// whose `truncated` reports whether a cap dropped a declared member. One WARN
/// at most, naming the bound that tripped, whichever of the two produced it.
/// Test: `npm_workspaces_array_members`, `pnpm_workspace_members`,
/// `elixir_umbrella_members`, `single_package_project_probes_only_root`,
/// `member_count_is_capped`.
pub(crate) fn probe_roots(project_dir: &Path, budget: &ProbeBudget) -> WorkspaceProbe {
    let scan = workspace_members(project_dir, budget);
    let mut truncated_by = scan.truncated_by;
    let mut roots = vec![project_dir.to_path_buf()];
    for member in scan.members {
        // `roots` carries the project root plus members, so the ceiling is
        // MAX_WORKSPACE_MEMBERS members = MAX_WORKSPACE_MEMBERS + 1 entries.
        if roots.len() > MAX_WORKSPACE_MEMBERS {
            // #7781: dropping a declared member is a RESOURCE cap; before this
            // it was a bare `break` that returned a short list and said nothing.
            truncated_by = Some("MAX_WORKSPACE_MEMBERS");
            break;
        }
        if !roots.contains(&member) {
            roots.push(member);
        }
    }
    if let Some(bound) = truncated_by {
        warn_truncated(project_dir, bound);
    }
    WorkspaceProbe {
        roots,
        truncated: truncated_by.is_some(),
    }
}

/// Declared members, plus the bound that dropped the ones beyond them.
///
/// Why: the caller needs the tripped bound's NAME, not just "something was
/// dropped", so its one WARN can say which ceiling to raise (#7781).
/// What: `truncated_by` is `None` when every declared pattern and member was
/// honoured, else the name of the constant that cut the list short.
struct MemberScan {
    members: Vec<PathBuf>,
    truncated_by: Option<&'static str>,
}

/// Every workspace member directory declared by `project_dir`'s root manifest.
///
/// Why/What: unions the four declaration formats, expands each declared glob,
/// and keeps only paths that exist and are directories. Reading the DECLARATION
/// rather than assuming `packages/*` means a workspace using any other layout is
/// covered without this module guessing at conventions. Both bounds it applies
/// report themselves through [`MemberScan::truncated_by`] rather than silently
/// shortening the list (#7781).
/// Test: `npm_workspaces_object_form`, `unknown_root_declares_no_members`,
/// `member_count_is_capped`, `pattern_count_is_capped`.
fn workspace_members(project_dir: &Path, budget: &ProbeBudget) -> MemberScan {
    let mut patterns = Vec::new();
    patterns.extend(npm_workspace_patterns(project_dir, budget));
    patterns.extend(pnpm_workspace_patterns(project_dir, budget));
    patterns.extend(elixir_umbrella_patterns(project_dir, budget));
    // #7781: a Cargo workspace declares its members the same way, and a member
    // that declares a stack the root does not was invisible before.
    patterns.extend(cargo_workspace_patterns(project_dir, budget));
    let over_pattern_cap = patterns.len() > MAX_WORKSPACE_PATTERNS;
    patterns.truncate(MAX_WORKSPACE_PATTERNS);

    let mut members = Vec::new();
    for pattern in patterns {
        for dir in expand_member_glob(project_dir, &pattern) {
            if members.len() >= MAX_WORKSPACE_MEMBERS {
                return MemberScan {
                    members,
                    truncated_by: Some("MAX_WORKSPACE_MEMBERS"),
                };
            }
            members.push(dir);
        }
    }
    MemberScan {
        members,
        truncated_by: over_pattern_cap.then_some("MAX_WORKSPACE_PATTERNS"),
    }
}

/// npm/yarn `workspaces` from `package.json` — array form or `{ packages: [] }`.
///
/// Why: both spellings are in the wild (yarn classic uses the object form when
/// it also declares `nohoist`), and a monorepo using the object form is not
/// meaningfully different from one using the array form.
/// What: parses `package.json` as JSON and returns the string entries of either
/// `workspaces` (array) or `workspaces.packages` (array). A malformed or absent
/// file yields no patterns.
/// Test: `npm_workspaces_array_members`, `npm_workspaces_object_form`,
/// `malformed_package_json_declares_no_members`.
fn npm_workspace_patterns(project_dir: &Path, budget: &ProbeBudget) -> Vec<String> {
    let raw = match read_bounded(&project_dir.join("package.json"), budget) {
        Some(raw) => raw,
        None => return Vec::new(),
    };
    let json: serde_json::Value = match serde_json::from_str(&raw) {
        Ok(v) => v,
        Err(_) => return Vec::new(),
    };
    let list = match json.get("workspaces") {
        Some(serde_json::Value::Array(a)) => a.clone(),
        Some(serde_json::Value::Object(o)) => match o.get("packages") {
            Some(serde_json::Value::Array(a)) => a.clone(),
            _ => return Vec::new(),
        },
        _ => return Vec::new(),
    };
    list.iter()
        .filter_map(|v| v.as_str().map(str::to_owned))
        .collect()
}

/// pnpm `packages:` entries from `pnpm-workspace.yaml`.
///
/// Why: pnpm declares members in a separate file, so a pnpm monorepo's
/// `package.json` says nothing about its members.
/// What: a deliberately minimal reader for the one shape this file has — a
/// `packages:` key followed by `- <glob>` list items. It reads a declaration in
/// a fixed format; it does not attempt general YAML. Quotes are stripped and a
/// `#` comment tail is ignored. Anything it cannot understand yields no
/// patterns, which is fail-closed.
/// Test: `pnpm_workspace_members`, `pnpm_workspace_ignores_other_keys`.
fn pnpm_workspace_patterns(project_dir: &Path, budget: &ProbeBudget) -> Vec<String> {
    let raw = match read_bounded(&project_dir.join("pnpm-workspace.yaml"), budget) {
        Some(raw) => raw,
        None => return Vec::new(),
    };
    let mut patterns = Vec::new();
    let mut in_packages = false;
    for line in raw.lines() {
        let trimmed = line.trim();
        if trimmed.is_empty() || trimmed.starts_with('#') {
            continue;
        }
        if let Some(item) = trimmed.strip_prefix("- ").or(trimmed.strip_prefix("-")) {
            if in_packages {
                let item = item.split('#').next().unwrap_or("").trim();
                let item = item.trim_matches(|c| c == '\'' || c == '"').trim();
                if !item.is_empty() {
                    patterns.push(item.to_string());
                }
            }
            continue;
        }
        // A non-list line at any indentation ends the `packages:` block.
        in_packages = trimmed == "packages:";
    }
    patterns
}

/// Cargo `[workspace] members` globs from the root `Cargo.toml` (#7781).
///
/// Why: a Cargo workspace root declares its crates and nothing else — no
/// dependency of a member appears in it. A member that carries a second stack
/// (a Tauri crate's `src-tauri`, a crate shipping a Python sidecar) was
/// therefore invisible to root-only probing, which is the root-only limit the
/// owner ruled insufficient on 2026-09-13.
/// What: parses the root `Cargo.toml` and returns the string entries of
/// `[workspace] members`. `exclude` is not honoured: probing one extra
/// directory can only add a marker the repository genuinely contains, and the
/// member cap already bounds the count. A file that is absent, oversized,
/// unaffordable, or not valid TOML yields no patterns — fail-closed, as
/// everywhere else in this module.
/// Test: `cargo_workspace_members`, `non_workspace_cargo_toml_declares_no_members`.
fn cargo_workspace_patterns(project_dir: &Path, budget: &ProbeBudget) -> Vec<String> {
    let Some(raw) = read_bounded(&project_dir.join("Cargo.toml"), budget) else {
        return Vec::new();
    };
    let Ok(doc) = raw.parse::<toml::Value>() else {
        return Vec::new();
    };
    doc.get("workspace")
        .and_then(|ws| ws.get("members"))
        .and_then(toml::Value::as_array)
        .map(|list| {
            list.iter()
                .filter_map(|v| v.as_str().map(str::to_owned))
                .collect()
        })
        .unwrap_or_default()
}

/// An Elixir umbrella's member directory from `mix.exs`'s `apps_path:`.
///
/// Why: an umbrella project's root `mix.exs` declares no dependencies of its
/// own — every app lives under `apps_path`, so a root-only probe never sees
/// `{:phoenix,` in an umbrella.
/// What: finds `apps_path:` and takes its quoted value, returning `<value>/*`.
/// `mix.exs` is Elixir source rather than a data format, so this reads the one
/// declaration it needs rather than attempting to evaluate the file.
/// Test: `elixir_umbrella_members`, `plain_mix_project_declares_no_members`.
fn elixir_umbrella_patterns(project_dir: &Path, budget: &ProbeBudget) -> Vec<String> {
    let raw = match read_bounded(&project_dir.join("mix.exs"), budget) {
        Some(raw) => raw,
        None => return Vec::new(),
    };
    for line in raw.lines() {
        let Some(rest) = line.split_once("apps_path:") else {
            continue;
        };
        let rest = rest.1.trim_start();
        let quote = match rest.chars().next() {
            Some(c @ ('"' | '\'')) => c,
            _ => continue,
        };
        if let Some(value) = rest[1..].split(quote).next()
            && !value.is_empty()
        {
            return vec![format!("{value}/*")];
        }
    }
    Vec::new()
}

/// Expand one declared member glob into existing directories under `root`.
///
/// Why: a declared pattern is usually a glob (`packages/*`), and expanding it
/// against the real filesystem is what turns a declaration into probe targets.
/// What: splits on `/` and walks literal segments until the first containing
/// `*`; that segment's position is expanded to the matching DIRECT child
/// directories (matched with the manifest's own glob vocabulary via
/// [`super::schema::matches_any`]). Segments after the glob are ignored — only
/// the member root is needed, and honouring them would require recursive
/// descent this deliberately does not do. `**` is treated as a single `*`. A
/// pattern with no glob resolves to one literal directory. A pattern containing
/// `..` is REJECTED so a declaration cannot walk outside the project.
/// Test: `glob_expands_one_level`, `literal_member_path`,
/// `pattern_with_parent_escape_is_rejected`, `double_star_is_one_level`.
fn expand_member_glob(root: &Path, pattern: &str) -> Vec<PathBuf> {
    if pattern.contains("..") {
        return Vec::new();
    }
    let segments: Vec<&str> = pattern.split('/').filter(|s| !s.is_empty()).collect();
    let glob_at = segments.iter().position(|s| s.contains('*'));

    let Some(idx) = glob_at else {
        // No glob: one literal member directory.
        let path = segments.iter().fold(root.to_path_buf(), |p, s| p.join(s));
        return if path.is_dir() {
            vec![path]
        } else {
            Vec::new()
        };
    };

    let parent = segments[..idx]
        .iter()
        .fold(root.to_path_buf(), |p, s| p.join(s));
    // `**` carries no more meaning here than `*` — see the doc comment.
    let seg = segments[idx].replace("**", "*");
    let patterns = [seg];

    let Ok(entries) = std::fs::read_dir(&parent) else {
        return Vec::new();
    };
    let mut out: Vec<PathBuf> = entries
        .flatten()
        .filter(|e| e.path().is_dir())
        .filter(|e| {
            e.file_name()
                .to_str()
                .is_some_and(|n| super::schema::matches_any(n, &patterns))
        })
        .map(|e| e.path())
        .collect();
    // Deterministic order so a capped walk is reproducible.
    out.sort();
    out
}

#[cfg(test)]
#[path = "workspace_tests.rs"]
mod tests;