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
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
//! Per-project MARKER EVALUATION for the framework manifest's gated categories
//! (#1941 / HR-2, #4760, #4765).
//!
//! Why: deploying the FULL polyglot agent roster (every `*-engineer`) plus every
//! platform-ops agent to every project regardless of stack is pure noise in the
//! delegation surface and a contributor to the catalog-drift false positives
//! (#1940). This module answers one question — *are this entry's declared
//! markers present in this project?* — for the gated categories
//! `framework-manifest.toml` declares.
//!
//! **What this module no longer owns (#4765).** It used to carry the marker
//! TABLES themselves (`LANGUAGE_ENGINEERS`, `PLATFORM_AGENTS`), which made the
//! gate a Rust constant while the category that used it was a manifest
//! declaration — two authorities for one entry, and two places to drift. The
//! owner ruling of 2026-08-04 ("authoritative agent/skill bundling should be in
//! framework-manifest.toml or project manifest.toml") moved the tables into the
//! manifest as [`super::schema::GatedAgent::markers`]. What is left here is the
//! MECHANISM: how a marker string is evaluated against a directory, bounded and
//! fail-closed. Declaration lives in the manifest; evaluation lives here.
//! What: [`MarkerProbe`] resolves a project's probe roots (the project dir, every
//! declared workspace member, and every nested manifest directory the bounded
//! walk in [`super::nested`] finds — #7781) and a shared [`ProbeBudget`] once,
//! then answers [`MarkerProbe::detect`] for any list of declared entries. A
//! possibly-EMPTY result is a valid answer, never an error — `super::framework`
//! composes it with the declared categories into the final
//! [`super::schema::AgentSet`]; this module never decides selection policy.
//! Test: `rust_workspace_detects_only_rust_engineer`, `js_project_detects_js_family`,
//! `unknown_project_detects_nothing`, `vercel_marker_detects_vercel_ops`,
//! `gcp_marker_detects_gcp_ops`, `no_platform_marker_detects_nothing`.

use std::collections::BTreeSet;
use std::path::{Path, PathBuf};

use super::nested::{MarkerAnchors, nested_probe_roots};
use super::schema::{AgentCategories, GatedAgent};
use super::workspace::{ProbeBudget, probe_roots};

/// Whether a single marker is present in `dir`.
///
/// Why: three marker kinds are needed, because three kinds of project actually
/// exist. Most stacks are identified by a fixed filename. .NET projects are
/// identified by extension globs (`*.csproj`, `*.sln`, `*.vbproj`) that no fixed
/// name captures. And React and Phoenix (#4760) are identified by NEITHER — React
/// ships no config file at all, and Phoenix ships nothing that plain Elixir
/// lacks, so for those the dependency declaration inside the project manifest is
/// the only reliable signal.
/// What: three forms, in precedence order.
///
/// * `<path>::<needle>` — a CONTENT PROBE. True when `<path>` exists, is within
///   the size cap, is affordable under `budget`, and contains `<needle>` as a
///   literal substring. This is a deliberate, bounded read of a declaration the
///   project wrote about itself — not an inference. Needles are written with
///   their surrounding syntax (`"react"` with quotes, `{:phoenix,` with the
///   tuple brace) so a substring match is an exact declaration match and cannot
///   catch a longer-named sibling package.
/// * `*.<ext>` — an extension glob over the direct children of `dir`.
/// * anything else — an exact path, tested for existence.
///
/// Any I/O failure (missing or unreadable path, non-UTF-8 contents) or an
/// exhausted budget yields `false`: a marker that cannot be read is a marker
/// that is not present.
/// Test: `content_probe_matches_a_declared_dependency`,
/// `content_probe_does_not_match_a_longer_named_sibling`,
/// `content_probe_ignores_an_oversized_file`,
/// `dotnet_csproj_detects_dotnet_engineer` (glob branch); every other detection
/// test exercises the exact-path branch.
fn marker_present(dir: &Path, marker: &str, budget: &ProbeBudget) -> bool {
    if let Some((path, needle)) = marker.split_once("::") {
        return file_contains(&dir.join(path), needle, budget);
    }
    if let Some(ext) = marker.strip_prefix("*.") {
        let suffix = format!(".{ext}");
        return std::fs::read_dir(dir)
            .map(|entries| {
                entries
                    .flatten()
                    .any(|e| e.file_name().to_str().is_some_and(|n| n.ends_with(&suffix)))
            })
            .unwrap_or(false);
    }
    dir.join(marker).exists()
}

