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
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
//! Unit tests for `daemon::doctor` — split out of `doctor.rs` (test-file
//! budget: 1500 SLOC; the inline module pushed the production file over its
//! 500-SLOC cap once issue #2940 added the `hooks_contamination` /
//! `hooks_foreign_conflict` probes).
//! What: exercises `index_present`, the memory/search reachability probes,
//! the managed-workspace-scoped `agents` probe, the full `run_doctor` check
//! roster, the `oauth_token` advisory check, and the worktree-orphan scan.
//! The `gh_account` check has its own suite in `doctor_gh_account_tests.rs`
//! (#5032).
//! Test: this module IS the test suite for `super`.

use super::*;

// #7685: the sidecar probes these tests drive now live beside `doctor.rs`
// rather than inside it. Same functions, same assertions — only the module
// boundary moved, so the import is what changed and nothing else.
use super::doctor_sidecars::{check_search, expected_search_index_id, index_present, probe_health};
use crate::core::doctor::{CheckStatus, DoctorCheck};
use crate::daemon::search_rpc;

#[test]
fn index_present_matches_each_shape() {
    // Bare string array.
    let strings = serde_json::json!(["other", "trusty-mpm"]);
    assert!(index_present(&strings, "trusty-mpm"));
    // Objects with an `id` field, nested under `indexes`.
    let objects = serde_json::json!({"indexes": [{"id": "trusty-mpm"}]});
    assert!(index_present(&objects, "trusty-mpm"));
    // Objects with a `name` field.
    let named = serde_json::json!([{"name": "trusty-mpm"}]);
    assert!(index_present(&named, "trusty-mpm"));
    // Absent index.
    let missing = serde_json::json!(["a", "b"]);
    assert!(!index_present(&missing, "trusty-mpm"));
}

#[tokio::test]
async fn memory_unreachable_is_fail() {
    // #6286: a path under a directory that cannot exist is refused by the
    // kernel immediately, so the probe must fail cleanly rather than hang.
    // #8225: the dead socket is passed to `probe_health` directly, as the
    // sibling memory tests do. Setting `TRUSTY_MEMORY_SOCKET` process-wide
    // leaked into a concurrent `run_doctor` and split its `memory` row across
    // transports in `parity_doctor_agrees_across_transports`.
    let socket = std::path::Path::new("/nonexistent/trusty-memory/trusty-memory.sock");
    let addr = socket.display().to_string();
    let check = probe_health("memory", "trusty-memory", socket, &addr).await;
    assert_eq!(check.status, CheckStatus::Fail);
}

#[test]
fn expected_search_index_id_derives_from_project_dir_not_hardcoded() {
    // #4003: `tm doctor`'s search check used to hardcode the literal expected
    // index id "trusty-mpm" (the crate name), which diverges from a repo's
    // actual registered index id — this repo registers as "trusty-tools" —
    // so a healthy, fully-indexed project permanently warned "index
    // missing". Pin that the id is now DERIVED from the project root's
    // basename (the same rule `trusty_common::derive_index_id` applies
    // everywhere else an index id is resolved), not a fixed constant: a
    // project literally named "trusty-mpm" must resolve to "trusty-mpm", and
    // one named anything else must resolve to ITS OWN name, never the old
    // hardcoded literal.
    let tmp = tempfile::tempdir().unwrap();

    let other_repo = tmp.path().join("trusty-tools");
    std::fs::create_dir_all(other_repo.join(".git")).unwrap();
    let other_id = expected_search_index_id(Some(&other_repo));
    assert_eq!(other_id, "trusty-tools");
    assert_ne!(
        other_id, "trusty-mpm",
        "must not fall back to the old hardcoded literal for an unrelated project"
    );

    let mpm_repo = tmp.path().join("trusty-mpm");
    std::fs::create_dir_all(mpm_repo.join(".git")).unwrap();
    let mpm_id = expected_search_index_id(Some(&mpm_repo));
    assert_eq!(mpm_id, "trusty-mpm");

    // Nested working directory inside the repo still resolves to the git
    // root's basename, not the nested dir's own name.
    let nested = other_repo.join("crates").join("trusty-mpm");
    std::fs::create_dir_all(&nested).unwrap();
    assert_eq!(expected_search_index_id(Some(&nested)), "trusty-tools");
}

/// Point [`search_rpc::search_socket`] at `socket` for the duration of a test,
/// and clear the override when dropped.
///
/// Why `Drop` rather than a pair of statements: a failing assertion strands the
/// override pointing at a deleted `TempDir`, and the next `#[serial]` sibling
/// then probes a socket under a directory that no longer exists.
struct SearchSocketOverride;

impl SearchSocketOverride {
    fn point_at(socket: &std::path::Path) -> Self {
        unsafe {
            std::env::set_var(search_rpc::TRUSTY_SEARCH_SOCKET_ENV, socket);
        }
        Self
    }
}

impl Drop for SearchSocketOverride {
    fn drop(&mut self) {
        unsafe {
            std::env::remove_var(search_rpc::TRUSTY_SEARCH_SOCKET_ENV);
        }
    }
}

/// A stand-in trusty-search daemon answering `search.health` and
/// `search.indexes.list` (#6285).
///
/// Why the method names are matched by literal: trusty-mpm has no Cargo edge on
/// trusty-search, so a rig cannot import them either. A name that drifted on
/// either side answers `method_not_found`, which `check_search` reports as an
/// unhealthy daemon — which is what these tests assert on.
async fn spawn_search_daemon(indexes: serde_json::Value) -> crate::uds_mock::MockUdsDaemon {
    crate::uds_mock::spawn(move |method: &str, _params: serde_json::Value| {
        let indexes = indexes.clone();
        let method = method.to_string();
        Box::pin(async move {
            match method.as_str() {
                m if m == search_rpc::METHOD_HEALTH => {
                    Ok(serde_json::json!({"status": "ok", "indexes": 1}))
                }
                m if m == search_rpc::METHOD_INDEXES_LIST => Ok(indexes),
                other => Err(crate::uds_mock::RpcError::new(
                    -32601,
                    format!("unknown method {other:?}"),
                )),
            }
        })
    })
    .await
}

