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
//! 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:`, and an Elixir umbrella's `apps_path:`.
//! 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.
//! Test: `crates/trusty-mpm/src/core/manifest/workspace_tests.rs`.
use Cell;
use ;
/// Maximum member globs read from a single root manifest.
pub const MAX_WORKSPACE_PATTERNS: usize = 64;
/// Maximum workspace member directories probed for one detection call.
pub const MAX_WORKSPACE_MEMBERS: usize = 256;
/// Maximum total bytes read while discovering and probing workspace members.
pub 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 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".
/// Test: `budget_exhaustion_stops_probing`, `budget_is_shared_across_members`.
pub
/// 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.
/// Test: `oversized_manifest_is_not_read`, `budget_exhaustion_stops_probing`.
pub
/// 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: `[project_dir]` followed by every declared workspace member directory
/// that exists, de-duplicated, capped at [`MAX_WORKSPACE_MEMBERS`], in
/// deterministic (sorted-per-pattern) order.
/// Test: `npm_workspaces_array_members`, `pnpm_workspace_members`,
/// `elixir_umbrella_members`, `single_package_project_probes_only_root`.
pub
/// Every workspace member directory declared by `project_dir`'s root manifest.
///
/// Why/What: unions the three 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.
/// Test: `npm_workspaces_object_form`, `unknown_root_declares_no_members`.
/// 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`.
/// 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`.
/// 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`.
/// 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`.