/// Whether `path` is a readable, size- and budget-bounded file containing `needle`.
///
/// Why: factored out so the size guard, the shared budget, and the
/// failure-is-absence rule are stated once and testable on their own.
/// What: delegates the bounded read to [`super::workspace::read_bounded`], then
/// tests for the literal substring.
/// Test: `content_probe_matches_a_declared_dependency`,
/// `content_probe_ignores_an_oversized_file`.
fn file_contains(path: &Path, needle: &str, budget: &ProbeBudget) -> bool {
    super::workspace::read_bounded(path, budget).is_some_and(|body| body.contains(needle))
}

/// Whether `marker` is present at the project root or at any workspace member.
///
/// Why (#4760 monorepo regression): probing only the project root loses every
/// marker a monorepo keeps inside its members — a turborepo root declares
/// neither `"react"` nor `"next"` and carries no `next.config.*`, so a Next.js
/// monorepo would lose three engineers it had before the gates tightened. The
/// gate narrowing was authorised for single-package projects that never declared
/// the framework, not for monorepos that declare it one directory down.
/// What: `true` when [`marker_present`] holds for ANY entry in `roots` (the
/// project root first, then each declared workspace member — see
/// [`super::workspace::probe_roots`]).
/// Test: `npm_monorepo_member_declares_react`,
/// `elixir_umbrella_member_declares_phoenix`,
/// `monorepo_without_the_framework_still_misses_it`.
fn marker_present_in_any(roots: &[PathBuf], marker: &str, budget: &ProbeBudget) -> bool {
    roots
        .iter()
        .any(|root| marker_present(root, marker, budget))
}

/// A project's resolved probe roots and shared read budget (#4765).
///
/// Why: detection asks the same two questions of the same project — which
/// stacks, which platforms — and both must share ONE budget. Two independent
/// `ProbeBudget::new()` calls (what the two former free functions did) let a
/// pathological monorepo spend the aggregate cap twice. Resolving the roots
/// once also avoids re-reading the root manifest's workspace declaration per
/// category.
/// What: holds the probe roots from [`probe_roots`] (project dir first, then
/// every declared workspace member) and one [`ProbeBudget`] for the whole
/// detection call. Construct once per project, then call [`Self::detect`] for
/// each declared category.
/// Test: `budget_is_shared_across_members`, `npm_monorepo_member_declares_react`.
pub(crate) struct MarkerProbe {
    /// The project dir, every declared workspace member, then every nested
    /// manifest directory the bounded walk found (#7781).
    roots: Vec<PathBuf>,
    /// One aggregate read budget for the whole detection call.
    budget: ProbeBudget,
    /// True when a RESOURCE cap stopped EITHER discovery pass short, so every
    /// [`Self::detect`] answer from this probe may be incomplete (#7781).
    truncated: bool,
    /// True when the nested walk left children unread at its declared depth,
    /// which is the walk's designed scope rather than a failure (#7781).
    depth_limited: bool,
}

impl MarkerProbe {
    /// Resolve `project_dir`'s probe roots under a fresh shared budget.
    ///
    /// Why: #7781 — a repository that keeps a second stack in an UNDECLARED
    /// subdirectory (trusty-tools' own `crates/*/ui` Svelte apps) showed none of
    /// it to root-and-declared-members probing, so the PM prompt named
    /// `rust-engineer` alone for a repo with eight Svelte front ends. Owner
    /// ruling 2026-09-13: root-only stack detection is not enough.
    /// What: declared roots from [`probe_roots`] first — so a project with no
    /// nested manifest behaves exactly as before — then the bounded nested walk
    /// of [`nested_probe_roots`], whose anchors are derived from `categories`
    /// rather than restated here. Both share this call's one [`ProbeBudget`].
    /// Test: `nested_ui_package_is_found`,
    /// `mixed_stack_repo_detects_nested_web_engineers`,
    /// `rust_only_repo_detects_only_rust_engineer`.
    pub(crate) fn new(project_dir: &Path, categories: &AgentCategories) -> Self {
        let budget = ProbeBudget::new();
        let declared = probe_roots(project_dir, &budget);
        let mut roots = declared.roots;
        let anchors = MarkerAnchors::from_categories(categories);
        // #7781 round-2: the walk reports whether a bound cut it short, and that
        // flag travels with the probe so a caller can say detection is partial.
        // Round-3: two flags, because a resource cap and the declared depth are
        // not the same event.
        let nested = nested_probe_roots(project_dir, &anchors, &budget, &roots);
        roots.extend(nested.roots);
        Self {
            roots,
            budget,
            // #7781 round-3: EITHER discovery path can exhaust a resource cap,
            // and a member the declared pass dropped is often unreachable by the
            // nested walk (it lives under a skipped directory), so the walk's
            // flag alone under-reports.
            truncated: declared.truncated || nested.truncated,
            depth_limited: nested.depth_limited,
        }
    }