/// Why: an absent daemon must be a clean `Fail`, not a hang — a path under a
/// directory that cannot exist is refused by the kernel immediately (#6285).
/// Test: itself.
#[tokio::test]
#[serial_test::serial]
async fn search_unreachable_is_fail() {
    let _override =
        SearchSocketOverride::point_at(std::path::Path::new("/nonexistent/trusty-search/ts.sock"));
    let tmp = tempfile::tempdir().unwrap();
    let check = check_search(tmp.path(), None).await;
    assert_eq!(check.status, CheckStatus::Fail, "{}", check.message);
}

/// Why: the whole probe over a real socket — a daemon that answers
/// `search.health` and lists the project's own index is `Ok`.
/// Test: itself.
#[tokio::test]
#[serial_test::serial]
async fn search_reports_the_expected_index() {
    let project = tempfile::tempdir().unwrap();
    std::fs::create_dir_all(project.path().join(".git")).unwrap();
    let expected = expected_search_index_id(Some(project.path()));

    let daemon = spawn_search_daemon(serde_json::json!({"indexes": [expected.clone()]})).await;
    let _override = SearchSocketOverride::point_at(daemon.socket());

    let home = tempfile::tempdir().unwrap();
    let check = check_search(home.path(), Some(project.path())).await;
    assert_eq!(check.status, CheckStatus::Ok, "{}", check.message);
    assert!(check.message.contains(&expected), "{}", check.message);
}

/// Why (#4003): a healthy daemon that does not hold this project's index is a
/// `Warn`, never an `Ok` — the PM's "search before grep" rule silently returns
/// nothing in that state.
/// Test: itself.
#[tokio::test]
#[serial_test::serial]
async fn search_without_the_expected_index_is_warn() {
    let project = tempfile::tempdir().unwrap();
    std::fs::create_dir_all(project.path().join(".git")).unwrap();

    let daemon = spawn_search_daemon(serde_json::json!({"indexes": ["some-other-index"]})).await;
    let _override = SearchSocketOverride::point_at(daemon.socket());

    let home = tempfile::tempdir().unwrap();
    let check = check_search(home.path(), Some(project.path())).await;
    assert_eq!(check.status, CheckStatus::Warn, "{}", check.message);
    assert!(check.message.contains("missing"), "{}", check.message);
}

#[tokio::test]
async fn agents_check_probes_the_managed_config_tier_not_the_workspace() {
    // Issue #4409 supersedes #2149's workspace scoping for AGENTS: bundled
    // agents no longer deploy per-workspace at all, so probing
    // `<workspace>/.claude/agents/` would now report every healthy install as
    // broken. The probe must name the tm-managed `CLAUDE_CONFIG_DIR` tier —
    // the ONE place a bundled agent can legitimately be — even when a
    // `project_dir` is supplied.
    //
    // The status itself is deliberately not asserted: the deploy tier is
    // machine-global, so a provisioned workstation and a bare CI runner
    // legitimately disagree. `doctor_fs_checks`'s `agents_*` tests cover every
    // status branch hermetically.
    let project = tempfile::tempdir().unwrap();
    let report = run_doctor(Some(project.path()), None, &[], None).await;
    let agents_check = report
        .checks
        .iter()
        .find(|c| c.name == "agents")
        .expect("agents check present");

    let workspace_tier = project.path().join(".claude").join("agents");
    assert!(
        !agents_check
            .message
            .contains(&workspace_tier.display().to_string()),
        "the agents probe must not read the workspace tier any more: {}",
        agents_check.message
    );

    // Asserted as a relative suffix, not an absolute path: the deploy tier is
    // home-relative, and sibling `#[serial]` tests move `$HOME` around, so
    // resolving it a second time here would race them.
    let expected_suffix = std::path::Path::new(".trusty-tools")
        .join(crate::core::trusty_tools_config::CRATE_NAME)
        .join(crate::core::trusty_tools_config::MANAGED_CLAUDE_CONFIG_SUBDIR)
        .join("agents");
    assert!(
        agents_check
            .message
            .contains(&expected_suffix.display().to_string()),
        "message must name the tm-managed agent deploy tier (…/{}): {}",
        expected_suffix.display(),
        agents_check.message
    );
}

#[tokio::test]
async fn unmanaged_cwd_audits_the_operator_home_tier() {
    // #5867: `tm doctor` sends the process cwd as `project`, so `run_doctor`
    // took the `for_managed_workspace` arm for any directory at all. That
    // pointed `claude_skills_dir()` at `<cwd>/.claude/skills` — the same path
    // the "project" tier candidate builds — and `skill_deploy_tiers`' dedup
    // dropped the duplicate, leaving `~/.claude/skills` unaudited. The `skills`
    // probe reads the same root, so it names the directory it looked at and is
    // the observable half of that resolution.
    //
    // Before the fix this asserted false: the message read
    // "<tmp>/.claude/skills does not exist".
    let project = tempfile::tempdir().unwrap();
    let report = run_doctor(Some(project.path()), None, &[], None).await;
    let skills = report
        .checks
        .iter()
        .find(|c| c.name == "skills")
        .expect("skills check present");

    let workspace_tier = project.path().join(".claude").join("skills");
    assert!(
        !skills
            .message
            .contains(&workspace_tier.display().to_string()),
        "an unregistered directory is not a managed workspace and must not be \
         probed as one: {}",
        skills.message
    );
}

