@stylusnexus/work-plan 2026.7.10 → 2026.7.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -1
- package/VERSION +1 -1
- package/package.json +1 -1
- package/skills/work-plan/SKILL.md +1 -0
- package/skills/work-plan/commands/doctor.py +529 -0
- package/skills/work-plan/commands/export.py +14 -11
- package/skills/work-plan/commands/init_repo.py +3 -6
- package/skills/work-plan/commands/new_track.py +21 -2
- package/skills/work-plan/commands/plan_status.py +48 -6
- package/skills/work-plan/lib/config.py +29 -0
- package/skills/work-plan/lib/doc_discovery.py +22 -2
- package/skills/work-plan/lib/export_model.py +16 -3
- package/skills/work-plan/lib/github_state.py +25 -0
- package/skills/work-plan/lib/notes_vcs.py +21 -9
- package/skills/work-plan/lib/plan_worktree.py +47 -8
- package/skills/work-plan/lib/tracks.py +17 -3
- package/skills/work-plan/tests/test_config.py +59 -0
- package/skills/work-plan/tests/test_discover_archived.py +15 -0
- package/skills/work-plan/tests/test_doc_discovery.py +11 -0
- package/skills/work-plan/tests/test_doctor.py +852 -0
- package/skills/work-plan/tests/test_export.py +34 -8
- package/skills/work-plan/tests/test_export_command.py +101 -0
- package/skills/work-plan/tests/test_github_state.py +41 -1
- package/skills/work-plan/tests/test_group_apply.py +4 -0
- package/skills/work-plan/tests/test_new_track.py +79 -0
- package/skills/work-plan/tests/test_notes_vcs.py +30 -0
- package/skills/work-plan/tests/test_plan_status_stamp.py +68 -0
- package/skills/work-plan/tests/test_plan_worktree.py +31 -0
- package/skills/work-plan/tests/test_tracks.py +55 -1
- package/skills/work-plan/work_plan.py +20 -0
|
@@ -0,0 +1,852 @@
|
|
|
1
|
+
"""Tests for the doctor subcommand — config-drift detection."""
|
|
2
|
+
import json
|
|
3
|
+
import subprocess
|
|
4
|
+
import unittest
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from unittest import mock
|
|
7
|
+
|
|
8
|
+
from commands import doctor
|
|
9
|
+
from lib.config import ConfigError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _finding(findings, type_):
|
|
13
|
+
return [f for f in findings if f["type"] == type_]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TestStep0FatalLoad(unittest.TestCase):
|
|
17
|
+
def _run_json_with_load_error(self, exc):
|
|
18
|
+
with mock.patch("commands.doctor.load_config", side_effect=exc):
|
|
19
|
+
with mock.patch("sys.stdout", new_callable=__import__("io").StringIO) as out:
|
|
20
|
+
code = doctor.run(["--json"])
|
|
21
|
+
return code, out.getvalue()
|
|
22
|
+
|
|
23
|
+
def test_config_error_produces_fatal_json_and_exit_0(self):
|
|
24
|
+
code, out = self._run_json_with_load_error(ConfigError("missing notes_root"))
|
|
25
|
+
self.assertEqual(code, 0)
|
|
26
|
+
blob = json.loads(out)
|
|
27
|
+
self.assertIn("missing notes_root", blob["fatal"])
|
|
28
|
+
self.assertEqual(blob["attempts"], [])
|
|
29
|
+
self.assertEqual(blob["findings"], [])
|
|
30
|
+
|
|
31
|
+
def test_file_not_found_is_fatal(self):
|
|
32
|
+
code, out = self._run_json_with_load_error(FileNotFoundError("no yq"))
|
|
33
|
+
self.assertEqual(code, 0)
|
|
34
|
+
self.assertIn("fatal", json.loads(out))
|
|
35
|
+
|
|
36
|
+
def test_called_process_error_is_fatal(self):
|
|
37
|
+
exc = subprocess.CalledProcessError(1, ["yq"], stderr="bad yaml")
|
|
38
|
+
code, out = self._run_json_with_load_error(exc)
|
|
39
|
+
self.assertEqual(code, 0)
|
|
40
|
+
self.assertIn("fatal", json.loads(out))
|
|
41
|
+
|
|
42
|
+
def test_json_decode_error_is_fatal(self):
|
|
43
|
+
exc = json.JSONDecodeError("bad", "doc", 0)
|
|
44
|
+
code, out = self._run_json_with_load_error(exc)
|
|
45
|
+
self.assertEqual(code, 0)
|
|
46
|
+
self.assertIn("fatal", json.loads(out))
|
|
47
|
+
|
|
48
|
+
def test_os_error_is_fatal(self):
|
|
49
|
+
code, out = self._run_json_with_load_error(OSError("read failed"))
|
|
50
|
+
self.assertEqual(code, 0)
|
|
51
|
+
self.assertIn("fatal", json.loads(out))
|
|
52
|
+
|
|
53
|
+
def test_attribute_error_is_fatal(self):
|
|
54
|
+
# Simulates `repos: null` reaching `.items()` inside load_config.
|
|
55
|
+
code, out = self._run_json_with_load_error(AttributeError("'NoneType' object has no attribute 'items'"))
|
|
56
|
+
self.assertEqual(code, 0)
|
|
57
|
+
self.assertIn("fatal", json.loads(out))
|
|
58
|
+
|
|
59
|
+
def test_unicode_decode_error_is_fatal(self):
|
|
60
|
+
exc = UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid start byte")
|
|
61
|
+
code, out = self._run_json_with_load_error(exc)
|
|
62
|
+
self.assertEqual(code, 0)
|
|
63
|
+
self.assertIn("fatal", json.loads(out))
|
|
64
|
+
|
|
65
|
+
def test_human_mode_exits_1_on_fatal(self):
|
|
66
|
+
with mock.patch("commands.doctor.load_config", side_effect=ConfigError("bad")):
|
|
67
|
+
with mock.patch("sys.stdout", new_callable=__import__("io").StringIO) as out:
|
|
68
|
+
code = doctor.run([])
|
|
69
|
+
self.assertEqual(code, 1)
|
|
70
|
+
printed = out.getvalue()
|
|
71
|
+
self.assertIn("ERROR:", printed)
|
|
72
|
+
self.assertIn("bad", printed)
|
|
73
|
+
self.assertNotIn("{", printed) # no JSON fallback shape leaked into human mode
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class TestFieldShapeValidation(unittest.TestCase):
|
|
77
|
+
def test_non_string_github_is_excluded_and_reported(self):
|
|
78
|
+
cfg = {"repos": {"bad": {"github": 12345, "local": None}}, "_scalar_shape_keys": set()}
|
|
79
|
+
valid, findings = doctor._validate_repo_field_shapes(cfg)
|
|
80
|
+
self.assertNotIn("bad", valid)
|
|
81
|
+
self.assertEqual(len(_finding(findings, "repo_entry_malformed")), 1)
|
|
82
|
+
self.assertEqual(findings[0]["key"], "bad")
|
|
83
|
+
|
|
84
|
+
def test_non_string_local_is_excluded_and_reported(self):
|
|
85
|
+
cfg = {"repos": {"bad": {"github": "org/bad", "local": 42}}, "_scalar_shape_keys": set()}
|
|
86
|
+
valid, findings = doctor._validate_repo_field_shapes(cfg)
|
|
87
|
+
self.assertNotIn("bad", valid)
|
|
88
|
+
self.assertEqual(len(_finding(findings, "repo_entry_malformed")), 1)
|
|
89
|
+
|
|
90
|
+
def test_conforming_entries_pass_through_unaffected(self):
|
|
91
|
+
cfg = {"repos": {"ok": {"github": "org/ok", "local": "/code/ok"}}, "_scalar_shape_keys": set()}
|
|
92
|
+
valid, findings = doctor._validate_repo_field_shapes(cfg)
|
|
93
|
+
self.assertIn("ok", valid)
|
|
94
|
+
self.assertEqual(findings, [])
|
|
95
|
+
|
|
96
|
+
def test_one_bad_entry_does_not_exclude_others(self):
|
|
97
|
+
cfg = {"repos": {
|
|
98
|
+
"bad": {"github": 1, "local": None},
|
|
99
|
+
"ok": {"github": "org/ok", "local": None},
|
|
100
|
+
}, "_scalar_shape_keys": set()}
|
|
101
|
+
valid, findings = doctor._validate_repo_field_shapes(cfg)
|
|
102
|
+
self.assertIn("ok", valid)
|
|
103
|
+
self.assertNotIn("bad", valid)
|
|
104
|
+
self.assertEqual(len(findings), 1)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class TestCleanConfigNoDrift(unittest.TestCase):
|
|
108
|
+
def test_empty_repos_no_notes_root_findings_yields_clean_json(self):
|
|
109
|
+
# A full-pipeline smoke test using a real empty notes_root, to be
|
|
110
|
+
# extended in later tasks as Steps 1-4 are implemented — this proves
|
|
111
|
+
# the skeleton wires Step 0 through to output correctly even with
|
|
112
|
+
# zero repos configured.
|
|
113
|
+
import tempfile
|
|
114
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
115
|
+
cfg = {"notes_root": tmp, "repos": {}, "_scalar_shape_keys": set()}
|
|
116
|
+
with mock.patch("commands.doctor.load_config", return_value=cfg):
|
|
117
|
+
with mock.patch("sys.stdout", new_callable=__import__("io").StringIO) as out:
|
|
118
|
+
code = doctor.run(["--json"])
|
|
119
|
+
self.assertEqual(code, 0)
|
|
120
|
+
blob = json.loads(out.getvalue())
|
|
121
|
+
self.assertEqual(blob["attempts"], [])
|
|
122
|
+
self.assertEqual(blob["findings"], [])
|
|
123
|
+
|
|
124
|
+
def test_empty_repos_no_notes_root_findings_yields_clean_human_mode(self):
|
|
125
|
+
# Same clean-config scenario as test_empty_repos_no_notes_root_findings_yields_clean_json,
|
|
126
|
+
# but exercising human mode (no --json flag) to ensure the success path
|
|
127
|
+
# prints "No drift found." and exits with code 0.
|
|
128
|
+
import tempfile
|
|
129
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
130
|
+
cfg = {"notes_root": tmp, "repos": {}, "_scalar_shape_keys": set()}
|
|
131
|
+
with mock.patch("commands.doctor.load_config", return_value=cfg):
|
|
132
|
+
with mock.patch("sys.stdout", new_callable=__import__("io").StringIO) as out:
|
|
133
|
+
code = doctor.run([])
|
|
134
|
+
self.assertEqual(code, 0)
|
|
135
|
+
printed = out.getvalue()
|
|
136
|
+
self.assertIn("No drift found.", printed)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class TestStep1CanonicalSlugs(unittest.TestCase):
|
|
140
|
+
def test_matching_slug_no_finding(self):
|
|
141
|
+
repos = {"foo": {"github": "org/foo", "local": None}}
|
|
142
|
+
with mock.patch("commands.doctor.repo_full_name", return_value="org/foo"):
|
|
143
|
+
canonical = doctor._resolve_canonical_slugs(repos)
|
|
144
|
+
findings = doctor._step1_findings(repos, canonical)
|
|
145
|
+
self.assertEqual(canonical["foo"], {"canonical": "org/foo", "unverified": False})
|
|
146
|
+
self.assertEqual(findings, [])
|
|
147
|
+
|
|
148
|
+
def test_matching_slug_is_case_insensitive(self):
|
|
149
|
+
repos = {"foo": {"github": "Org/Foo", "local": None}}
|
|
150
|
+
with mock.patch("commands.doctor.repo_full_name", return_value="org/foo"):
|
|
151
|
+
canonical = doctor._resolve_canonical_slugs(repos)
|
|
152
|
+
findings = doctor._step1_findings(repos, canonical)
|
|
153
|
+
self.assertEqual(findings, [])
|
|
154
|
+
|
|
155
|
+
def test_renamed_slug_is_fixable_finding(self):
|
|
156
|
+
repos = {"foo": {"github": "org/old-name", "local": None}}
|
|
157
|
+
with mock.patch("commands.doctor.repo_full_name", return_value="org/new-name"):
|
|
158
|
+
canonical = doctor._resolve_canonical_slugs(repos)
|
|
159
|
+
findings = doctor._step1_findings(repos, canonical)
|
|
160
|
+
self.assertEqual(canonical["foo"]["canonical"], "org/new-name")
|
|
161
|
+
renamed = _finding(findings, "github_rename_detected")
|
|
162
|
+
self.assertEqual(len(renamed), 1)
|
|
163
|
+
self.assertTrue(renamed[0]["fixable"])
|
|
164
|
+
self.assertEqual(renamed[0]["old"], "org/old-name")
|
|
165
|
+
self.assertEqual(renamed[0]["new"], "org/new-name")
|
|
166
|
+
|
|
167
|
+
def test_renamed_slug_unsafe_key_not_fixable(self):
|
|
168
|
+
repos = {"a.b": {"github": "org/old-name", "local": None}}
|
|
169
|
+
with mock.patch("commands.doctor.repo_full_name", return_value="org/new-name"):
|
|
170
|
+
canonical = doctor._resolve_canonical_slugs(repos)
|
|
171
|
+
findings = doctor._step1_findings(repos, canonical)
|
|
172
|
+
renamed = _finding(findings, "github_rename_detected")
|
|
173
|
+
self.assertFalse(renamed[0]["fixable"])
|
|
174
|
+
self.assertIn("unsafe", renamed[0]["message"].lower())
|
|
175
|
+
|
|
176
|
+
def test_renamed_slug_scalar_shaped_not_fixable(self):
|
|
177
|
+
repos = {"foo": {"github": "org/old-name", "local": None}}
|
|
178
|
+
with mock.patch("commands.doctor.repo_full_name", return_value="org/new-name"):
|
|
179
|
+
canonical = doctor._resolve_canonical_slugs(repos)
|
|
180
|
+
findings = doctor._step1_findings(repos, canonical, scalar_shape_keys={"foo"})
|
|
181
|
+
renamed = _finding(findings, "github_rename_detected")
|
|
182
|
+
self.assertFalse(renamed[0]["fixable"])
|
|
183
|
+
self.assertIn("scalar", renamed[0]["message"].lower())
|
|
184
|
+
|
|
185
|
+
def test_unreachable_repo(self):
|
|
186
|
+
repos = {"foo": {"github": "org/gone", "local": None}}
|
|
187
|
+
with mock.patch("commands.doctor.repo_full_name", return_value=None):
|
|
188
|
+
canonical = doctor._resolve_canonical_slugs(repos)
|
|
189
|
+
findings = doctor._step1_findings(repos, canonical)
|
|
190
|
+
self.assertEqual(canonical["foo"], {"canonical": "org/gone", "unverified": True})
|
|
191
|
+
unreachable = _finding(findings, "github_repo_unreachable")
|
|
192
|
+
self.assertEqual(len(unreachable), 1)
|
|
193
|
+
self.assertFalse(unreachable[0]["fixable"])
|
|
194
|
+
|
|
195
|
+
def test_aggregate_deadline_marks_unresolved_as_unreachable(self):
|
|
196
|
+
import time
|
|
197
|
+
def _slow(slug):
|
|
198
|
+
time.sleep(0.3)
|
|
199
|
+
return "org/foo"
|
|
200
|
+
repos = {f"repo{i}": {"github": f"org/repo{i}", "local": None} for i in range(3)}
|
|
201
|
+
with mock.patch("commands.doctor.repo_full_name", side_effect=_slow):
|
|
202
|
+
with mock.patch("commands.doctor.SCAN_DEADLINE", 0.05):
|
|
203
|
+
canonical = doctor._resolve_canonical_slugs(repos)
|
|
204
|
+
# With a near-zero deadline every repo should be marked unreachable/unverified.
|
|
205
|
+
self.assertTrue(all(v["unverified"] for v in canonical.values()))
|
|
206
|
+
|
|
207
|
+
def test_deadline_bounds_actual_return_time_regardless_of_repo_count(self):
|
|
208
|
+
import time
|
|
209
|
+
def _slow(slug):
|
|
210
|
+
time.sleep(1) # long enough to still be "not done" at the deadline
|
|
211
|
+
return "org/foo"
|
|
212
|
+
# More repos than MAX_GH_WORKERS (4), so most are queued, never started.
|
|
213
|
+
repos = {f"repo{i}": {"github": f"org/repo{i}", "local": None} for i in range(12)}
|
|
214
|
+
with mock.patch("commands.doctor.repo_full_name", side_effect=_slow):
|
|
215
|
+
with mock.patch("commands.doctor.SCAN_DEADLINE", 0.05):
|
|
216
|
+
start = time.monotonic()
|
|
217
|
+
canonical = doctor._resolve_canonical_slugs(repos)
|
|
218
|
+
elapsed = time.monotonic() - start
|
|
219
|
+
# Must return close to the deadline, NOT scale with repo count — 12
|
|
220
|
+
# repos / MAX_GH_WORKERS=4 workers * 1s sleep would be ~3s+ if queued
|
|
221
|
+
# jobs ran to completion instead of being cancelled at the deadline.
|
|
222
|
+
self.assertLess(elapsed, 1.0)
|
|
223
|
+
self.assertTrue(all(v["unverified"] for v in canonical.values()))
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class TestStep2LocalPathChecks(unittest.TestCase):
|
|
227
|
+
def test_relative_local_path(self):
|
|
228
|
+
repos = {"foo": {"github": "org/foo", "local": "relative/path"}}
|
|
229
|
+
findings = doctor._step2_findings(repos)
|
|
230
|
+
self.assertEqual(len(_finding(findings, "local_path_relative")), 1)
|
|
231
|
+
# Downstream checks should not ALSO fire for this entry.
|
|
232
|
+
self.assertEqual(_finding(findings, "missing_local"), [])
|
|
233
|
+
|
|
234
|
+
def test_missing_local_path(self):
|
|
235
|
+
import tempfile
|
|
236
|
+
# Cross-platform absolute-but-nonexistent path. A hardcoded POSIX
|
|
237
|
+
# string like "/definitely/not/here/xyz" is NOT absolute on Windows
|
|
238
|
+
# (no drive letter), so it would hit local_path_relative first
|
|
239
|
+
# instead of missing_local — build it from a real absolute base.
|
|
240
|
+
missing = str(Path(tempfile.gettempdir()) / "wp-doctor-test-missing-xyz")
|
|
241
|
+
repos = {"foo": {"github": "org/foo", "local": missing}}
|
|
242
|
+
findings = doctor._step2_findings(repos)
|
|
243
|
+
self.assertEqual(len(_finding(findings, "missing_local")), 1)
|
|
244
|
+
|
|
245
|
+
def test_local_not_git(self):
|
|
246
|
+
import tempfile
|
|
247
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
248
|
+
repos = {"foo": {"github": "org/foo", "local": tmp}}
|
|
249
|
+
findings = doctor._step2_findings(repos)
|
|
250
|
+
self.assertEqual(len(_finding(findings, "local_not_git")), 1)
|
|
251
|
+
|
|
252
|
+
def test_no_finding_when_local_absent(self):
|
|
253
|
+
repos = {"foo": {"github": "org/foo", "local": None}}
|
|
254
|
+
self.assertEqual(doctor._step2_findings(repos), [])
|
|
255
|
+
|
|
256
|
+
def test_remote_missing(self):
|
|
257
|
+
import tempfile
|
|
258
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
259
|
+
(Path(tmp) / ".git").mkdir()
|
|
260
|
+
repos = {"foo": {"github": "org/foo", "local": tmp}}
|
|
261
|
+
with mock.patch("commands.doctor._git", return_value=None):
|
|
262
|
+
findings = doctor._step2_findings(repos)
|
|
263
|
+
self.assertEqual(len(_finding(findings, "local_remote_missing")), 1)
|
|
264
|
+
|
|
265
|
+
def test_remote_mismatch_github_fork(self):
|
|
266
|
+
import tempfile
|
|
267
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
268
|
+
(Path(tmp) / ".git").mkdir()
|
|
269
|
+
repos = {"foo": {"github": "org/foo", "local": tmp}}
|
|
270
|
+
fake = mock.Mock(returncode=0, stdout="git@github.com:someone/fork.git\n")
|
|
271
|
+
with mock.patch("commands.doctor._git", return_value=fake):
|
|
272
|
+
findings = doctor._step2_findings(repos, canonical={"foo": {"canonical": "org/foo", "unverified": False}})
|
|
273
|
+
mismatch = _finding(findings, "local_remote_mismatch")
|
|
274
|
+
self.assertEqual(len(mismatch), 1)
|
|
275
|
+
self.assertFalse(mismatch[0]["fixable"])
|
|
276
|
+
|
|
277
|
+
def test_remote_non_github_host(self):
|
|
278
|
+
import tempfile
|
|
279
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
280
|
+
(Path(tmp) / ".git").mkdir()
|
|
281
|
+
repos = {"foo": {"github": "org/foo", "local": tmp}}
|
|
282
|
+
fake = mock.Mock(returncode=0, stdout="git@gitlab.com:org/foo.git\n")
|
|
283
|
+
with mock.patch("commands.doctor._git", return_value=fake):
|
|
284
|
+
findings = doctor._step2_findings(repos, canonical={"foo": {"canonical": "org/foo", "unverified": False}})
|
|
285
|
+
mismatch = _finding(findings, "local_remote_mismatch")
|
|
286
|
+
self.assertEqual(len(mismatch), 1)
|
|
287
|
+
self.assertIn("non-github", mismatch[0]["message"].lower())
|
|
288
|
+
self.assertFalse(mismatch[0]["fixable"])
|
|
289
|
+
|
|
290
|
+
def test_remote_host_with_explicit_port_still_recognized_as_github(self):
|
|
291
|
+
# https://github.com:8080/org/foo.git — an explicit port on a genuine
|
|
292
|
+
# github.com remote must not be misclassified as a non-GitHub host.
|
|
293
|
+
import tempfile
|
|
294
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
295
|
+
(Path(tmp) / ".git").mkdir()
|
|
296
|
+
repos = {"foo": {"github": "org/foo", "local": tmp}}
|
|
297
|
+
fake = mock.Mock(returncode=0, stdout="https://github.com:8080/org/foo.git\n")
|
|
298
|
+
with mock.patch("commands.doctor._git", return_value=fake):
|
|
299
|
+
findings = doctor._step2_findings(repos, canonical={"foo": {"canonical": "org/foo", "unverified": False}})
|
|
300
|
+
self.assertEqual(_finding(findings, "local_remote_mismatch"), [])
|
|
301
|
+
|
|
302
|
+
def test_happy_path_no_findings(self):
|
|
303
|
+
# Valid absolute local path, valid git repo, origin matching the
|
|
304
|
+
# canonical slug — Step 2 should report nothing.
|
|
305
|
+
import tempfile
|
|
306
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
307
|
+
(Path(tmp) / ".git").mkdir()
|
|
308
|
+
repos = {"foo": {"github": "org/foo", "local": tmp}}
|
|
309
|
+
fake = mock.Mock(returncode=0, stdout="git@github.com:org/foo.git\n")
|
|
310
|
+
with mock.patch("commands.doctor._git", return_value=fake):
|
|
311
|
+
findings = doctor._step2_findings(repos, canonical={"foo": {"canonical": "org/foo", "unverified": False}})
|
|
312
|
+
self.assertEqual(findings, [])
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
class TestStep3WholeConfigChecks(unittest.TestCase):
|
|
316
|
+
def test_duplicate_local(self):
|
|
317
|
+
repos = {
|
|
318
|
+
"a": {"github": "org/a", "local": "/code/dup"},
|
|
319
|
+
"b": {"github": "org/b", "local": "/code/dup"},
|
|
320
|
+
}
|
|
321
|
+
cfg = {"notes_root": "/tmp/notes"}
|
|
322
|
+
findings = doctor._step3_findings(repos, canonical={}, cfg=cfg)
|
|
323
|
+
self.assertEqual(len(_finding(findings, "duplicate_local")), 2)
|
|
324
|
+
|
|
325
|
+
def test_duplicate_github(self):
|
|
326
|
+
repos = {
|
|
327
|
+
"a": {"github": "org/x", "local": None},
|
|
328
|
+
"b": {"github": "org/x", "local": None},
|
|
329
|
+
}
|
|
330
|
+
canonical = {"a": {"canonical": "org/x", "unverified": False},
|
|
331
|
+
"b": {"canonical": "org/x", "unverified": False}}
|
|
332
|
+
cfg = {"notes_root": "/tmp/notes"}
|
|
333
|
+
findings = doctor._step3_findings(repos, canonical, cfg)
|
|
334
|
+
self.assertEqual(len(_finding(findings, "duplicate_github")), 2)
|
|
335
|
+
|
|
336
|
+
def test_notes_root_invalid_blank(self):
|
|
337
|
+
cfg = {"notes_root": ""}
|
|
338
|
+
findings = doctor._step3_findings({}, {}, cfg)
|
|
339
|
+
self.assertEqual(len(_finding(findings, "notes_root_invalid")), 1)
|
|
340
|
+
|
|
341
|
+
def test_notes_root_invalid_relative(self):
|
|
342
|
+
cfg = {"notes_root": "."}
|
|
343
|
+
findings = doctor._step3_findings({}, {}, cfg)
|
|
344
|
+
self.assertEqual(len(_finding(findings, "notes_root_invalid")), 1)
|
|
345
|
+
|
|
346
|
+
def test_notes_root_invalid_bare_root(self):
|
|
347
|
+
cfg = {"notes_root": "/"}
|
|
348
|
+
findings = doctor._step3_findings({}, {}, cfg)
|
|
349
|
+
self.assertEqual(len(_finding(findings, "notes_root_invalid")), 1)
|
|
350
|
+
|
|
351
|
+
def test_notes_root_missing(self):
|
|
352
|
+
import tempfile
|
|
353
|
+
# Cross-platform absolute-but-nonexistent path — see
|
|
354
|
+
# test_missing_local_path for why a hardcoded POSIX string breaks
|
|
355
|
+
# on Windows (not absolute there, so notes_root_invalid fires
|
|
356
|
+
# instead of notes_root_missing).
|
|
357
|
+
missing = str(Path(tempfile.gettempdir()) / "wp-doctor-test-notes-root-xyz")
|
|
358
|
+
cfg = {"notes_root": missing}
|
|
359
|
+
findings = doctor._step3_findings({}, {}, cfg)
|
|
360
|
+
self.assertEqual(len(_finding(findings, "notes_root_missing")), 1)
|
|
361
|
+
|
|
362
|
+
def test_notes_root_ok_no_finding(self):
|
|
363
|
+
import tempfile
|
|
364
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
365
|
+
cfg = {"notes_root": tmp}
|
|
366
|
+
findings = doctor._step3_findings({}, {}, cfg)
|
|
367
|
+
self.assertEqual(_finding(findings, "notes_root_invalid"), [])
|
|
368
|
+
self.assertEqual(_finding(findings, "notes_root_missing"), [])
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
class TestStep4NotesRootWalk(unittest.TestCase):
|
|
372
|
+
def _mk(self, tmp, subpath, content):
|
|
373
|
+
p = Path(tmp) / subpath
|
|
374
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
375
|
+
p.write_text(content)
|
|
376
|
+
return p
|
|
377
|
+
|
|
378
|
+
def test_orphaned_folder(self):
|
|
379
|
+
import tempfile
|
|
380
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
381
|
+
self._mk(tmp, "unknown-project/track.md", "---\n---\nbody")
|
|
382
|
+
cfg = {"notes_root": tmp}
|
|
383
|
+
findings = doctor._step4_findings(cfg, repos={}, canonical={}, walkable=True)
|
|
384
|
+
self.assertEqual(len(_finding(findings, "orphaned_folder")), 1)
|
|
385
|
+
|
|
386
|
+
def test_not_orphaned_when_folder_matches_repo_key(self):
|
|
387
|
+
import tempfile
|
|
388
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
389
|
+
self._mk(tmp, "known/track.md", "---\ngithub:\n repo: org/known\n---\nbody")
|
|
390
|
+
cfg = {"notes_root": tmp}
|
|
391
|
+
repos = {"known": {"github": "org/known", "local": None}}
|
|
392
|
+
canonical = {"known": {"canonical": "org/known", "unverified": False}}
|
|
393
|
+
findings = doctor._step4_findings(cfg, repos, canonical, walkable=True)
|
|
394
|
+
self.assertEqual(_finding(findings, "orphaned_folder"), [])
|
|
395
|
+
|
|
396
|
+
def test_dotdir_never_orphaned(self):
|
|
397
|
+
import tempfile
|
|
398
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
399
|
+
self._mk(tmp, ".git/config", "junk")
|
|
400
|
+
self._mk(tmp, ".obsidian/workspace.json", "{}")
|
|
401
|
+
cfg = {"notes_root": tmp}
|
|
402
|
+
findings = doctor._step4_findings(cfg, repos={}, canonical={}, walkable=True)
|
|
403
|
+
self.assertEqual(_finding(findings, "orphaned_folder"), [])
|
|
404
|
+
|
|
405
|
+
def test_empty_folder_never_orphaned(self):
|
|
406
|
+
import tempfile
|
|
407
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
408
|
+
(Path(tmp) / "empty-dir").mkdir()
|
|
409
|
+
cfg = {"notes_root": tmp}
|
|
410
|
+
findings = doctor._step4_findings(cfg, repos={}, canonical={}, walkable=True)
|
|
411
|
+
self.assertEqual(_finding(findings, "orphaned_folder"), [])
|
|
412
|
+
|
|
413
|
+
def test_track_unreadable_bad_yaml(self):
|
|
414
|
+
import tempfile
|
|
415
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
416
|
+
self._mk(tmp, "known/bad.md", "---\ngithub: [this is not a mapping\n---\nbody")
|
|
417
|
+
cfg = {"notes_root": tmp}
|
|
418
|
+
repos = {"known": {"github": "org/known", "local": None}}
|
|
419
|
+
canonical = {"known": {"canonical": "org/known", "unverified": False}}
|
|
420
|
+
findings = doctor._step4_findings(cfg, repos, canonical, walkable=True)
|
|
421
|
+
self.assertEqual(len(_finding(findings, "track_unreadable")), 1)
|
|
422
|
+
|
|
423
|
+
def test_track_unreadable_non_mapping_root(self):
|
|
424
|
+
# Valid YAML but the frontmatter root itself is a list, not a mapping
|
|
425
|
+
# (e.g. a stray leading '-' turns the whole block into a YAML
|
|
426
|
+
# sequence) — a distinct trigger from a parse exception.
|
|
427
|
+
import tempfile
|
|
428
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
429
|
+
self._mk(tmp, "known/bad.md", "---\n- a\n- b\n---\nbody")
|
|
430
|
+
cfg = {"notes_root": tmp}
|
|
431
|
+
repos = {"known": {"github": "org/known", "local": None}}
|
|
432
|
+
canonical = {"known": {"canonical": "org/known", "unverified": False}}
|
|
433
|
+
findings = doctor._step4_findings(cfg, repos, canonical, walkable=True)
|
|
434
|
+
self.assertEqual(len(_finding(findings, "track_unreadable")), 1)
|
|
435
|
+
|
|
436
|
+
def test_track_unreadable_non_mapping_github(self):
|
|
437
|
+
import tempfile
|
|
438
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
439
|
+
self._mk(tmp, "known/bad.md", "---\ngithub: not-a-mapping\n---\nbody")
|
|
440
|
+
cfg = {"notes_root": tmp}
|
|
441
|
+
repos = {"known": {"github": "org/known", "local": None}}
|
|
442
|
+
canonical = {"known": {"canonical": "org/known", "unverified": False}}
|
|
443
|
+
findings = doctor._step4_findings(cfg, repos, canonical, walkable=True)
|
|
444
|
+
self.assertEqual(len(_finding(findings, "track_unreadable")), 1)
|
|
445
|
+
|
|
446
|
+
def test_track_unreadable_non_string_repo(self):
|
|
447
|
+
import tempfile
|
|
448
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
449
|
+
self._mk(tmp, "known/bad.md", "---\ngithub:\n repo: 12345\n---\nbody")
|
|
450
|
+
cfg = {"notes_root": tmp}
|
|
451
|
+
repos = {"known": {"github": "org/known", "local": None}}
|
|
452
|
+
canonical = {"known": {"canonical": "org/known", "unverified": False}}
|
|
453
|
+
findings = doctor._step4_findings(cfg, repos, canonical, walkable=True)
|
|
454
|
+
self.assertEqual(len(_finding(findings, "track_unreadable")), 1)
|
|
455
|
+
|
|
456
|
+
def test_stale_frontmatter(self):
|
|
457
|
+
import tempfile
|
|
458
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
459
|
+
self._mk(tmp, "known/track.md", "---\ngithub:\n repo: org/old\n---\nbody")
|
|
460
|
+
cfg = {"notes_root": tmp}
|
|
461
|
+
repos = {"known": {"github": "org/old", "local": None}}
|
|
462
|
+
canonical = {"known": {"canonical": "org/new", "unverified": False}}
|
|
463
|
+
findings = doctor._step4_findings(cfg, repos, canonical, walkable=True)
|
|
464
|
+
stale = _finding(findings, "stale_frontmatter")
|
|
465
|
+
self.assertEqual(len(stale), 1)
|
|
466
|
+
self.assertTrue(stale[0]["fixable"])
|
|
467
|
+
self.assertEqual(stale[0]["old"], "org/old")
|
|
468
|
+
self.assertEqual(stale[0]["new"], "org/new")
|
|
469
|
+
|
|
470
|
+
def test_stale_frontmatter_unverified_never_fixable(self):
|
|
471
|
+
import tempfile
|
|
472
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
473
|
+
self._mk(tmp, "known/track.md", "---\ngithub:\n repo: org/old\n---\nbody")
|
|
474
|
+
cfg = {"notes_root": tmp}
|
|
475
|
+
repos = {"known": {"github": "org/old", "local": None}}
|
|
476
|
+
canonical = {"known": {"canonical": "org/old", "unverified": True}}
|
|
477
|
+
findings = doctor._step4_findings(cfg, repos, canonical, walkable=True)
|
|
478
|
+
stale = _finding(findings, "stale_frontmatter")
|
|
479
|
+
self.assertEqual(stale, []) # matches configured value, and unverified — no finding either way here
|
|
480
|
+
|
|
481
|
+
def test_archived_track_is_scanned(self):
|
|
482
|
+
import tempfile
|
|
483
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
484
|
+
self._mk(tmp, "known/archive/old.md", "---\ngithub:\n repo: org/old\n---\nbody")
|
|
485
|
+
cfg = {"notes_root": tmp}
|
|
486
|
+
repos = {"known": {"github": "org/old", "local": None}}
|
|
487
|
+
canonical = {"known": {"canonical": "org/new", "unverified": False}}
|
|
488
|
+
findings = doctor._step4_findings(cfg, repos, canonical, walkable=True)
|
|
489
|
+
self.assertEqual(len(_finding(findings, "stale_frontmatter")), 1)
|
|
490
|
+
|
|
491
|
+
def test_not_walkable_returns_no_findings(self):
|
|
492
|
+
findings = doctor._step4_findings({"notes_root": "/nope"}, {}, {}, walkable=False)
|
|
493
|
+
self.assertEqual(findings, [])
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
class TestApplyFixesConfigRename(unittest.TestCase):
|
|
497
|
+
def test_fixable_rename_writes_config_and_ledger_entry(self):
|
|
498
|
+
import tempfile
|
|
499
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
500
|
+
cfg_path = Path(tmp) / "config.yml"
|
|
501
|
+
cfg_path.write_text("notes_root: /tmp/notes\nrepos:\n foo:\n github: org/old\n")
|
|
502
|
+
finding = doctor._finding(
|
|
503
|
+
"github_rename_detected", key="foo", fixable=True,
|
|
504
|
+
message="renamed", old="org/old", new="org/new",
|
|
505
|
+
)
|
|
506
|
+
with mock.patch("commands.doctor.DEFAULT_CONFIG_PATH", cfg_path):
|
|
507
|
+
ledger = doctor._apply_config_fixes([finding])
|
|
508
|
+
self.assertEqual(len(ledger), 1)
|
|
509
|
+
self.assertTrue(ledger[0]["fixed"])
|
|
510
|
+
self.assertIsNone(ledger[0]["error"])
|
|
511
|
+
text = cfg_path.read_text()
|
|
512
|
+
self.assertIn("org/new", text)
|
|
513
|
+
|
|
514
|
+
def test_yq_failure_recorded_as_ledger_error(self):
|
|
515
|
+
finding = doctor._finding(
|
|
516
|
+
"github_rename_detected", key="foo", fixable=True,
|
|
517
|
+
message="renamed", old="org/old", new="org/new",
|
|
518
|
+
)
|
|
519
|
+
exc = subprocess.CalledProcessError(1, ["yq"], stderr="boom")
|
|
520
|
+
with mock.patch("commands.doctor.write_repo_field", side_effect=exc):
|
|
521
|
+
ledger = doctor._apply_config_fixes([finding])
|
|
522
|
+
self.assertFalse(ledger[0]["fixed"])
|
|
523
|
+
self.assertIn("boom", ledger[0]["error"])
|
|
524
|
+
|
|
525
|
+
def test_unfixable_findings_are_never_attempted(self):
|
|
526
|
+
finding = doctor._finding(
|
|
527
|
+
"github_rename_detected", key="foo", fixable=False,
|
|
528
|
+
message="unsafe key", old="org/old", new="org/new",
|
|
529
|
+
)
|
|
530
|
+
with mock.patch("commands.doctor.write_repo_field") as m:
|
|
531
|
+
ledger = doctor._apply_config_fixes([finding])
|
|
532
|
+
m.assert_not_called()
|
|
533
|
+
self.assertEqual(ledger, [])
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
class TestDirtyFilePolicy(unittest.TestCase):
|
|
537
|
+
def test_pre_snapshot_failure_skips_all_frontmatter_writes(self):
|
|
538
|
+
import tempfile
|
|
539
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
540
|
+
track = Path(tmp) / "known" / "track.md"
|
|
541
|
+
track.parent.mkdir()
|
|
542
|
+
track.write_text("---\ngithub:\n repo: org/old\n---\nbody")
|
|
543
|
+
finding = doctor._finding(
|
|
544
|
+
"stale_frontmatter", folder="known", track="track.md", fixable=True,
|
|
545
|
+
message="stale", old="org/old", new="org/new",
|
|
546
|
+
)
|
|
547
|
+
with mock.patch("commands.doctor.dirty_paths_checked", return_value=(False, set())):
|
|
548
|
+
ledger, skipped_write = doctor._apply_frontmatter_fixes(
|
|
549
|
+
Path(tmp), [finding], auto_commit_enabled=True,
|
|
550
|
+
)
|
|
551
|
+
self.assertEqual(ledger, [])
|
|
552
|
+
self.assertIn("org/old", track.read_text()) # untouched
|
|
553
|
+
self.assertTrue(skipped_write)
|
|
554
|
+
|
|
555
|
+
def test_already_dirty_file_is_skipped_others_still_fixed(self):
|
|
556
|
+
import tempfile
|
|
557
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
558
|
+
dirty = Path(tmp) / "known" / "dirty.md"
|
|
559
|
+
clean = Path(tmp) / "known" / "clean.md"
|
|
560
|
+
dirty.parent.mkdir()
|
|
561
|
+
dirty.write_text("---\ngithub:\n repo: org/old\n---\nbody")
|
|
562
|
+
clean.write_text("---\ngithub:\n repo: org/old\n---\nbody")
|
|
563
|
+
findings = [
|
|
564
|
+
doctor._finding("stale_frontmatter", folder="known", track="dirty.md",
|
|
565
|
+
fixable=True, old="org/old", new="org/new", message="m"),
|
|
566
|
+
doctor._finding("stale_frontmatter", folder="known", track="clean.md",
|
|
567
|
+
fixable=True, old="org/old", new="org/new", message="m"),
|
|
568
|
+
]
|
|
569
|
+
with mock.patch("commands.doctor.dirty_paths_checked",
|
|
570
|
+
return_value=(True, {"known/dirty.md"})):
|
|
571
|
+
ledger, _ = doctor._apply_frontmatter_fixes(Path(tmp), findings, auto_commit_enabled=False)
|
|
572
|
+
fixed_tracks = {a["track"] for a in ledger if a["fixed"]}
|
|
573
|
+
self.assertEqual(fixed_tracks, {"clean.md"})
|
|
574
|
+
self.assertIn("org/old", dirty.read_text())
|
|
575
|
+
self.assertIn("org/new", clean.read_text())
|
|
576
|
+
|
|
577
|
+
def test_write_failure_recorded_in_ledger_and_residual(self):
|
|
578
|
+
import tempfile
|
|
579
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
580
|
+
track = Path(tmp) / "known" / "track.md"
|
|
581
|
+
track.parent.mkdir()
|
|
582
|
+
track.write_text("---\ngithub:\n repo: org/old\n---\nbody")
|
|
583
|
+
finding = doctor._finding(
|
|
584
|
+
"stale_frontmatter", folder="known", track="track.md", fixable=True,
|
|
585
|
+
message="stale", old="org/old", new="org/new",
|
|
586
|
+
)
|
|
587
|
+
with mock.patch("commands.doctor.dirty_paths_checked", return_value=(True, set())):
|
|
588
|
+
with mock.patch("commands.doctor.write_file", side_effect=ValueError("symlink")):
|
|
589
|
+
ledger, _ = doctor._apply_frontmatter_fixes(Path(tmp), [finding], auto_commit_enabled=False)
|
|
590
|
+
self.assertFalse(ledger[0]["fixed"])
|
|
591
|
+
self.assertIn("symlink", ledger[0]["error"])
|
|
592
|
+
|
|
593
|
+
def test_auto_commit_gated_on_notes_vcs_setting(self):
|
|
594
|
+
import tempfile
|
|
595
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
596
|
+
track = Path(tmp) / "known" / "track.md"
|
|
597
|
+
track.parent.mkdir()
|
|
598
|
+
track.write_text("---\ngithub:\n repo: org/old\n---\nbody")
|
|
599
|
+
finding = doctor._finding(
|
|
600
|
+
"stale_frontmatter", folder="known", track="track.md", fixable=True,
|
|
601
|
+
message="stale", old="org/old", new="org/new",
|
|
602
|
+
)
|
|
603
|
+
with mock.patch("commands.doctor.dirty_paths_checked", return_value=(True, set())):
|
|
604
|
+
with mock.patch("commands.doctor.auto_commit") as m:
|
|
605
|
+
doctor._apply_frontmatter_fixes(Path(tmp), [finding], auto_commit_enabled=False)
|
|
606
|
+
m.assert_not_called()
|
|
607
|
+
|
|
608
|
+
def test_auto_commit_invoked_on_successful_fix_with_correct_delta(self):
|
|
609
|
+
# Positive path: auto_commit_enabled=True AND the fix genuinely
|
|
610
|
+
# succeeds against a real git repo (not mocked dirty_paths_checked)
|
|
611
|
+
# so the delta computation (dirty_after - dirty_before) is exercised
|
|
612
|
+
# for real, not just gated off by auto_commit_enabled=False or a
|
|
613
|
+
# pre-snapshot failure like the other tests in this class.
|
|
614
|
+
import tempfile
|
|
615
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
616
|
+
notes_root = Path(tmp)
|
|
617
|
+
(notes_root / "known").mkdir()
|
|
618
|
+
track = notes_root / "known" / "track.md"
|
|
619
|
+
track.write_text("---\ngithub:\n repo: org/old\n---\nbody")
|
|
620
|
+
for git_args in (
|
|
621
|
+
["init"],
|
|
622
|
+
["add", "-A"],
|
|
623
|
+
["-c", "user.email=doctor-test@example.com",
|
|
624
|
+
"-c", "user.name=doctor-test", "commit", "-m", "init"],
|
|
625
|
+
):
|
|
626
|
+
subprocess.run(["git", "-C", str(notes_root), *git_args],
|
|
627
|
+
capture_output=True, text=True, check=True)
|
|
628
|
+
|
|
629
|
+
finding = doctor._finding(
|
|
630
|
+
"stale_frontmatter", folder="known", track="track.md", fixable=True,
|
|
631
|
+
message="stale", old="org/old", new="org/new",
|
|
632
|
+
)
|
|
633
|
+
with mock.patch("commands.doctor.auto_commit") as mock_commit:
|
|
634
|
+
ledger, skipped = doctor._apply_frontmatter_fixes(
|
|
635
|
+
notes_root, [finding], auto_commit_enabled=True,
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
self.assertFalse(skipped)
|
|
639
|
+
self.assertTrue(ledger[0]["fixed"])
|
|
640
|
+
self.assertIn("org/new", track.read_text())
|
|
641
|
+
mock_commit.assert_called_once()
|
|
642
|
+
call = mock_commit.call_args
|
|
643
|
+
self.assertEqual(call.args[0], notes_root)
|
|
644
|
+
self.assertEqual(
|
|
645
|
+
call.args[1], "doctor: fix stale repo identity in track frontmatter",
|
|
646
|
+
)
|
|
647
|
+
self.assertEqual(call.kwargs.get("paths"), ["known/track.md"])
|
|
648
|
+
|
|
649
|
+
def test_archived_name_collision_is_not_blindly_overwritten(self):
|
|
650
|
+
# A finding's folder/track fields lose any intermediate path segment
|
|
651
|
+
# (see _step4_findings: folder=rel.parts[0], track=md_path.name), so a
|
|
652
|
+
# finding raised against a nested/archived file (e.g.
|
|
653
|
+
# 'known/archive/old.md') collides on-disk with 'known/old.md' if one
|
|
654
|
+
# exists. _apply_frontmatter_fixes must refuse to write when the
|
|
655
|
+
# resolved path's current value doesn't match the finding's `old`
|
|
656
|
+
# value, rather than blindly overwriting whatever file it lands on.
|
|
657
|
+
import tempfile
|
|
658
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
659
|
+
# The path _apply_frontmatter_fixes actually resolves to
|
|
660
|
+
# ('known/old.md') is a DIFFERENT, unrelated track — not the
|
|
661
|
+
# archived file the finding was really raised against.
|
|
662
|
+
unrelated = Path(tmp) / "known" / "old.md"
|
|
663
|
+
unrelated.parent.mkdir()
|
|
664
|
+
unrelated.write_text("---\ngithub:\n repo: org/unrelated\n---\nbody")
|
|
665
|
+
finding = doctor._finding(
|
|
666
|
+
"stale_frontmatter", folder="known", track="old.md", fixable=True,
|
|
667
|
+
message="stale", old="org/old", new="org/new",
|
|
668
|
+
)
|
|
669
|
+
with mock.patch("commands.doctor.dirty_paths_checked", return_value=(True, set())):
|
|
670
|
+
ledger, _ = doctor._apply_frontmatter_fixes(Path(tmp), [finding], auto_commit_enabled=False)
|
|
671
|
+
self.assertFalse(ledger[0]["fixed"])
|
|
672
|
+
self.assertIsNotNone(ledger[0]["error"])
|
|
673
|
+
# The unrelated file must be completely untouched.
|
|
674
|
+
self.assertIn("org/unrelated", unrelated.read_text())
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
class TestFixThenRescan(unittest.TestCase):
|
|
678
|
+
def test_fixable_only_fixture_converges_to_clean_on_second_run(self):
|
|
679
|
+
import tempfile
|
|
680
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
681
|
+
cfg_path = Path(tmp) / "config.yml"
|
|
682
|
+
notes_root = Path(tmp) / "notes"
|
|
683
|
+
(notes_root / "known").mkdir(parents=True)
|
|
684
|
+
(notes_root / "known" / "track.md").write_text(
|
|
685
|
+
"---\ngithub:\n repo: org/old\n---\nbody"
|
|
686
|
+
)
|
|
687
|
+
cfg_path.write_text(
|
|
688
|
+
f"notes_root: {notes_root}\nrepos:\n known:\n github: org/old\n"
|
|
689
|
+
)
|
|
690
|
+
# The dirty-file safety check (dirty_paths_checked) requires
|
|
691
|
+
# notes_root to actually be a git repo to report ok_before=True —
|
|
692
|
+
# this mirrors a real user who has opted into notes-vcs. Without
|
|
693
|
+
# this, the pre-fix snapshot call fails closed (see
|
|
694
|
+
# TestDirtyFilePolicy) and the frontmatter fix would be
|
|
695
|
+
# (correctly) skipped, which isn't what this test is exercising.
|
|
696
|
+
for git_args in (
|
|
697
|
+
["init"],
|
|
698
|
+
["add", "-A"],
|
|
699
|
+
["-c", "user.email=doctor-test@example.com",
|
|
700
|
+
"-c", "user.name=doctor-test", "commit", "-m", "init"],
|
|
701
|
+
):
|
|
702
|
+
subprocess.run(["git", "-C", str(notes_root), *git_args],
|
|
703
|
+
capture_output=True, text=True, check=True)
|
|
704
|
+
|
|
705
|
+
def _load(*_a, **_kw):
|
|
706
|
+
from lib.config import load_config as real_load
|
|
707
|
+
return real_load(path=cfg_path, notes_root=notes_root)
|
|
708
|
+
|
|
709
|
+
with mock.patch("commands.doctor.DEFAULT_CONFIG_PATH", cfg_path):
|
|
710
|
+
with mock.patch("commands.doctor.load_config", side_effect=_load):
|
|
711
|
+
with mock.patch("commands.doctor.repo_full_name", return_value="org/new"):
|
|
712
|
+
with mock.patch("sys.stdout", new_callable=__import__("io").StringIO) as out1:
|
|
713
|
+
code1 = doctor.run(["--json", "--fix"])
|
|
714
|
+
with mock.patch("sys.stdout", new_callable=__import__("io").StringIO) as out2:
|
|
715
|
+
code2 = doctor.run(["--json"])
|
|
716
|
+
self.assertEqual(code1, 0)
|
|
717
|
+
blob1 = json.loads(out1.getvalue())
|
|
718
|
+
self.assertTrue(any(a["fixed"] for a in blob1["attempts"]))
|
|
719
|
+
self.assertEqual(code2, 0)
|
|
720
|
+
blob2 = json.loads(out2.getvalue())
|
|
721
|
+
self.assertEqual(blob2["findings"], [])
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
class TestMixedFixtureResidualSet(unittest.TestCase):
|
|
725
|
+
def test_after_fix_residual_is_exactly_report_only_types(self):
|
|
726
|
+
# One instance of every finding type EXCEPT the mutually-exclusive
|
|
727
|
+
# notes_root_invalid/notes_root_missing pair (covered in
|
|
728
|
+
# TestStep3WholeConfigChecks). --fix is applied once, and the
|
|
729
|
+
# assertion that matters is that the post-fix residual finding-type
|
|
730
|
+
# set is EXACTLY the report-only types: neither empty (something in
|
|
731
|
+
# the fixture is genuinely unfixable) nor equal to the pre-fix set
|
|
732
|
+
# (the two fixable types must actually have disappeared).
|
|
733
|
+
import tempfile
|
|
734
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
735
|
+
notes_root = Path(tmp) / "notes"
|
|
736
|
+
missing_local_dir = str(Path(tmp) / "does-not-exist")
|
|
737
|
+
dup_local_dir = Path(tmp) / "dup-local"
|
|
738
|
+
dup_local_dir.mkdir()
|
|
739
|
+
remote_missing_dir = Path(tmp) / "remote-missing-repo"
|
|
740
|
+
remote_missing_dir.mkdir()
|
|
741
|
+
remote_mismatch_dir = Path(tmp) / "remote-mismatch-repo"
|
|
742
|
+
remote_mismatch_dir.mkdir()
|
|
743
|
+
|
|
744
|
+
for d in (remote_missing_dir, remote_mismatch_dir):
|
|
745
|
+
subprocess.run(["git", "-C", str(d), "init"],
|
|
746
|
+
capture_output=True, text=True, check=True)
|
|
747
|
+
subprocess.run(
|
|
748
|
+
["git", "-C", str(remote_mismatch_dir), "remote", "add", "origin",
|
|
749
|
+
"git@github.com:someone/other.git"],
|
|
750
|
+
capture_output=True, text=True, check=True,
|
|
751
|
+
)
|
|
752
|
+
|
|
753
|
+
(notes_root / "orphan").mkdir(parents=True)
|
|
754
|
+
(notes_root / "orphan" / "t.md").write_text("---\n---\nbody")
|
|
755
|
+
(notes_root / "renaming").mkdir()
|
|
756
|
+
(notes_root / "renaming" / "t.md").write_text(
|
|
757
|
+
"---\ngithub:\n repo: org/old\n---\nbody"
|
|
758
|
+
)
|
|
759
|
+
(notes_root / "badyaml").mkdir()
|
|
760
|
+
(notes_root / "badyaml" / "t.md").write_text(
|
|
761
|
+
"---\ngithub: not-a-mapping\n---\nbody"
|
|
762
|
+
)
|
|
763
|
+
|
|
764
|
+
# Dirty-file policy (Task 9) requires notes_root to be a REAL git
|
|
765
|
+
# repo for dirty_paths_checked() to report ok_before=True — a plain
|
|
766
|
+
# directory fails closed and the stale_frontmatter fix would be
|
|
767
|
+
# (correctly) skipped, defeating the point of this fixture.
|
|
768
|
+
for git_args in (
|
|
769
|
+
["init"],
|
|
770
|
+
["add", "-A"],
|
|
771
|
+
["-c", "user.email=doctor-test@example.com",
|
|
772
|
+
"-c", "user.name=doctor-test", "commit", "-m", "init"],
|
|
773
|
+
):
|
|
774
|
+
subprocess.run(["git", "-C", str(notes_root), *git_args],
|
|
775
|
+
capture_output=True, text=True, check=True)
|
|
776
|
+
|
|
777
|
+
cfg_path = Path(tmp) / "config.yml"
|
|
778
|
+
cfg_path.write_text(
|
|
779
|
+
f"notes_root: {notes_root}\n"
|
|
780
|
+
"repos:\n"
|
|
781
|
+
" malformed:\n"
|
|
782
|
+
" github: 12345\n"
|
|
783
|
+
" renaming:\n"
|
|
784
|
+
" github: org/old\n"
|
|
785
|
+
" unreachable:\n"
|
|
786
|
+
" github: org/gone\n"
|
|
787
|
+
" relpath:\n"
|
|
788
|
+
" github: org/relpath\n"
|
|
789
|
+
" local: relative/path\n"
|
|
790
|
+
f" broken:\n github: org/broken\n local: {missing_local_dir}\n"
|
|
791
|
+
f" duplocal1:\n github: org/duplocal1\n local: {dup_local_dir}\n"
|
|
792
|
+
f" duplocal2:\n github: org/duplocal2\n local: {dup_local_dir}\n"
|
|
793
|
+
" dup1:\n github: org/samedupe\n"
|
|
794
|
+
" dup2:\n github: org/samedupe\n"
|
|
795
|
+
f" remotemissing:\n github: org/remotemissing\n local: {remote_missing_dir}\n"
|
|
796
|
+
f" remotemismatch:\n github: org/remotemismatch\n local: {remote_mismatch_dir}\n"
|
|
797
|
+
" badyaml:\n github: org/badyaml\n"
|
|
798
|
+
)
|
|
799
|
+
|
|
800
|
+
def _resolve(slug):
|
|
801
|
+
# Only 'renaming' (org/old) and 'unreachable' (org/gone) get
|
|
802
|
+
# special treatment; every other repo resolves to itself so
|
|
803
|
+
# it does NOT spuriously fire github_rename_detected.
|
|
804
|
+
if slug == "org/old":
|
|
805
|
+
return "org/new"
|
|
806
|
+
if slug == "org/gone":
|
|
807
|
+
return None
|
|
808
|
+
return slug
|
|
809
|
+
|
|
810
|
+
def _load(*_a, **_kw):
|
|
811
|
+
from lib.config import load_config as real_load
|
|
812
|
+
return real_load(path=cfg_path, notes_root=notes_root)
|
|
813
|
+
|
|
814
|
+
fixable_types = {"github_rename_detected", "stale_frontmatter"}
|
|
815
|
+
report_only_expected = {
|
|
816
|
+
"repo_entry_malformed", "github_repo_unreachable",
|
|
817
|
+
"local_path_relative", "missing_local", "local_not_git",
|
|
818
|
+
"local_remote_missing", "local_remote_mismatch",
|
|
819
|
+
"duplicate_local", "duplicate_github", "orphaned_folder",
|
|
820
|
+
"track_unreadable",
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
with mock.patch("commands.doctor.DEFAULT_CONFIG_PATH", cfg_path), \
|
|
824
|
+
mock.patch("commands.doctor.load_config", side_effect=_load), \
|
|
825
|
+
mock.patch("commands.doctor.repo_full_name", side_effect=_resolve):
|
|
826
|
+
|
|
827
|
+
with mock.patch("sys.stdout", new_callable=__import__("io").StringIO) as out_before:
|
|
828
|
+
doctor.run(["--json"])
|
|
829
|
+
pre_blob = json.loads(out_before.getvalue())
|
|
830
|
+
pre_types = {f["type"] for f in pre_blob["findings"]}
|
|
831
|
+
|
|
832
|
+
with mock.patch("sys.stdout", new_callable=__import__("io").StringIO) as out_after:
|
|
833
|
+
doctor.run(["--json", "--fix"])
|
|
834
|
+
post_blob = json.loads(out_after.getvalue())
|
|
835
|
+
residual_types = {f["type"] for f in post_blob["findings"]}
|
|
836
|
+
|
|
837
|
+
# Sanity check on the fixture itself: the pre-fix scan must have
|
|
838
|
+
# surfaced every finding type exactly once, including both
|
|
839
|
+
# fixable ones — otherwise this test isn't exercising the whole
|
|
840
|
+
# surface, just a subset of it.
|
|
841
|
+
self.assertEqual(pre_types, report_only_expected | fixable_types)
|
|
842
|
+
|
|
843
|
+
# The non-negotiable assertion: after --fix, the residual
|
|
844
|
+
# finding-type set is EXACTLY the report-only types — not empty,
|
|
845
|
+
# and not the pre-fix set either.
|
|
846
|
+
self.assertEqual(residual_types, report_only_expected)
|
|
847
|
+
self.assertNotIn("github_rename_detected", residual_types)
|
|
848
|
+
self.assertNotIn("stale_frontmatter", residual_types)
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
if __name__ == "__main__":
|
|
852
|
+
unittest.main()
|