    /// Whether a RESOURCE cap cut the nested walk short, so detection may be
    /// partial.
    ///
    /// Why: a caller that renders or routes on the detected set must be able to
    /// say "this list may be incomplete" — the round-1 #7781 finding. Kept
    /// separate from [`Self::depth_limited`] so it stays rare enough to act on.
    /// What: the OR of both discovery paths' resource caps — [`probe_roots`]'s
    /// declared-member and pattern caps, and the [`nested_probe_roots`] flag set
    /// by `MAX_SCANNED_DIRS` or the shared member cap.
    /// Test: `scanned_dirs_bound_reports_truncation`,
    /// `a_small_tree_is_not_truncated`, `depth_limited_tree_is_not_truncated`,
    /// `declared_member_cap_is_reported_to_the_caller`.
    pub(crate) fn truncated(&self) -> bool {
        self.truncated
    }

    /// Whether the nested walk stopped at its declared depth.
    ///
    /// Why: informational only (#7781 round-3). Most real repositories nest
    /// deeper than the walk descends, so a consumer that fails closed on this
    /// would refuse nearly every project.
    /// What: the [`nested_probe_roots`] flag set by `MAX_NESTED_DEPTH`.
    /// Test: `depth_bound_stops_the_walk`, `depth_limited_tree_is_not_truncated`.
    pub(crate) fn depth_limited(&self) -> bool {
        self.depth_limited
    }

    /// Whether any of `entry`'s declared markers is present at any probe root.
    ///
    /// Why: a declared entry deploys on ANY of its markers, not all — a Java
    /// project is Maven OR Gradle, never required to be both.
    /// What: the OR over [`marker_present_in_any`] for `entry.markers`. An entry
    /// with no markers can never match; `parse_framework_manifest` rejects that
    /// case up front so it is unreachable from the bundled manifest.
    /// Test: `java_project_detects_java_engineer`,
    /// `content_probe_does_not_match_a_longer_named_sibling`.
    fn selects(&self, entry: &GatedAgent) -> bool {
        entry
            .markers
            .iter()
            .any(|marker| marker_present_in_any(&self.roots, marker, &self.budget))
    }