#[tokio::test]
async fn a_registered_workspace_still_gets_the_workspace_layout() {
    // The other arm of #5867: a directory a live session was provisioned into
    // MUST keep the #2149/#1931 workspace scoping, or a managed workspace with
    // an empty roster goes back to reporting a false `Ok` off the operator's
    // own populated `$HOME/.claude`.
    let project = tempfile::tempdir().unwrap();
    let active = vec![project.path().to_path_buf()];
    let report = run_doctor(Some(project.path()), None, &active, None).await;
    let skills = report
        .checks
        .iter()
        .find(|c| c.name == "skills")
        .expect("skills check present");

    let workspace_tier = project.path().join(".claude").join("skills");
    assert!(
        skills
            .message
            .contains(&workspace_tier.display().to_string()),
        "a registered workspace must still be probed at its own tier: {}",
        skills.message
    );
}

#[test]
fn is_managed_workspace_matches_only_a_registered_path() {
    let registered = tempfile::tempdir().unwrap();
    let other = tempfile::tempdir().unwrap();
    let active = vec![registered.path().to_path_buf()];

    assert!(is_managed_workspace(registered.path(), &active));
    assert!(!is_managed_workspace(other.path(), &active));
    assert!(
        !is_managed_workspace(registered.path(), &[]),
        "with no live sessions nothing is a managed workspace"
    );
}

#[test]
fn is_managed_workspace_sees_through_a_symlinked_path() {
    // On macOS a workspace under `/tmp` is reached through a `/private/tmp`
    // symlink, so the daemon's recorded path and the cwd the CLI sends can be
    // two spellings of one directory. A raw `==` would miss the match and drop
    // a real managed workspace back to the home layout.
    let real = tempfile::tempdir().unwrap();
    let link_parent = tempfile::tempdir().unwrap();
    let link = link_parent.path().join("workspace-link");
    #[cfg(unix)]
    std::os::unix::fs::symlink(real.path(), &link).unwrap();
    #[cfg(not(unix))]
    return;

    let active = vec![real.path().to_path_buf()];
    assert!(
        is_managed_workspace(&link, &active),
        "a symlink to a registered workspace is that workspace"
    );
}

/// The canonicalize-failure fallback must not promote an unmanaged directory.
///
/// Why (#5867): `resolve` falls back to the raw path on ANY `canonicalize`
/// error, and that branch had no coverage — the two tests above only use paths
/// that exist. It is the branch the doc comment makes its correctness claim
/// about, and a false `true` here is #5867's original bug: an arbitrary cwd
/// audited as though a session had been provisioned into it.
/// What: drives all three failure kinds — an absent directory (`NotFound`), a
/// broken symlink (`NotFound` on the target), and a directory behind an
/// unreadable parent (`PermissionDenied`) — on both sides of the comparison,
/// and asserts every one answers `false`.
/// Test: this function IS the test.
#[test]
fn an_uncanonicalizable_path_is_not_a_managed_workspace() {
    let registered = tempfile::tempdir().unwrap();
    let scratch = tempfile::tempdir().unwrap();
    let active = vec![registered.path().to_path_buf()];

    let absent = scratch.path().join("never-created");
    assert!(
        !is_managed_workspace(&absent, &active),
        "a directory that is not on disk is not a provisioned workspace"
    );

    #[cfg(unix)]
    {
        let broken = scratch.path().join("dangling-link");
        std::os::unix::fs::symlink(scratch.path().join("no-such-target"), &broken).unwrap();
        assert!(
            !is_managed_workspace(&broken, &active),
            "a symlink to nothing resolves to nothing, not to a workspace"
        );
    }

    // The failure can sit on the RECORDED side too: a session's workspace_path
    // that has since been deleted must not start matching arbitrary cwds.
    let stale = vec![scratch.path().join("reaped-workspace")];
    assert!(
        !is_managed_workspace(registered.path(), &stale),
        "a stale recorded path must not match a live, unrelated directory"
    );
}

/// A `PermissionDenied` canonicalize is the same answer as an absent path.
///
/// Why (#5867): `resolve` collapses every `canonicalize` error into the raw
/// path, so the doc comment's "safe answer" claim has to hold for the
/// permissions kind, not just `NotFound`.
/// What: puts a real directory behind a `0o000` parent so `canonicalize` fails
/// with `PermissionDenied`, and asserts it still does not match a registered
/// workspace.
/// Test: this function IS the test.
#[cfg(unix)]
#[test]
fn an_unreadable_directory_is_not_a_managed_workspace() {
    use std::os::unix::fs::PermissionsExt;
    let registered = tempfile::tempdir().unwrap();
    let scratch = tempfile::tempdir().unwrap();
    let locked = scratch.path().join("locked");
    let inner = locked.join("workspace");
    std::fs::create_dir_all(&inner).unwrap();
    std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
    if std::fs::canonicalize(&inner).is_ok() {
        eprintln!("skipping: cannot deny traversal on this platform/privilege level");
        let _ = std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755));
        return;
    }

    let managed = is_managed_workspace(&inner, &[registered.path().to_path_buf()]);
    let _ = std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755));

    assert!(
        !managed,
        "a directory tm cannot even resolve is not one to deploy into"
    );
}

/// The fallback still matches a recorded path by its exact spelling.
///
/// Why (#5867): the fallback is only safe because a raw-path comparison can
/// match nothing but a path already in `active_workspace_paths`. Stating that
/// as prose in the doc comment is what left the branch untested; this pins the
/// boundary from the other side, so a future "just return false on any
/// canonicalize error" edit has to face the case it would change.
/// What: passes a path that exists on neither side but is spelled identically
/// to a recorded workspace, and asserts it matches — a recorded workspace,
/// under the name it was recorded with, is never the arbitrary cwd #5867 is
/// about.
/// Test: this function IS the test.
#[test]
fn an_absent_path_still_matches_the_recorded_spelling_of_itself() {
    let scratch = tempfile::tempdir().unwrap();
    let reaped = scratch.path().join("reaped-workspace");
    assert!(
        is_managed_workspace(&reaped, std::slice::from_ref(&reaped)),
        "an unresolvable path is compared verbatim, so it matches only its own \
         recorded spelling — never an unregistered directory"
    );
}

