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
//! `tm doctor` retired-override-file probe (issue #4286).
//!
//! Why: #4286 stopped the prompt composer reading the five `.trusty-mpm/`
//! override files. A hard cut with no signal would delete a project's rules
//! between two releases with no error anywhere — the single worst outcome
//! available, and issue #381 in reverse (there, a file was advertised and never
//! read; here, a file was read and would silently stop being read). The
//! resolver logs an `error!` on every launch, and this probe is the half an
//! operator can run on demand and see in one place.
//!
//! What: [`check_legacy_overrides`] `Fail`s when any retired override file is
//! present in the project, naming every file found and the migration to
//! `CLAUDE.md` named sections. `Fail` rather than `Warn` deliberately: the
//! project's instructions are no longer reaching the PM, which is a broken
//! configuration and not an advisory. Read-only — never deletes or rewrites a
//! project's files.
//! Test: the `tests` module below covers the clean, single-file, all-files and
//! no-project-directory branches.
use std::path::Path;
use crate::core::doctor::{CheckStatus, DoctorCheck};
use crate::core::instruction_overrides::{LEGACY_MIGRATION_HINT, detect_legacy_overrides};
/// Probe the project for retired `.trusty-mpm/` instruction override files.
///
/// Why: see the module doc — this is the on-demand half of the #4286 retirement
/// signal. It shares [`detect_legacy_overrides`] with the prompt resolver so the
/// two can never disagree about whether a project is affected; a probe with its
/// own copy of the filename list would drift and under-report.
/// What: `Ok` when the project carries none of the five files. `Fail` naming
/// every file found plus [`LEGACY_MIGRATION_HINT`]. With no `project_dir` the
/// probe cannot be scoped, so it reports `Warn` rather than a false `Ok` —
/// mirroring `check_instructions`, which faces the same unscoped case.
/// Test: `legacy_overrides_ok_when_clean`,
/// `legacy_overrides_fail_names_the_migration`,
/// `legacy_overrides_reports_every_file_found`,
/// `legacy_overrides_warns_without_a_project_dir`.
pub(super) fn check_legacy_overrides(project_dir: Option<&Path>) -> DoctorCheck {
let Some(project) = project_dir else {
return DoctorCheck::new(
"legacy_overrides",
CheckStatus::Warn,
"no project directory supplied — cannot check for retired .trusty-mpm/ \
override files",
);
};
let found = detect_legacy_overrides(project);
if found.is_empty() {
return DoctorCheck::new(
"legacy_overrides",
CheckStatus::Ok,
"no retired .trusty-mpm/ instruction override files present",
);
}
let names: Vec<String> = found.iter().map(|p| p.display().to_string()).collect();
DoctorCheck::new(
"legacy_overrides",
CheckStatus::Fail,
format!(
"{} retired override file(s) are NO LONGER READ and their contents do not \
reach the PM prompt: {} — {LEGACY_MIGRATION_HINT} {}",
names.len(),
names.join(", "),
removal_command(&names),
),
)
}
/// The exact command that removes the retired files, for the failure text.
///
/// Why (#7262): this check has no `--fix` arm and must not grow one without an
/// owner ruling — the files are TRACKED, so repairing them means deleting a
/// file the project committed, which is not a call `tm doctor` may make on its
/// own. What it can do is stop making the operator assemble the command: the
/// finding names paths, and the remedy is one `git rm` over exactly those
/// paths.
/// What: `Run this once the sections are migrated: git rm <paths>`, space
/// separated in the order [`detect_legacy_overrides`] found them.
/// Test: `legacy_overrides_prints_the_exact_git_rm_command`.
fn removal_command(names: &[String]) -> String {
format!(
"Once those sections are migrated, remove the files with: git rm {}",
names.join(" ")
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::instruction_overrides::{
FILE_INSTRUCTIONS, LEGACY_OVERRIDE_FILES, OVERRIDE_DIR_NAME,
};
/// Write `<project>/.trusty-mpm/<name>`, creating parents.
fn write_legacy(project: &Path, name: &str) {
let dir = project.join(OVERRIDE_DIR_NAME);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(name), "PROJECT RULES\n").unwrap();
}
#[test]
fn legacy_overrides_ok_when_clean() {
// A project with no `.trusty-mpm/` directory at all is the common case
// and must be Ok — the probe may never nag a clean project.
let tmp = tempfile::tempdir().unwrap();
let check = check_legacy_overrides(Some(tmp.path()));
assert_eq!(check.status, CheckStatus::Ok);
}
#[test]
fn legacy_overrides_ok_when_only_unrelated_state_is_present() {
// `.trusty-mpm/` also holds `last-instructions.md` and session state,
// which are NOT override files. Their presence must not trip the probe.
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join(OVERRIDE_DIR_NAME);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("last-instructions.md"), "composed prompt").unwrap();
std::fs::write(dir.join("config.toml"), "[project]\n").unwrap();
let check = check_legacy_overrides(Some(tmp.path()));
assert_eq!(check.status, CheckStatus::Ok);
}
/// #7262: this check has no `--fix` arm — repairing it means deleting a
/// TRACKED file, which needs an owner ruling — so the failure text must at
/// least hand the operator the exact command instead of the shape of one.
#[test]
fn legacy_overrides_prints_the_exact_git_rm_command() {
let tmp = tempfile::tempdir().unwrap();
write_legacy(tmp.path(), FILE_INSTRUCTIONS);
let expected = tmp.path().join(OVERRIDE_DIR_NAME).join(FILE_INSTRUCTIONS);
let check = check_legacy_overrides(Some(tmp.path()));
assert_eq!(
check.message.matches("git rm ").count(),
1,
"one command, not a hint: {}",
check.message
);
assert!(
check
.message
.contains(&format!("git rm {}", expected.display())),
"the command must name the file that was found: {}",
check.message
);
}
#[test]
fn legacy_overrides_fail_names_the_migration() {
// The blast-radius case: the ONE file that actually exists in the field
// (12 instances across 4 real projects at the time of #4286). A leftover
// file must Fail loudly AND state where the content has to go — a
// failure that does not name the migration leaves the operator stuck.
let tmp = tempfile::tempdir().unwrap();
write_legacy(tmp.path(), FILE_INSTRUCTIONS);
let check = check_legacy_overrides(Some(tmp.path()));
assert_eq!(check.status, CheckStatus::Fail);
assert!(check.message.contains(FILE_INSTRUCTIONS));
assert!(
check.message.contains("CLAUDE.md"),
"the failure must name the destination surface"
);
assert!(
check.message.contains("TRUSTY-MPM:"),
"the failure must show the marker grammar to migrate into"
);
assert!(
check.message.contains("#4286"),
"the failure must cite the issue that retired the file"
);
}
#[test]
fn legacy_overrides_reports_every_file_found() {
// A project carrying all five must have all five named. Reporting only
// the first would leave four files to rediscover one `tm doctor` run at
// a time.
let tmp = tempfile::tempdir().unwrap();
for name in LEGACY_OVERRIDE_FILES {
write_legacy(tmp.path(), name);
}
let check = check_legacy_overrides(Some(tmp.path()));
assert_eq!(check.status, CheckStatus::Fail);
for name in LEGACY_OVERRIDE_FILES {
assert!(
check.message.contains(name),
"every retired file present must be named; missing {name}"
);
}
}
#[test]
fn legacy_overrides_warns_without_a_project_dir() {
// Unscoped: Warn, never a false Ok. `tm doctor` outside a project must
// not report "clean" about a project it never looked at.
let check = check_legacy_overrides(None);
assert_eq!(check.status, CheckStatus::Warn);
}
}