    /// The stems of `entries` whose declared markers this project shows.
    ///
    /// Why: the one detection primitive `super::framework::agent_scope_from`
    /// calls, once per gated category.
    /// What: a sorted, de-duplicated set of matching stems. An EMPTY result is a
    /// valid, expected answer meaning "this project shows none of these markers"
    /// — never confused with a manifest error, which is a distinct `Err` on a
    /// different code path (`super::framework::parse_framework_manifest`).
    /// Test: `rust_workspace_detects_only_rust_engineer`,
    /// `no_platform_marker_detects_nothing`.
    pub(crate) fn detect(&self, entries: &[GatedAgent]) -> BTreeSet<String> {
        entries
            .iter()
            .filter(|entry| self.selects(entry))
            .map(|entry| entry.stem.clone())
            .collect()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::core::manifest::framework::framework_agent_categories;
    use std::fs;
    use tempfile::TempDir;

    /// The stack (`language` + `framework`) stems the BUNDLED manifest's markers
    /// select for `dir`.
    ///
    /// Why: every detection test below asserts against the real shipped
    /// declaration, not a fixture — that is what makes them a check on
    /// `framework-manifest.toml`'s markers rather than on a second copy of them.
    /// Test: this helper IS the test surface for the tests that call it.
    fn detected_engineers(dir: &Path) -> BTreeSet<String> {
        let categories = framework_agent_categories().expect("bundled manifest must be valid");
        let probe = MarkerProbe::new(dir, &categories);
        let mut detected = probe.detect(&categories.language);
        detected.extend(probe.detect(&categories.framework));
        detected
    }

    /// The `platform` stems the BUNDLED manifest's markers select for `dir`.
    fn detected_platforms(dir: &Path) -> BTreeSet<String> {
        let categories = framework_agent_categories().expect("bundled manifest must be valid");
        MarkerProbe::new(dir, &categories).detect(&categories.platform)
    }

    fn touch(dir: &Path, name: &str) {
        write(dir, name, "");
    }

    fn write(dir: &Path, name: &str, body: &str) {
        let path = dir.join(name);
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent).unwrap();
        }
        fs::write(path, body).unwrap();
    }

    /// A `package.json` declaring exactly `deps` — the fixture the tightened
    /// JS-framework gates are written against.
    fn package_json(deps: &[&str]) -> String {
        let entries: Vec<String> = deps
            .iter()
            .map(|d| format!("    \"{d}\": \"1.0.0\""))
            .collect();
        format!(
            "{{\n  \"dependencies\": {{\n{}\n  }}\n}}\n",
            entries.join(",\n")
        )
    }

    #[test]
    fn rust_workspace_detects_only_rust_engineer() {
        // A Cargo workspace must detect rust-engineer and NO other language
        // engineer.
        let tmp = TempDir::new().unwrap();
        touch(tmp.path(), "Cargo.toml");

        let detected = detected_engineers(tmp.path());
        assert!(detected.contains("rust-engineer"), "rust must be detected");
        for foreign in [
            "python-engineer",
            "javascript-engineer",
            "typescript-engineer",
            "golang-engineer",
            "java-engineer",
            "ruby-engineer",
            "php-engineer",
            "dart-engineer",
            "elixir-engineer",
            "phoenix-engineer",
            "react-engineer",
            "nextjs-engineer",
            "svelte-engineer",
            "tauri-engineer",
            "dotnet-engineer",
        ] {
            assert!(
                !detected.contains(foreign),
                "{foreign} must not be detected in a Rust-only project"
            );
        }
    }

    #[test]
    fn js_project_detects_js_family() {
        // A JS/TS project detects the whole LANGUAGE family — `package.json`
        // selects javascript-engineer and (with tsconfig.json, or on its own)
        // typescript-engineer. The framework engineers are a separate question,
        // covered by `bare_js_project_detects_no_framework_engineer`.
        let tmp = TempDir::new().unwrap();
        write(tmp.path(), "package.json", &package_json(&["lodash"]));
        touch(tmp.path(), "tsconfig.json");

        let detected = detected_engineers(tmp.path());
        assert!(detected.contains("javascript-engineer"));
        assert!(detected.contains("typescript-engineer"));
        assert!(
            !detected.contains("rust-engineer"),
            "a JS project must not detect a foreign language engineer"
        );
    }

    #[test]
    fn java_project_detects_java_engineer() {
        // `java-engineer` declares FOUR markers; any ONE selects it. Maven and
        // Gradle are checked independently so a regression that silently
        // required all of them (an AND where the manifest means OR) is caught.
        for marker in ["pom.xml", "build.gradle", "build.gradle.kts"] {
            let tmp = TempDir::new().unwrap();
            touch(tmp.path(), marker);
            assert!(
                detected_engineers(tmp.path()).contains("java-engineer"),
                "`{marker}` alone must select java-engineer"
            );
        }
    }

    #[test]
    fn dotnet_csproj_detects_dotnet_engineer() {
        // The `*.<ext>` GLOB branch of `marker_present`, which no fixed-name
        // marker exercises: .NET project files are named after the project, so
        // the manifest declares `*.csproj` / `*.sln` / `*.vbproj`.
        for marker in ["MyApp.csproj", "MyApp.sln", "Legacy.vbproj"] {
            let tmp = TempDir::new().unwrap();
            touch(tmp.path(), marker);
            assert!(
                detected_engineers(tmp.path()).contains("dotnet-engineer"),
                "`{marker}` must match the extension glob and select dotnet-engineer"
            );
        }

        // The glob must not fire on a same-stem file with a different extension.
        let tmp = TempDir::new().unwrap();
        touch(tmp.path(), "MyApp.csproj.bak");
        assert!(
            !detected_engineers(tmp.path()).contains("dotnet-engineer"),
            "`.csproj.bak` does not end in `.csproj` and must not match"
        );
    }

    #[test]
    fn unknown_project_detects_nothing() {
        // A directory with no recognized language marker detects nothing — the
        // signal `framework::agent_scope_from` reads as "unknown project type".
        let tmp = TempDir::new().unwrap();
        touch(tmp.path(), "README.md");
        assert!(detected_engineers(tmp.path()).is_empty());
    }

    #[test]
    fn bare_js_project_detects_no_framework_engineer() {
        // #4760 behavior change: a plain `package.json` with no framework
        // dependency selects the LANGUAGE engineers only. Before #4760 it also
        // selected react/nextjs/svelte, which is the accident this closes.
        let tmp = TempDir::new().unwrap();
        write(tmp.path(), "package.json", &package_json(&["lodash"]));

        let detected = detected_engineers(tmp.path());
        assert!(detected.contains("javascript-engineer"));
        assert!(detected.contains("typescript-engineer"));
        for framework in ["react-engineer", "nextjs-engineer", "svelte-engineer"] {
            assert!(
                !detected.contains(framework),
                "{framework} must NOT fire on a framework-free package.json"
            );
        }
        assert!(!detected.contains("rust-engineer"));
        assert!(!detected.contains("python-engineer"));
    }

    #[test]
    fn react_dependency_detects_react_engineer() {
        // The declared dependency is the signal, and it is exact.
        let tmp = TempDir::new().unwrap();
        write(
            tmp.path(),
            "package.json",
            &package_json(&["react", "react-dom"]),
        );
        let detected = detected_engineers(tmp.path());
        assert!(detected.contains("react-engineer"));
        assert!(
            detected.contains("javascript-engineer"),
            "language engineers still fire"
        );
        assert!(!detected.contains("nextjs-engineer"));
        assert!(!detected.contains("svelte-engineer"));
    }

    #[test]
    fn content_probe_does_not_match_a_longer_named_sibling() {
        // `"react-dom"`/`"react-native"`/`"@types/react"` must NOT satisfy the
        // `"react"` probe — the quotes make it an exact key match. This is the
        // assertion that makes the probe a declaration match, not a heuristic.
        let tmp = TempDir::new().unwrap();
        write(
            tmp.path(),
            "package.json",
            &package_json(&["react-dom", "react-native", "@types/react"]),
        );
        assert!(!detected_engineers(tmp.path()).contains("react-engineer"));

        // Same rule on the other three probes.
        let nx = TempDir::new().unwrap();
        write(
            nx.path(),
            "package.json",
            &package_json(&["next-auth", "next-themes"]),
        );
        assert!(!detected_engineers(nx.path()).contains("nextjs-engineer"));

        let sv = TempDir::new().unwrap();
        write(sv.path(), "package.json", &package_json(&["svelte-check"]));
        assert!(!detected_engineers(sv.path()).contains("svelte-engineer"));
    }

    #[test]
    fn nextjs_detects_by_config_or_dependency() {
        // `next.config.*` is the primary signal, but it is OPTIONAL in Next.js,
        // so the dependency declaration must also work on its own.
        for marker in ["next.config.js", "next.config.mjs", "next.config.ts"] {
            let tmp = TempDir::new().unwrap();
            touch(tmp.path(), marker);
            assert!(
                detected_engineers(tmp.path()).contains("nextjs-engineer"),
                "{marker} must select nextjs-engineer"
            );
        }
        let dep_only = TempDir::new().unwrap();
        write(
            dep_only.path(),
            "package.json",
            &package_json(&["next", "react"]),
        );
        assert!(detected_engineers(dep_only.path()).contains("nextjs-engineer"));
    }

    #[test]
    fn svelte_detects_by_config_or_dependency() {
        for marker in ["svelte.config.js", "svelte.config.ts"] {
            let tmp = TempDir::new().unwrap();
            touch(tmp.path(), marker);
            assert!(
                detected_engineers(tmp.path()).contains("svelte-engineer"),
                "{marker} must select svelte-engineer"
            );
        }
        let dep_only = TempDir::new().unwrap();
        write(dep_only.path(), "package.json", &package_json(&["svelte"]));
        assert!(detected_engineers(dep_only.path()).contains("svelte-engineer"));
    }

    #[test]
    fn plain_elixir_detects_elixir_engineer_only() {
        // #4760: `mix.exs` alone is the ELIXIR marker. Before this change it
        // selected `phoenix-engineer`, giving a plain Elixir project a Phoenix
        // specialist and no general Elixir engineer at all.
        let tmp = TempDir::new().unwrap();
        write(
            tmp.path(),
            "mix.exs",
            "defmodule My.MixProject do\n  defp deps, do: [{:jason, \"~> 1.4\"}]\nend\n",
        );
        let detected = detected_engineers(tmp.path());
        assert!(detected.contains("elixir-engineer"));
        assert!(
            !detected.contains("phoenix-engineer"),
            "a non-Phoenix Elixir project must not get the Phoenix specialist"
        );
    }

    #[test]
    fn phoenix_dependency_detects_both_elixir_and_phoenix() {
        // A Phoenix app is an Elixir app, so it gets both. `{:phoenix,` must not
        // be satisfied by `{:phoenix_live_view,` alone.
        let tmp = TempDir::new().unwrap();
        write(
            tmp.path(),
            "mix.exs",
            "defp deps do\n  [{:phoenix, \"~> 1.7.14\"}, {:phoenix_live_view, \"~> 1.0\"}]\nend\n",
        );
        let detected = detected_engineers(tmp.path());
        assert!(detected.contains("phoenix-engineer"));
        assert!(
            detected.contains("elixir-engineer"),
            "Phoenix apps are Elixir apps"
        );

        let live_only = TempDir::new().unwrap();
        write(
            live_only.path(),
            "mix.exs",
            "defp deps do\n  [{:phoenix_live_view, \"~> 1.0\"}]\nend\n",
        );
        assert!(
            !detected_engineers(live_only.path()).contains("phoenix-engineer"),
            "the phoenix_live_view dep alone must not satisfy the phoenix probe"
        );
    }

    #[test]
    fn content_probe_matches_a_declared_dependency() {
        // The probe helper on its own: present-and-matching vs present-and-not.
        let tmp = TempDir::new().unwrap();
        write(tmp.path(), "mix.exs", "[{:phoenix, \"~> 1.7\"}]");
        let budget = ProbeBudget::new();
        assert!(file_contains(
            &tmp.path().join("mix.exs"),
            "{:phoenix,",
            &budget
        ));
        assert!(!file_contains(
            &tmp.path().join("mix.exs"),
            "{:ecto,",
            &budget
        ));
        assert!(
            !file_contains(&tmp.path().join("absent.exs"), "{:phoenix,", &budget),
            "a missing file is an absent marker, never an error"
        );
        assert!(
            !file_contains(tmp.path(), "{:phoenix,", &budget),
            "a directory is not a readable marker file"
        );
    }

    #[test]
    fn content_probe_ignores_an_oversized_file() {
        // The size guard must win even when the needle IS present, so a
        // pathological manifest cannot be read into memory.
        let tmp = TempDir::new().unwrap();
        let mut body = String::from("{:phoenix, \"~> 1.7\"}");
        body.push_str(&"x".repeat(super::super::workspace::MAX_MANIFEST_BYTES as usize + 1));
        write(tmp.path(), "mix.exs", &body);
        let budget = ProbeBudget::new();
        assert!(!file_contains(
            &tmp.path().join("mix.exs"),
            "{:phoenix,",
            &budget
        ));
    }

    #[test]
    fn npm_monorepo_member_declares_react() {
        // #4760 monorepo regression: a turborepo/pnpm root declares no
        // framework dependency, so root-only probing lost react/next/svelte on
        // a Next.js monorepo. The member declaration must be found.
        let tmp = TempDir::new().unwrap();
        write(
            tmp.path(),
            "package.json",
            r#"{"name":"root","workspaces":["apps/*","packages/*"]}"#,
        );
        write(
            tmp.path(),
            "apps/web/package.json",
            r#"{"dependencies":{"react":"^18","next":"^15"}}"#,
        );
        touch(tmp.path(), "apps/web/next.config.js");

        let detected = detected_engineers(tmp.path());
        assert!(detected.contains("react-engineer"), "{detected:?}");
        assert!(detected.contains("nextjs-engineer"), "{detected:?}");
        assert!(
            detected.contains("javascript-engineer"),
            "the root package.json still selects the language engineers"
        );
    }

    #[test]
    fn monorepo_without_the_framework_still_misses_it() {
        // The walk must not make every monorepo match everything: a workspace
        // whose members declare no framework gets no framework engineer.
        let tmp = TempDir::new().unwrap();
        write(
            tmp.path(),
            "package.json",
            r#"{"workspaces":["packages/*"]}"#,
        );
        write(
            tmp.path(),
            "packages/util/package.json",
            r#"{"dependencies":{"lodash":"^4"}}"#,
        );
        let detected = detected_engineers(tmp.path());
        assert!(detected.contains("javascript-engineer"));
        for framework in ["react-engineer", "nextjs-engineer", "svelte-engineer"] {
            assert!(!detected.contains(framework), "{framework} must not fire");
        }
    }

    #[test]
    fn elixir_umbrella_member_declares_phoenix() {
        // An umbrella root's mix.exs declares no deps of its own.
        let tmp = TempDir::new().unwrap();
        write(
            tmp.path(),
            "mix.exs",
            "def project do\n  [apps_path: \"apps\"]\nend\n",
        );
        write(
            tmp.path(),
            "apps/my_app_web/mix.exs",
            "defp deps do\n  [{:phoenix, \"~> 1.7\"}]\nend\n",
        );
        let detected = detected_engineers(tmp.path());
        assert!(detected.contains("elixir-engineer"), "root mix.exs");
        assert!(
            detected.contains("phoenix-engineer"),
            "the umbrella member's phoenix dep must be found: {detected:?}"
        );
    }

    #[test]
    fn monorepo_member_platform_marker_is_found() {
        // Platform detection uses the same probe roots — a Vercel monorepo keeps
        // vercel.json in the deployed app, not at the root.
        let tmp = TempDir::new().unwrap();
        write(tmp.path(), "package.json", r#"{"workspaces":["apps/*"]}"#);
        write(tmp.path(), "apps/web/vercel.json", "{}");
        let detected = detected_platforms(tmp.path());
        assert!(detected.contains("vercel-ops"), "{detected:?}");
        assert!(!detected.contains("gcp-ops"));
    }

    #[test]
    fn vercel_marker_detects_vercel_ops() {
        // Each Vercel marker selects vercel-ops and nothing else.
        for marker in ["vercel.json", ".vercelignore", ".vercel/project.json"] {
            let tmp = TempDir::new().unwrap();
            touch(tmp.path(), marker);
            let detected = detected_platforms(tmp.path());
            assert!(
                detected.contains("vercel-ops"),
                "vercel-ops must be detected for {marker}"
            );
            assert!(
                !detected.contains("gcp-ops"),
                "gcp-ops must NOT be detected for {marker}"
            );
        }
    }

    #[test]
    fn gcp_marker_detects_gcp_ops() {
        // Each GCP marker selects gcp-ops and nothing else.
        for marker in [
            "app.yaml",
            "cloudbuild.yaml",
            "cloudbuild.yml",
            ".gcloudignore",
        ] {
            let tmp = TempDir::new().unwrap();
            touch(tmp.path(), marker);
            let detected = detected_platforms(tmp.path());
            assert!(
                detected.contains("gcp-ops"),
                "gcp-ops must be detected for {marker}"
            );
            assert!(!detected.contains("vercel-ops"));
        }
    }

    #[test]
    fn no_platform_marker_detects_nothing() {
        // A project with language markers but no platform marker detects zero
        // platforms — an explicit, valid empty answer, never an error.
        let tmp = TempDir::new().unwrap();
        touch(tmp.path(), "Cargo.toml");
        touch(tmp.path(), "package.json");
        assert!(
            detected_platforms(tmp.path()).is_empty(),
            "no platform marker → empty platform set"
        );
        assert!(
            !detected_engineers(tmp.path()).is_empty(),
            "the same project DOES detect languages — so the empty platform set \
             cannot be an artifact of an unreadable directory"
        );
    }

    #[test]
    fn an_entry_with_no_markers_never_selects() {
        // #4765: markers are a manifest field now, so "declared but ungated" is
        // representable in the type. It must select nothing — and
        // `parse_framework_manifest` rejects it, so it can never ship.
        let tmp = TempDir::new().unwrap();
        touch(tmp.path(), "Cargo.toml");
        let ungated = GatedAgent {
            stem: "ungated-engineer".to_string(),
            markers: Vec::new(),
        };
        let categories = framework_agent_categories().expect("bundled manifest must be valid");
        assert!(
            MarkerProbe::new(tmp.path(), &categories)
                .detect(&[ungated])
                .is_empty(),
            "an entry declaring no markers can never be selected"
        );
    }
}