#[tokio::test]
async fn run_doctor_produces_sixty_eight_checks() {
    // Issue #2158 added the `deployment` probe (nine → ten); issue #2246
    // adds `oauth_token` (ten → eleven); issue #2876 adds `skill_staleness`
    // and `legacy_sources` (eleven → thirteen); DOC-42 / issue #2889 adds
    // `agent_skills` (thirteen → fourteen); issue #2906 review splits that
    // into `agent_skills` + `agent_skills_prose_hints` (fourteen → fifteen);
    // issue #2940 adds `hooks_contamination` + `hooks_foreign_conflict`
    // (fifteen → seventeen); issue #2333 adds `output_style_staleness`
    // (seventeen → eighteen); issue #2997 adds `tcc_taint` (eighteen →
    // nineteen); issue #3453 part 2 adds `output_style_legacy_ids` (nineteen
    // → twenty); issue #3427 adds `scaffold_tracking` (twenty →
    // twenty-one); issue #2867 adds `push_guard` (twenty-one →
    // twenty-two); issue #4451 adds `agent_reachability` (twenty-two →
    // twenty-three); issue #4467 adds `transcript_saving` (twenty-three →
    // twenty-four); issue #4442 adds `asset_tier` (twenty-four →
    // twenty-five); issue #2919 adds `worktree_disk` (twenty-five →
    // twenty-six); issue #4605 adds `skill_unmanaged` (twenty-six →
    // twenty-seven); issue #4033 adds `binary_provenance` (twenty-seven →
    // twenty-eight); issue #5045 adds `search_index_pin` (twenty-nine →
    // thirty); the stray-`.mcp.json` probe adds `stray_mcp_json` (thirty →
    // thirty-one); issue #3605 adds `base_clone` (thirty-one → thirty-two);
    // issue #5007 adds `session_store` and issue #6469 adds `tmux_options`
    // (thirty-two → thirty-four); issue #6529 adds `pty_headroom` and issue
    // #6535 adds `log_drain` (thirty-four → thirty-six); issue #6586 adds
    // `skill_project_tier` (thirty-six → thirty-seven); issue #6649 adds
    // `asset_duplicates` (thirty-seven → thirty-eight); issue #4947 adds
    // `skill_reachability` (thirty-eight → thirty-nine); issue #7311 adds
    // `rtk` (forty-three → forty-four); #7422 adds `session_scope`
    // (forty-four → forty-five); issue #7424 adds `startup_context`
    // (forty-five → forty-six); issue #7490 adds `hooks_missing_tm_group`
    // (forty-six → forty-seven); issue #7497 adds `disk_usage`
    // (forty-seven → forty-eight); issue #7616 adds
    // `instruction_compression` (forty-eight → forty-nine); issue #7617 adds
    // `statusline` (forty-nine → fifty); issue #7685 adds `auto_memory`
    // (fifty → fifty-one); issue #7673 adds `ancestor_claude_md` (fifty-one →
    // fifty-two); issue #7867 renames `instruction_compression` to
    // `instruction_fold` and adds `tool_output_compression` (fifty-two →
    // fifty-three); issue #6556 adds `stop_spool` (fifty-four →
    // fifty-five); issue #8236 adds `launchd_secrets` and `credential_reach`
    // (fifty-six → fifty-eight); issue #8415 adds `launchd_process_type`
    // (fifty-eight → fifty-nine), then `tmux_priority` (fifty-nine → sixty);
    // issue #8482 adds `bundled_asset_lag` (sixty → sixty-one); issue #8453
    // adds `session_profile` (sixty-one → sixty-two); issue #8926 adds
    // `tcp_listeners` (sixty-two → sixty-three).
    // #8980 adds session_claudes (sixty-three → sixty-four).
    // #9018 adds pm_guard (sixty-four → sixty-five).
    // #8378 adds content (sixty-five → sixty-six).
    // #9091 adds org_accounts (sixty-six → sixty-seven).
    // #8994 adds worktree_registry (sixty-seven → sixty-eight).
    //
    // The test NAME had drifted four additions behind the tally above by the
    // time #6586 landed — it still read `thirty_two`. Renaming it is part of
    // adding a check, not optional bookkeeping.
    // #1905's stale-skill cleanup is deliberately NOT a `run_doctor` probe
    // — see the `run_doctor` doc.
    let report = run_doctor(None, None, &[], None).await;
    let names: Vec<&str> = report.checks.iter().map(|c| c.name.as_str()).collect();
    let expected = [
        "instructions",
        "agents",
        "agent_reachability",
        "asset_tier",
        // #6649: one asset name claimed twice inside ONE tier.
        "asset_duplicates",
        "transcript_saving",
        "skills",
        "skill_source",
        // #4947: every rostered bundled skill reaches a tier the harness reads.
        "skill_reachability",
        "output_style",
        "output_style_staleness",
        "output_style_legacy_ids",
        "deployment",
        "skill_staleness",
        "skill_unmanaged",
        // #6586: a bundled skill an older binary left at a project's own tier.
        "skill_project_tier",
        "legacy_sources",
        "legacy_overrides",
        // #8453: the resolved session profile, or why it fell back to PM.
        "session_profile",
        // #9018: whether `[pm_guard] enabled = false` turned the guard off.
        "pm_guard",
        "org_accounts",
        // #7616: whether the instruction fold saved this project anything, or
        // is inactive — the state a daemon-log line used to be the only
        // evidence of. Named `instruction_fold` since #7867.
        "instruction_fold",
        // #7867: what the 💸 segment itself measures — the `compress` and
        // `divert` rows it folds, and when the last one landed.
        "tool_output_compression",
        // #7617: whether the 💸 segment can render at all — the `statusLine`
        // entry in each settings tier, its two inputs' readability, and the
        // render rule itself.
        "statusline",
        "agent_skills",
        "agent_skills_prose_hints",
        "memory",
        "search",
        "search_index_pin",
        "worktrees",
        "worktree_disk",
        // #7497: the MOUNT the worktree store sits on, against
        // `disk.max_usage_pct` — the number that decides whether the next
        // worktree is created at all.
        "disk_usage",
        // #8994: count and GiB per project from the worktree ledger alone.
        "worktree_registry",
        // See #6868: this machine's Rust build settings — the shared cargo
        // target directory, the job count, and the sccache posture — plus the
        // prefix line a PM pastes into an engineer brief.
        "rust_build_env",
        // #3605: the base clone a live worktree resolves through.
        "base_clone",
        "gh_account",
        // #7311: rtk is an install dependency; never run rtk init.
        "rtk",
        // #7097: whether the issues opened this week carry the milestone,
        // project and component label the ticketing standard requires.
        "issue_audit_recent",
        "oauth_token",
        "launchd_secrets",
        "credential_reach",
        "hooks_contamination",
        "hooks_foreign_conflict",
        "hooks_build_tree_binary",
        // #7490: the lifecycle event a tm-provisioned file has no group for.
        "hooks_missing_tm_group",
        "tcc_taint",
        "scaffold_tracking",
        "push_guard",
        // #7171: the git-maintenance-storm detection probes.
        "maintenance_config",
        "maintenance_processes",
        // #7965: each background sweep's kill switch and last pass duration.
        "background_sweeps",
        "binary_provenance",
        // #8482: the binary's own embedded skill assets against `origin/main` —
        // the row above reads a registry ledger, never the source tree.
        "bundled_asset_lag",
        // #8378 PR-C: the runtime content source, pin and sha256 (ADR-0064).
        "content",
        // #5007: `sessions.json` integrity — a corrupt store blocks every write.
        "session_store",
        // #8980: an untrusted `session-claudes.json` seals the registry.
        "session_claudes",
        // #6556: undelivered SubagentStop records waiting on disk, or a spool
        // the hook cannot write into.
        "stop_spool",
        "stray_mcp_json",
        // #7422: what this project's sessions will NOT load, and where to opt in.
        "session_scope",
        // #6469: the tmux server globals a resurrect restore leaves unset.
        "tmux_options",
        // #8415: the launchd ProcessType of the jobs that start tmux servers.
        "launchd_process_type",
        // #8415: the observed priority of the running tmux server.
        "tmux_priority",
        // #8926: trusty-* TCP listeners against the ADR-0032 allowlist.
        "tcp_listeners",
        // #6529: pseudo-terminal headroom — a session leak exhausts it and the
        // next spawn fails with a bare ENXIO.
        "pty_headroom",
        // #6535: whether the cloud log drain is on, where it points, and
        // whether its last pass landed.
        "log_drain",
        // #7424: this project's turn-1 startup context against its ceiling.
        "startup_context",
        // #7685: whether Claude Code's own auto memory is off for this project.
        "auto_memory",
        // #7673: every CLAUDE.md ABOVE the project root.
        "ancestor_claude_md",
    ];
    assert_eq!(names, expected);
    // Count derived from the list above, never a standalone literal:
    // adding a check is then a one-line edit here (#4090 review LOW-1).
    assert_eq!(report.checks.len(), expected.len());
}

