@stylusnexus/work-plan 2026.7.13 → 2026.7.16

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.
@@ -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()