#[test]
fn oauth_token_check_warns_when_relocated_with_no_token_or_key() {
    // Issue #2246: the exact at-risk configuration — CLAUDE_CONFIG_DIR
    // relocated (as every managed spawn does), no stored token, no
    // resolvable OAuth env var — must Warn with an actionable hint.
    let check = build_oauth_token_check(true, false, false);
    assert_eq!(check.status, CheckStatus::Warn);
    assert!(check.message.contains("tm auth set-token"));
    assert!(check.message.contains("claude setup-token"));
}

#[test]
fn oauth_token_check_ok_when_token_stored() {
    // A stored token file resolves for a managed spawn → satisfied.
    let check = build_oauth_token_check(true, true, false);
    assert_eq!(check.status, CheckStatus::Ok);
}

#[test]
fn oauth_token_check_ok_when_env_var_set() {
    // A CLAUDE_CODE_OAUTH_TOKEN env var is the higher-precedence source
    // resolve_oauth_token consults → also satisfies the managed-session
    // auth requirement even without a stored token file.
    let check = build_oauth_token_check(true, false, true);
    assert_eq!(check.status, CheckStatus::Ok);
}

#[test]
fn oauth_token_check_warns_when_only_api_key_set() {
    // #2246 false-negative fix: ambient ANTHROPIC_API_KEY does NOT satisfy
    // the managed-session auth requirement — every managed spawn strips it
    // via `env -u ANTHROPIC_API_KEY`, so relying on it still triggers the
    // login loop. With only the API key set (no token file, no OAuth env
    // var), the check MUST still Warn. `build_oauth_token_check` no longer
    // even takes the API-key flag as an input; this test pins the behaviour
    // by driving the same relocated/no-token state the api-key-only
    // environment produces.
    let check = build_oauth_token_check(true, false, false);
    assert_eq!(
        check.status,
        CheckStatus::Warn,
        "an ambient ANTHROPIC_API_KEY must NOT count as managed-session auth"
    );
}

#[test]
fn oauth_token_check_ok_when_not_relocated() {
    // Home unresolved → no managed config dir → the #2246 divergence
    // cannot occur (there is nothing to relocate).
    let check = build_oauth_token_check(false, false, false);
    assert_eq!(check.status, CheckStatus::Ok);
}

/// Counts for `repos_root`, taken from the SHARED reconciled inventory (#5947).
///
/// Why: the whole point of the fix is that doctor no longer has its own orphan
/// rule, so every worktree test here must feed it the same classification
/// `prune-worktrees` and `reconcile-worktrees` read. Computing the counts any
/// other way in a test would re-introduce the third implementation the fix
/// removed, in the one place nobody would look for it.
fn counts_for(repos_root: &std::path::Path) -> WorktreeOrphanCounts {
    let report = crate::session_manager::worktree_reconcile::reconcile_worktrees(
        repos_root,
        &[],
        &[],
        chrono::Utc::now(),
        &[],
    );
    WorktreeOrphanCounts::from_reconcile(&report)
}

#[tokio::test]
async fn worktrees_no_repos_root_is_ok() {
    // Fix 7 (#1840): absence of a managed workspace root is NORMAL — not every
    // operator uses in-project worktree sessions. Return Ok, not Warn.
    let check = check_worktrees(None, None).await;
    assert_eq!(check.status, CheckStatus::Ok);
    assert!(check.message.contains("no managed workspace"));
}

#[tokio::test]
async fn worktrees_no_orphans_is_ok() {
    // #4207: a REAL `git worktree add`, since discovery is derived from git's
    // registry — a `mkdir` is not a candidate and would make this pass for the
    // wrong reason. The fixture canonicalizes its own root, so the /tmp vs
    // /private/tmp symlink hazard (#1845 item 2) is handled there.
    let fx = crate::session_manager::worktree_git_fixture::GitWorktreeFixture::new();
    let _wt = fx.add_worktree("session-abc");
    let check = check_worktrees(Some(&fx.repos_root), Some(counts_for(&fx.repos_root))).await;
    assert_eq!(check.status, CheckStatus::Ok, "{}", check.message);
}

#[tokio::test]
async fn worktrees_unowned_worktree_is_not_an_orphan() {
    // #5947 (fault 2): THE false positive. Two real worktrees, neither carrying
    // an ownership sentinel. The old probe counted every git-registered
    // worktree no live session claimed, so it warned "1 orphaned" here — and on
    // the dogfood fleet, "198 orphaned" where `prune-worktrees` and
    // `reconcile-worktrees` both found zero. A worktree with no sentinel is
    // never auto-reclaimable, so it is not an orphan.
    let fx = crate::session_manager::worktree_git_fixture::GitWorktreeFixture::new();
    let _a = fx.add_worktree("session-live");
    let _b = fx.add_worktree("session-dead");
    let counts = counts_for(&fx.repos_root);
    assert_eq!(counts.orphaned, 0, "reconcile must report zero orphans");
    let check = check_worktrees(Some(&fx.repos_root), Some(counts)).await;
    assert_eq!(check.status, CheckStatus::Ok, "{}", check.message);
    assert!(check.message.contains("no orphaned worktrees found"));
}

#[tokio::test]
async fn worktrees_with_orphan_is_warn() {
    // A GENUINE orphan: admitted by git, sentinel names a provably-gone owner,
    // tree clean — the same shape `clean_ownerless_admitted_worktree_is_orphaned`
    // pins on the reconcile side.
    let fx = crate::session_manager::worktree_git_fixture::GitWorktreeFixture::new();
    let _live = fx.add_worktree("session-live");
    let dead = fx.add_worktree("session-dead");
    crate::session_manager::worktree_git_fixture::GitWorktreeFixture::stamp_reclaimable_sentinel(
        &dead,
    );
    let check = check_worktrees(Some(&fx.repos_root), Some(counts_for(&fx.repos_root))).await;
    assert_eq!(check.status, CheckStatus::Warn, "{}", check.message);
    assert!(check.message.contains("1 orphaned"));
    // #5947 (fault 1): the hint must name the verb that exists.
    assert!(check.message.contains(WORKTREE_REMEDIATION_COMMAND));
    assert!(
        !check.message.contains("prune --worktrees"),
        "the nonexistent flag must not come back: {}",
        check.message
    );
}

#[tokio::test]
async fn worktrees_without_a_reconciled_inventory_is_unknown() {
    // #5947: no inventory means no count. Reporting Ok would hide real orphans
    // and Warn would invent them, so the probe says it could not observe.
    let fx = crate::session_manager::worktree_git_fixture::GitWorktreeFixture::new();
    let check = check_worktrees(Some(&fx.repos_root), None).await;
    assert_eq!(check.status, CheckStatus::Unknown, "{}", check.message);
    assert!(check.message.contains("not established"));
}

// ---- Issues #4005 / #4001: doctor must observe, not infer ----

/// Spawn a throwaway HTTP listener that answers `GET /health` with `body`
/// after waiting `delay`.
///
/// Serve a canned `memory.health` answer on a temp Unix socket, after `delay`.
///
/// Why: the other probe tests point at a path nothing serves (which only ever
/// refuses), so nothing else in this suite can express "a daemon that IS
/// serving". Both #4005 (a serving daemon reported unreachable) and #4001 (a
/// wedged daemon reported healthy) are about what doctor concludes from a REAL
/// answer. #6286 moved that answer from an HTTP body to a JSON-RPC frame; the
/// shared `uds_mock` binds the socket so this file does not re-derive one.
/// What: returns the daemon handle — dropping it stops the accept loop.
/// Test: used by the #4005/#4001 tests below.
async fn spawn_health_listener(
    body: serde_json::Value,
    delay: std::time::Duration,
) -> crate::uds_mock::MockUdsDaemon {
    crate::uds_mock::spawn(move |_method: &str, _params: serde_json::Value| {
        let body = body.clone();
        Box::pin(async move {
            tokio::time::sleep(delay).await;
            Ok(body)
        })
    })
    .await
}

/// A `/health` body from a healthy, fully-ready daemon.
fn healthy_body() -> serde_json::Value {
    serde_json::json!({
        "status": "ok",
        "daemon_state": "ready",
        "worker": {"in_flight": 0, "wedged": false, "stall_tracking_ok": true},
    })
}

/// Why (issue #4001): THE false positive. During the #3992 incident six
/// trusty-memory threads were parked in `concurrent_open::backoff_sleep_ms`
/// with a `memory_remember` hung ~1800 s, and `tm doctor` reported HEALTHY the
/// entire time — because it only ever asked "did the socket answer?". Here the
/// listener is fully live and returns a clean HTTP 200; only the body reveals
/// the wedge. Before this change doctor returned `Ok` for exactly this input.
/// What: asserts a wedge-reporting daemon is NOT `Ok`, and that the message
/// names the wedge so an operator is not sent looking elsewhere.
/// Test: itself.
#[tokio::test]
async fn memory_wedged_worker_pool_is_not_ok() {
    let body = serde_json::json!({
        "status": "ok",
        "daemon_state": "ready",
        "worker": {"in_flight": 6, "oldest_age_secs": 1800, "wedged": true},
    });
    let daemon = spawn_health_listener(body, std::time::Duration::ZERO).await;
    let addr = daemon.socket().display().to_string();
    let check = probe_health("memory", "trusty-memory", daemon.socket(), &addr).await;

    assert_ne!(
        check.status,
        CheckStatus::Ok,
        "a live listener over a wedged worker pool must NOT report healthy: {}",
        check.message
    );
    assert_eq!(check.status, CheckStatus::Fail);
    assert!(
        check.message.contains("WEDGED"),
        "message must name the wedge: {}",
        check.message
    );
    assert!(
        check.message.contains("1800"),
        "message must carry the observed age: {}",
        check.message
    );
}

/// Why (issue #4005): THE false negative. A daemon that is genuinely serving
/// must not be called unreachable just because `/health` is slower than the
/// probe's budget — `/health` samples RSS/CPU and enumerates file descriptors,
/// work the MCP path never does. This listener answers correctly but takes
/// 2.5 s, which the old 2 s single-shot budget turned into
/// "trusty-memory unreachable at ...".
/// What: asserts a slow-but-serving daemon reports `Ok`.
/// Test: itself.
#[tokio::test]
async fn memory_slow_but_serving_daemon_is_ok() {
    let daemon =
        spawn_health_listener(healthy_body(), std::time::Duration::from_millis(2500)).await;
    let addr = daemon.socket().display().to_string();
    let check = probe_health("memory", "trusty-memory", daemon.socket(), &addr).await;

    assert_eq!(
        check.status,
        CheckStatus::Ok,
        "a serving daemon that is merely slow must not be reported unreachable: {}",
        check.message
    );
    assert!(
        !check.message.contains("unreachable"),
        "message must not claim unreachable: {}",
        check.message
    );
}

/// Why (issue #4005, warm-up case): the issue explicitly notes the failure may
/// only reproduce right after a `tm` restart, and asks that the fix handle
/// warm-up specifically rather than reporting a hard failure. A warming daemon
/// is serving (recall falls back to the non-embedder path) so it is not a
/// failure; it is not fully ready either, so it is not `Ok`.
/// What: asserts warm-up is `Warn` — neither `Fail` nor `Ok`.
/// Test: itself.
#[tokio::test]
async fn memory_warming_is_warn_not_fail() {
    let body = serde_json::json!({
        "status": "ok",
        "daemon_state": "warming",
        "worker": {
            "in_flight": 1,
            "oldest_age_secs": 2,
            "wedged": false,
            "stall_tracking_ok": true,
        },
    });
    let daemon = spawn_health_listener(body, std::time::Duration::ZERO).await;
    let addr = daemon.socket().display().to_string();
    let check = probe_health("memory", "trusty-memory", daemon.socket(), &addr).await;

    assert_eq!(
        check.status,
        CheckStatus::Warn,
        "post-restart warm-up must be a warning, not a failure: {}",
        check.message
    );
    assert!(
        check.message.contains("WARMING"),
        "message must name the warm-up: {}",
        check.message
    );
}

/// Why (the unifying principle): a probe that times out has learned NOTHING.
/// Rendering that as `Fail` is the #4005 false negative; rendering it as `Ok`
/// would be a false positive. It must render as its own state, and that state
/// must never be healthy.
/// What: points the probe at a daemon that accepts the connection and takes a
/// minute to answer, and asserts `Unknown` — explicitly neither `Ok` nor
/// `Fail`.
/// Test: itself.
#[tokio::test]
async fn memory_timeout_is_unknown_not_fail() {
    // Accept the connection but take far longer than the budget to answer, so
    // the probe times out rather than being refused.
    let daemon = spawn_health_listener(healthy_body(), std::time::Duration::from_secs(60)).await;
    let addr = daemon.socket().display().to_string();

    let check = tokio::time::timeout(
        std::time::Duration::from_secs(90),
        probe_health("memory", "trusty-memory", daemon.socket(), &addr),
    )
    .await
    .expect("probe must stay bounded");

    assert_eq!(
        check.status,
        CheckStatus::Unknown,
        "a timed-out probe must be UNKNOWN, not a verdict: {}",
        check.message
    );
    assert!(
        check.message.contains("could not be determined"),
        "message must say so plainly: {}",
        check.message
    );
}

/// Why (issue #4001, forward-compatibility): a daemon too old to report worker
/// occupancy cannot support a healthy verdict — doctor has no observation to
/// base one on. Claiming `Ok` there would reintroduce exactly the inference
/// this fix removes.
/// What: asserts a 2xx body with no `worker` block is `Unknown`.
/// Test: itself.
#[tokio::test]
async fn health_body_without_worker_block_is_unknown() {
    let body = serde_json::json!({"status": "ok", "daemon_state": "ready"});
    let daemon = spawn_health_listener(body, std::time::Duration::ZERO).await;
    let addr = daemon.socket().display().to_string();
    let check = probe_health("memory", "trusty-memory", daemon.socket(), &addr).await;

    assert_eq!(
        check.status,
        CheckStatus::Unknown,
        "no worker observation means health is undetermined: {}",
        check.message
    );
}

/// Why (issue #4001): `tm doctor` reads the same payload as `trusty-memory
/// doctor` and must reach the same verdict. A daemon whose palace-lock stall
/// detector stopped reports `status: degraded`, which mapped to `Warn` — a
/// green run on the detector's own failure. A daemon predating the detector
/// omits the field entirely while still reporting `worker.wedged`, so it never
/// reached the pre-#4001 arm: that is the 2026-09-13 incident build reading
/// HEALTHY.
/// What: asserts both shapes — reported-stopped and field-absent — are
/// `Unknown`, and that neither is `Warn` or `Ok`.
/// Test: itself.
#[tokio::test]
async fn memory_without_stall_tracking_is_unknown_not_warn() {
    let bodies = [
        serde_json::json!({
            "status": "degraded",
            "detail": "palace lock stall ticker has not run for 412s (interval 30000ms)",
            "daemon_state": "ready",
            "worker": {"in_flight": 0, "wedged": false, "stall_tracking_ok": false},
        }),
        // The incident build: worker occupancy (#3992), no stall detector.
        serde_json::json!({
            "status": "ok",
            "daemon_state": "ready",
            "worker": {"in_flight": 0, "wedged": false},
        }),
    ];
    for body in bodies {
        let daemon = spawn_health_listener(body.clone(), std::time::Duration::ZERO).await;
        let addr = daemon.socket().display().to_string();
        let check = probe_health("memory", "trusty-memory", daemon.socket(), &addr).await;

        assert_eq!(
            check.status,
            CheckStatus::Unknown,
            "a daemon not watching its palace locks is undetermined, not a warning: {} ({body})",
            check.message
        );
        assert!(
            check.message.contains("stall detector is not reporting"),
            "message must name the detector: {}",
            check.message
        );
    }
}

/// Why: the aggregate verdict is what an operator actually reads. If a single
/// `Unknown` could still fold into an `Ok` report, the third state would be
/// cosmetic.
/// What: asserts `Unknown` outranks `Ok` and `Warn` but not `Fail`.
/// Test: itself.
#[test]
fn unknown_never_aggregates_to_ok() {
    let report = DoctorReport::from_checks(vec![
        DoctorCheck::new("a", CheckStatus::Ok, "fine"),
        DoctorCheck::new("b", CheckStatus::Unknown, "no idea"),
    ]);
    assert_eq!(report.overall, CheckStatus::Unknown);

    let with_warn = DoctorReport::from_checks(vec![
        DoctorCheck::new("a", CheckStatus::Warn, "meh"),
        DoctorCheck::new("b", CheckStatus::Unknown, "no idea"),
    ]);
    assert_eq!(with_warn.overall, CheckStatus::Unknown);

    // A real failure still outranks an unknown — a known problem is more
    // actionable than an undetermined one.
    let with_fail = DoctorReport::from_checks(vec![
        DoctorCheck::new("a", CheckStatus::Unknown, "no idea"),
        DoctorCheck::new("b", CheckStatus::Fail, "broken"),
    ]);
    assert_eq!(with_fail.overall, CheckStatus::Fail);
}

/// 🔴 #7259: the four "which workspaces are live" probes drop a claim only when
/// a liveness probe ANSWERED and found the session gone.
///
/// Why: `Live` covers both "the session is running" and "nothing could
/// establish that it is not", and collapsing those two would let a tmux outage
/// promote an unmanaged cwd to a managed workspace and hand every base-clone
/// and hooks probe a shorter list than the fleet actually holds.
#[test]
fn live_workspace_paths_drops_only_claims_a_probe_found_gone() {
    let gone = PathBuf::from("/srv/projects/acme");
    let still_here = PathBuf::from("/srv/projects/beta");
    let claims = LiveClaims::foreign(vec![
        WorkspaceClaim::with_liveness("tm-gone", &gone, ClaimLiveness::SessionGone),
        WorkspaceClaim::with_liveness("tm-still-here", &still_here, ClaimLiveness::Live),
    ]);
    assert_eq!(live_workspace_paths(&claims), vec![still_here]);
}

/// See #6868: the report carries the `rust_build_env` row, and the row is
/// harmless in a project that is not Rust.
///
/// Why: the check is registered in `run_doctor_with_claims`, and the
/// registration is the one part `doctor_rust_build_env_tests.rs` cannot prove —
/// it drives the module directly. This proves the wiring, and proves the row
/// never turns a non-Rust project's report red, which is the failure that would
/// make every operator stop reading `tm doctor` output.
#[tokio::test]
async fn doctor_report_includes_rust_build_env_row() {
    let project = tempfile::tempdir().expect("temp project");
    std::fs::write(project.path().join("package.json"), "{}").expect("a non-Rust marker");

    let report = run_doctor(Some(project.path()), None, &[], None).await;
    let row = report
        .checks
        .iter()
        .find(|c| c.name == "rust_build_env")
        .expect("the rust_build_env row must be registered");

    assert_eq!(
        row.status,
        CheckStatus::Ok,
        "a non-Rust project must not be warned about a cargo target directory: {}",
        row.message
    );
    assert!(
        row.message.contains("not applicable"),
        "the row must say why it did nothing: {}",
        row.message
    );
}