wikifier 4.5.4__tar.gz → 4.5.6__tar.gz

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.
Files changed (54) hide show
  1. {wikifier-4.5.4/wikifier.egg-info → wikifier-4.5.6}/PKG-INFO +1 -1
  2. wikifier-4.5.6/tests/test_gap_closure.py +274 -0
  3. {wikifier-4.5.4 → wikifier-4.5.6}/wikifier/__init__.py +1 -1
  4. {wikifier-4.5.4 → wikifier-4.5.6}/wikifier/cli.py +113 -29
  5. {wikifier-4.5.4 → wikifier-4.5.6}/wikifier/health.py +477 -115
  6. {wikifier-4.5.4 → wikifier-4.5.6}/wikifier/mcp/server.py +14 -2
  7. {wikifier-4.5.4 → wikifier-4.5.6/wikifier.egg-info}/PKG-INFO +1 -1
  8. wikifier-4.5.4/tests/test_gap_closure.py +0 -128
  9. {wikifier-4.5.4 → wikifier-4.5.6}/CONTRIBUTING.md +0 -0
  10. {wikifier-4.5.4 → wikifier-4.5.6}/LICENSE +0 -0
  11. {wikifier-4.5.4 → wikifier-4.5.6}/MANIFEST.in +0 -0
  12. {wikifier-4.5.4 → wikifier-4.5.6}/README.md +0 -0
  13. {wikifier-4.5.4 → wikifier-4.5.6}/diagnostics.html +0 -0
  14. {wikifier-4.5.4 → wikifier-4.5.6}/docs/spec.md +0 -0
  15. {wikifier-4.5.4 → wikifier-4.5.6}/index.html +0 -0
  16. {wikifier-4.5.4 → wikifier-4.5.6}/pyproject.toml +0 -0
  17. {wikifier-4.5.4 → wikifier-4.5.6}/setup.cfg +0 -0
  18. {wikifier-4.5.4 → wikifier-4.5.6}/skills/run.md +0 -0
  19. {wikifier-4.5.4 → wikifier-4.5.6}/tests/test_barrel_invalidation.py +0 -0
  20. {wikifier-4.5.4 → wikifier-4.5.6}/tests/test_health.py +0 -0
  21. {wikifier-4.5.4 → wikifier-4.5.6}/tests/test_import_cache.py +0 -0
  22. {wikifier-4.5.4 → wikifier-4.5.6}/tests/test_multi_lang_parsers.py +0 -0
  23. {wikifier-4.5.4 → wikifier-4.5.6}/tests/test_parsers.py +0 -0
  24. {wikifier-4.5.4 → wikifier-4.5.6}/tests/test_selftest_wrappers.py +0 -0
  25. {wikifier-4.5.4 → wikifier-4.5.6}/wikifier/__main__.py +0 -0
  26. {wikifier-4.5.4 → wikifier-4.5.6}/wikifier/contracts.py +0 -0
  27. {wikifier-4.5.4 → wikifier-4.5.6}/wikifier/daemon.py +0 -0
  28. {wikifier-4.5.4 → wikifier-4.5.6}/wikifier/diagnostics.py +0 -0
  29. {wikifier-4.5.4 → wikifier-4.5.6}/wikifier/import_cache.py +0 -0
  30. {wikifier-4.5.4 → wikifier-4.5.6}/wikifier/index.html +0 -0
  31. {wikifier-4.5.4 → wikifier-4.5.6}/wikifier/library.py +0 -0
  32. {wikifier-4.5.4 → wikifier-4.5.6}/wikifier/locking.py +0 -0
  33. {wikifier-4.5.4 → wikifier-4.5.6}/wikifier/mcp/__init__.py +0 -0
  34. {wikifier-4.5.4 → wikifier-4.5.6}/wikifier/parsers/__init__.py +0 -0
  35. {wikifier-4.5.4 → wikifier-4.5.6}/wikifier/parsers/_edge.py +0 -0
  36. {wikifier-4.5.4 → wikifier-4.5.6}/wikifier/parsers/bree.py +0 -0
  37. {wikifier-4.5.4 → wikifier-4.5.6}/wikifier/parsers/c_cpp.py +0 -0
  38. {wikifier-4.5.4 → wikifier-4.5.6}/wikifier/parsers/cdia.py +0 -0
  39. {wikifier-4.5.4 → wikifier-4.5.6}/wikifier/parsers/csharp.py +0 -0
  40. {wikifier-4.5.4 → wikifier-4.5.6}/wikifier/parsers/go_lang.py +0 -0
  41. {wikifier-4.5.4 → wikifier-4.5.6}/wikifier/parsers/java.py +0 -0
  42. {wikifier-4.5.4 → wikifier-4.5.6}/wikifier/parsers/javascript.py +0 -0
  43. {wikifier-4.5.4 → wikifier-4.5.6}/wikifier/parsers/python.py +0 -0
  44. {wikifier-4.5.4 → wikifier-4.5.6}/wikifier/parsers/rust.py +0 -0
  45. {wikifier-4.5.4 → wikifier-4.5.6}/wikifier/resolution.py +0 -0
  46. {wikifier-4.5.4 → wikifier-4.5.6}/wikifier/scripts/wikifier.bat +0 -0
  47. {wikifier-4.5.4 → wikifier-4.5.6}/wikifier/scripts/wikifier.ps1 +0 -0
  48. {wikifier-4.5.4 → wikifier-4.5.6}/wikifier/scripts/wikifier.sh +0 -0
  49. {wikifier-4.5.4 → wikifier-4.5.6}/wikifier/serve.py +0 -0
  50. {wikifier-4.5.4 → wikifier-4.5.6}/wikifier.egg-info/SOURCES.txt +0 -0
  51. {wikifier-4.5.4 → wikifier-4.5.6}/wikifier.egg-info/dependency_links.txt +0 -0
  52. {wikifier-4.5.4 → wikifier-4.5.6}/wikifier.egg-info/entry_points.txt +0 -0
  53. {wikifier-4.5.4 → wikifier-4.5.6}/wikifier.egg-info/requires.txt +0 -0
  54. {wikifier-4.5.4 → wikifier-4.5.6}/wikifier.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wikifier
3
- Version: 4.5.4
3
+ Version: 4.5.6
4
4
  Summary: Zero-dependency agent-to-agent codebase wiki for LLMs and AI agents. Autonomously maintained via record-change and mark-green for token-efficient lookup of files, dependencies, health, and summaries — across tiny scripts to 50k+ monorepos. Optional MCP server with rich tools for agents.
5
5
  Author-email: Aron Amos <aron@example.com>
6
6
  Maintainer: Aron Amos
@@ -0,0 +1,274 @@
1
+ """Gap-closure regressions (G4 ACS actionable, G5 health module, G7 ghosts/deletion)."""
2
+
3
+ import importlib
4
+ import unittest
5
+
6
+ from tests._base import TempProjectTestCase
7
+
8
+ from wikifier import import_cache as ic
9
+ from wikifier import cli
10
+
11
+
12
+ class TestHealthModuleImportG5(unittest.TestCase):
13
+ def test_importlib_health_is_module_with_get_summary(self):
14
+ mod = importlib.import_module("wikifier.health")
15
+ self.assertTrue(hasattr(mod, "get_summary"))
16
+ self.assertTrue(hasattr(mod, "find_ghost_entries"))
17
+ self.assertTrue(callable(mod.get_summary))
18
+
19
+ def test_health_module_alias_on_package(self):
20
+ import wikifier
21
+ self.assertTrue(hasattr(wikifier, "health_module"))
22
+ self.assertTrue(hasattr(wikifier.health_module, "get_summary"))
23
+ # Convenience function still exported as wikifier.health
24
+ self.assertTrue(callable(wikifier.health))
25
+
26
+
27
+ class TestAcsActionableG4(TempProjectTestCase):
28
+ def test_external_noise_excluded_from_actionable_low_conf(self):
29
+ cache = {}
30
+ # Project-internal low-conf edge
31
+ ic.update_file_data(
32
+ cache,
33
+ "app.py",
34
+ mtime=1,
35
+ imports=["./util"],
36
+ resolved_pairs=[{
37
+ "raw": "./util",
38
+ "resolved": "util.py",
39
+ "resolved_path": "util.py",
40
+ "confidence_score": 0.4,
41
+ "confidence_reasons": ["base:low", "via_barrel"],
42
+ "confidence_explanation": "Base low. Recommendation: Review project edge.",
43
+ "diagnostic": {"category": "project"},
44
+ "resolution_metadata": {"strategy": "relative-fs"},
45
+ }],
46
+ )
47
+ # External/stdlib-style edge (noise)
48
+ ic.update_file_data(
49
+ cache,
50
+ "app.py",
51
+ mtime=1,
52
+ imports=["json", "./util"],
53
+ resolved_pairs=[
54
+ {
55
+ "raw": "./util",
56
+ "resolved": "util.py",
57
+ "resolved_path": "util.py",
58
+ "confidence_score": 0.4,
59
+ "confidence_reasons": ["base:low"],
60
+ "confidence_explanation": "Project low. Recommendation: Review.",
61
+ "resolution_metadata": {"strategy": "relative-fs"},
62
+ },
63
+ {
64
+ "raw": "json",
65
+ "resolved": "json",
66
+ "resolved_path": "",
67
+ "confidence_score": 0.48,
68
+ "confidence_reasons": ["base:medium", "no_resolved_path"],
69
+ "confidence_explanation": "stdlib. Recommendation: harden import.",
70
+ "diagnostic": {"category": "external_or_bare"},
71
+ "resolution_metadata": {"strategy": "python-bare-or-external"},
72
+ },
73
+ ],
74
+ )
75
+ # Note: update_file_data may replace pairs — set explicitly
76
+ cache["app.py"]["resolved_pairs"] = [
77
+ {
78
+ "raw": "./util",
79
+ "resolved": "util.py",
80
+ "resolved_path": "util.py",
81
+ "confidence_score": 0.4,
82
+ "confidence_reasons": ["base:low"],
83
+ "confidence_explanation": "Project low. Recommendation: Review.",
84
+ "resolution_metadata": {"strategy": "relative-fs"},
85
+ },
86
+ {
87
+ "raw": "json",
88
+ "resolved": "json",
89
+ "confidence_score": 0.48,
90
+ "confidence_reasons": ["base:medium"],
91
+ "confidence_explanation": "stdlib noise. Recommendation: ignore.",
92
+ "diagnostic": {"category": "external_or_bare"},
93
+ "resolution_metadata": {"strategy": "python-bare-or-external"},
94
+ },
95
+ ]
96
+ summary = ic.compute_acs_summary(cache)
97
+ self.assertGreaterEqual(summary["low_conf_edges"], 1)
98
+ self.assertEqual(summary["actionable_low_conf_edges"], 1)
99
+ self.assertGreaterEqual(summary["external_noise_edges"], 1)
100
+ action = ic.get_low_confidence_edges(cache, actionable_only=True)
101
+ self.assertTrue(all(
102
+ (p.get("diagnostic") or {}).get("category") != "external_or_bare"
103
+ for p in action
104
+ ))
105
+
106
+
107
+ class TestGhostAndDeletionG7(TempProjectTestCase):
108
+ def test_find_ghost_and_record_deletion(self):
109
+ health_mod = importlib.import_module("wikifier.health")
110
+ self.write("keep.py", "x=1\n")
111
+ self.write("gone.py", "y=1\n")
112
+ health_mod.upsert_entry(self.root, "keep.py", "🟢 Green", "ok")
113
+ health_mod.upsert_entry(self.root, "gone.py", "🟢 Green", "ok")
114
+ (self.root / "gone.py").unlink()
115
+
116
+ ghosts = health_mod.find_ghost_entries(self.root)
117
+ self.assertIn("gone.py", ghosts)
118
+ self.assertNotIn("keep.py", ghosts)
119
+
120
+ res = cli.record_deletion("gone.py", "removed in cleanup", project_root=self.root)
121
+ self.assertTrue(res.get("success"))
122
+ entries = health_mod.load_health(self.root)["entries"]
123
+ self.assertIn("🔴", entries["gone.py"]["status"])
124
+ self.assertIn("DELETED", entries["gone.py"]["reason"])
125
+
126
+ def test_record_deletion_rejects_flag_paths(self):
127
+ res = cli.record_deletion("--help", "oops", project_root=self.root)
128
+ self.assertFalse(res.get("success"))
129
+ self.assertIn("flag", (res.get("error") or "").lower())
130
+
131
+
132
+ class TestPendingAndPollutionHygiene(TempProjectTestCase):
133
+ def test_pending_empty_marker_not_counted_and_no_dual_state(self):
134
+ health_mod = importlib.import_module("wikifier.health")
135
+ # Dual-state pollution (historical bug): empty marker + real items
136
+ p = self.root / "pending_updates.md"
137
+ p.write_text(
138
+ "# Pending Updates\n\n(no active items)\n- src/a.py: review\n",
139
+ encoding="utf-8",
140
+ )
141
+ self.assertEqual(health_mod.count_pending(self.root), 1)
142
+ # Normalize via remove of the only item → empty marker only
143
+ health_mod.remove_from_pending(self.root, "src/a.py")
144
+ text = p.read_text(encoding="utf-8")
145
+ self.assertIn("no pending", text.lower())
146
+ self.assertNotIn("- src/a.py", text)
147
+ self.assertEqual(health_mod.count_pending(self.root), 0)
148
+ # Add clears empty marker
149
+ health_mod.add_to_pending(self.root, "src/b.py", "need wiki")
150
+ text2 = p.read_text(encoding="utf-8")
151
+ self.assertIn("- src/b.py:", text2)
152
+ self.assertNotIn("no pending", text2.lower())
153
+ self.assertNotIn("no active", text2.lower())
154
+ self.assertEqual(health_mod.count_pending(self.root), 1)
155
+ summary = health_mod.get_summary(self.root)
156
+ self.assertEqual(summary["pending_updates"], 1)
157
+
158
+ def test_superseded_and_flag_deleted_keys_pruned(self):
159
+ health_mod = importlib.import_module("wikifier.health")
160
+ # Inject pollution keys directly then save
161
+ data = {
162
+ "version": 2,
163
+ "last_updated": "2026-01-01",
164
+ "entries": {
165
+ "M5.3 Cycle1 evidence append: 3 subs spawned": {
166
+ "status": "🔴 Red",
167
+ "reason": "DELETED — Historical early M5.3 launch note",
168
+ "last_updated": "2026-01-01",
169
+ },
170
+ "--help": {
171
+ "status": "🔴 Red",
172
+ "reason": "DELETED — removed",
173
+ "last_updated": "2026-01-01",
174
+ },
175
+ "real.py": {
176
+ "status": "🟢 Green",
177
+ "reason": "ok",
178
+ "last_updated": "2026-01-01",
179
+ },
180
+ },
181
+ }
182
+ health_mod.save_health(self.root, data)
183
+ entries = health_mod.load_health(self.root)["entries"]
184
+ self.assertNotIn("M5.3 Cycle1 evidence append: 3 subs spawned", entries)
185
+ self.assertNotIn("--help", entries)
186
+ self.assertIn("real.py", entries)
187
+ # Real path DELETED audit is kept
188
+ health_mod.upsert_entry(self.root, "gone_real.py", "🔴 Red", "DELETED — intentional")
189
+ entries2 = health_mod.load_health(self.root)["entries"]
190
+ self.assertIn("gone_real.py", entries2)
191
+
192
+
193
+ class TestMapFirstHealthAndValidate(TempProjectTestCase):
194
+ def test_deep_relative_keys_are_under_root(self):
195
+ """Regression: deep monorepo rel paths must not be treated as abs pollution."""
196
+ health_mod = importlib.import_module("wikifier.health")
197
+ deep = "airflow-core/src/airflow/api/client/local_client.py"
198
+ self.assertTrue(health_mod._entry_is_under_root(self.root, deep))
199
+ self.assertTrue(health_mod._entry_is_under_root(self.root, "pkg/a.py"))
200
+
201
+ def test_seed_health_from_map_creates_stubs(self):
202
+ health_mod = importlib.import_module("wikifier.health")
203
+ from wikifier import import_cache as ic
204
+
205
+ self.write("pkg/a.py", "import b\n")
206
+ self.write("pkg/b.py", "x=1\n")
207
+ self.write("README.md", "# hi\n")
208
+ cache = {}
209
+ ic.update_file_data(cache, "pkg/a.py", mtime=1, imports=["b"], resolved_pairs=[])
210
+ ic.update_file_data(cache, "pkg/b.py", mtime=1, imports=[], resolved_pairs=[])
211
+ ic.save_cache(self.root, cache)
212
+
213
+ # No health file yet
214
+ self.assertFalse((self.root / "file_health.json").exists())
215
+ res = health_mod.seed_health_from_map(self.root)
216
+ self.assertTrue(res.get("success"))
217
+ self.assertGreaterEqual(res.get("seeded"), 2)
218
+ entries = health_mod.load_health(self.root)["entries"]
219
+ self.assertIn("pkg/a.py", entries)
220
+ self.assertIn("pkg/b.py", entries)
221
+ self.assertTrue(entries["pkg/a.py"]["status"].startswith("🟡"))
222
+
223
+ def test_validate_ignores_non_source_and_reports_mapped_gaps(self):
224
+ health_mod = importlib.import_module("wikifier.health")
225
+ from wikifier import import_cache as ic
226
+
227
+ self.write("src/app.py", "print(1)\n")
228
+ self.write("src/notes.md", "docs\n")
229
+ self.write("src/logo.png", "x")
230
+ (self.root / "monitored_paths.txt").write_text("src\n", encoding="utf-8")
231
+ cache = {}
232
+ ic.update_file_data(cache, "src/app.py", mtime=1, imports=[], resolved_pairs=[])
233
+ ic.update_file_data(cache, "src/other.py", mtime=1, imports=[], resolved_pairs=[])
234
+ ic.save_cache(self.root, cache)
235
+
236
+ # No health yet — in-scope mapped + monitored parseable gaps
237
+ v = health_mod.validate_health(self.root)
238
+ self.assertTrue(v.get("map_first"))
239
+ self.assertGreaterEqual(v.get("mapped_in_scope_without_health_count", 0), 1)
240
+ self.assertGreaterEqual(v.get("missing_count", 0), 1)
241
+ # notes.md / logo.png must not appear as monitored-source gaps
242
+ mon_missing = v.get("missing_monitored_source") or []
243
+ self.assertTrue(all(not m.endswith(".md") and not m.endswith(".png") for m in mon_missing))
244
+
245
+ health_mod.seed_health_from_map(self.root) # only_monitored default
246
+ # Seed in-scope map keys; app.py on disk + mapped in-scope covered
247
+ v2 = health_mod.validate_health(self.root)
248
+ self.assertEqual(v2.get("mapped_in_scope_without_health_count"), 0)
249
+ # missing_count may still include on-disk sources not in map (ok if 0 after seed of map keys only)
250
+ self.assertEqual(v2.get("mapped_in_scope_without_health_count"), 0)
251
+
252
+ def test_prune_pending_and_health_to_monitored(self):
253
+ health_mod = importlib.import_module("wikifier.health")
254
+ (self.root / "monitored_paths.txt").write_text("src\n", encoding="utf-8")
255
+ self.write("src/keep.py", "k=1\n")
256
+ self.write("other/out.py", "o=1\n")
257
+ health_mod.upsert_entry(self.root, "src/keep.py", "🟡 Yellow", "in scope")
258
+ health_mod.upsert_entry(self.root, "other/out.py", "🟡 Yellow", "out of scope")
259
+ health_mod.add_to_pending(self.root, "src/keep.py", "need wiki")
260
+ health_mod.add_to_pending(self.root, "other/out.py", "noise")
261
+
262
+ pr = health_mod.prune_pending_to_monitored(self.root)
263
+ self.assertEqual(pr.get("removed"), 1)
264
+ self.assertEqual(health_mod.count_pending(self.root), 1)
265
+
266
+ hr = health_mod.prune_health_outside_monitored(self.root)
267
+ self.assertEqual(hr.get("removed"), 1)
268
+ entries = health_mod.load_health(self.root)["entries"]
269
+ self.assertIn("src/keep.py", entries)
270
+ self.assertNotIn("other/out.py", entries)
271
+
272
+
273
+ if __name__ == "__main__":
274
+ unittest.main()
@@ -71,4 +71,4 @@ from .contracts import (
71
71
  compute_acs_confidence,
72
72
  )
73
73
 
74
- __version__ = "4.5.4"
74
+ __version__ = "4.5.6"
@@ -616,23 +616,6 @@ def run_full_update(
616
616
  result["parse_errors"] = parse_errors[:10]
617
617
  result["parse_error_count"] = len(parse_errors)
618
618
 
619
- # Seed health stubs for newly parsed files (map-first wiki: agents fill prose later)
620
- health_seeded = 0
621
- if _health_mod is not None and new_entries:
622
- try:
623
- existing = (_health_mod.load_health(root) or {}).get("entries") or {}
624
- for rel in new_entries:
625
- if rel in existing:
626
- continue
627
- _health_mod.upsert_entry(
628
- root, rel, "🟡 Yellow",
629
- "Initial stub — parsed into map; agent should wiki + mark-green when editing"
630
- )
631
- health_seeded += 1
632
- except Exception:
633
- health_seeded = 0
634
- result["health_stubs_seeded"] = health_seeded
635
-
636
619
  # === 4. Persist (single save; reload first to pick up the barrel flush) ===
637
620
  cache = ic.load_cache(root) or {}
638
621
  cache.update(new_entries)
@@ -669,6 +652,27 @@ def run_full_update(
669
652
  except Exception:
670
653
  pass
671
654
 
655
+ # === 5b. Map-first health stubs (always backfill; warm cache safe) ===
656
+ # 0-dirty incremental runs never used to create file_health.json — fixed here.
657
+ health_seeded = 0
658
+ if _health_mod is not None and hasattr(_health_mod, "seed_health_from_map"):
659
+ try:
660
+ max_seed = int(os.environ.get("WIKIFIER_HEALTH_SEED_MAX", "20000") or "20000")
661
+ map_keys = [k for k in cache if isinstance(k, str) and k and not k.startswith("_")]
662
+ seed_res = _health_mod.seed_health_from_map(
663
+ root, map_keys=map_keys, max_new=max_seed,
664
+ )
665
+ health_seeded = int(seed_res.get("seeded") or 0)
666
+ if hasattr(_health_mod, "seed_health_for_monitored_sources"):
667
+ disk_res = _health_mod.seed_health_for_monitored_sources(
668
+ root, max_new=max_seed,
669
+ )
670
+ health_seeded += int(disk_res.get("seeded") or 0)
671
+ except Exception as se:
672
+ result["health_seed_error"] = str(se)
673
+ health_seeded = 0
674
+ result["health_stubs_seeded"] = health_seeded
675
+
672
676
  # === 6. library.md (atomic; pure Python) ===
673
677
  try:
674
678
  from .library import write_library_md
@@ -805,16 +809,40 @@ def main():
805
809
  if res.get("message"):
806
810
  print(res["message"])
807
811
  return 0 if res.get("success", True) else 1
808
- if _cmd0 == "record-change" and len(_args) >= 2:
812
+ if _cmd0 == "record-change":
813
+ if not _args or _args[0] in ("--help", "-h", "help"):
814
+ print('Usage: wikifier record-change <file> "<reason>"')
815
+ return 0
816
+ if _args[0].startswith("-") or len(_args) < 2:
817
+ print('Usage: wikifier record-change <file> "<reason>"', file=sys.stderr)
818
+ return 1
809
819
  res = record_change(_args[0], " ".join(_args[1:]), project_root=project_root)
810
820
  print(res.get("message") or res)
811
821
  return 0 if res.get("success") else 1
812
- if _cmd0 == "mark-green" and len(_args) >= 1:
822
+ if _cmd0 == "mark-green":
823
+ if not _args or _args[0] in ("--help", "-h", "help"):
824
+ print("Usage: wikifier mark-green <file> [reason]")
825
+ return 0
826
+ if _args[0].startswith("-"):
827
+ print("Usage: wikifier mark-green <file> [reason]", file=sys.stderr)
828
+ return 1
813
829
  reason = " ".join(_args[1:]) if len(_args) > 1 else ""
814
830
  res = mark_green(_args[0], reason, project_root=project_root)
815
831
  print(res.get("message") or res)
816
832
  return 0 if res.get("success") else 1
817
- if _cmd0 == "record-deletion" and len(_args) >= 1:
833
+ if _cmd0 == "record-deletion":
834
+ # Guard: `record-deletion --help` must not treat `--help` as a file path
835
+ # (that polluted health with a 🔴 DELETED "--help" key).
836
+ if not _args or _args[0] in ("--help", "-h", "help"):
837
+ print('Usage: wikifier record-deletion <file> "<reason>"')
838
+ return 0
839
+ if _args[0].startswith("-"):
840
+ print(
841
+ 'Usage: wikifier record-deletion <file> "<reason>"\n'
842
+ " <file> must be a project path, not a flag.",
843
+ file=sys.stderr,
844
+ )
845
+ return 1
818
846
  reason = " ".join(_args[1:]) if len(_args) > 1 else "removed"
819
847
  res = record_deletion(_args[0], reason, project_root=project_root)
820
848
  print(res.get("message") or res)
@@ -833,13 +861,40 @@ def main():
833
861
  if _health_mod is not None:
834
862
  root = _get_effective_root(project_root)
835
863
  res = _health_mod.validate_health(root)
836
- ghosts = []
837
- if hasattr(_health_mod, "find_ghost_entries"):
838
- ghosts = _health_mod.find_ghost_entries(root)
839
- res["ghost_count"] = len(ghosts)
840
- res["ghosts"] = ghosts[:50]
841
864
  print(_json.dumps(res, indent=2, default=str))
865
+ # Map-first: exit 0 when map is covered (or no map + no monitored source gaps)
842
866
  return 0 if res.get("missing_count", 0) == 0 else 1
867
+ if _cmd0 in ("seed-health", "seed-health-from-map"):
868
+ import json as _json
869
+ if _health_mod is None:
870
+ print("[wikifier] health module unavailable", file=sys.stderr)
871
+ return 1
872
+ root = _get_effective_root(project_root)
873
+ res = _health_mod.seed_health_from_map(root)
874
+ if hasattr(_health_mod, "seed_health_for_monitored_sources"):
875
+ disk = _health_mod.seed_health_for_monitored_sources(root)
876
+ res["disk_seeded"] = disk.get("seeded")
877
+ res["seeded_total"] = int(res.get("seeded") or 0) + int(disk.get("seeded") or 0)
878
+ print(_json.dumps(res, indent=2, default=str))
879
+ return 0 if res.get("success") else 1
880
+ if _cmd0 in ("prune-pending", "prune-pending-monitored"):
881
+ import json as _json
882
+ if _health_mod is None:
883
+ print("[wikifier] health module unavailable", file=sys.stderr)
884
+ return 1
885
+ root = _get_effective_root(project_root)
886
+ res = _health_mod.prune_pending_to_monitored(root)
887
+ print(_json.dumps(res, indent=2, default=str))
888
+ return 0 if res.get("success") else 1
889
+ if _cmd0 in ("prune-health-monitored", "prune-health"):
890
+ import json as _json
891
+ if _health_mod is None:
892
+ print("[wikifier] health module unavailable", file=sys.stderr)
893
+ return 1
894
+ root = _get_effective_root(project_root)
895
+ res = _health_mod.prune_health_outside_monitored(root)
896
+ print(_json.dumps(res, indent=2, default=str))
897
+ return 0 if res.get("success") else 1
843
898
  except Exception as e:
844
899
  print(f"[wikifier] Python-primary {_cmd0} failed: {e}", file=sys.stderr)
845
900
  return 1
@@ -1039,8 +1094,14 @@ def _ensure_journal_entry(root: Path, action: str, file: str, reason: str) -> No
1039
1094
 
1040
1095
 
1041
1096
  def _add_to_pending(root: Path, file: str, msg: str) -> None:
1042
- """Pure-Py pending_updates.md appender (skeleton, matches sh add_pending)."""
1097
+ """Append to pending_updates.md via health helpers (normalized empty/items)."""
1043
1098
  try:
1099
+ if _health_mod is not None and hasattr(_health_mod, "add_to_pending"):
1100
+ # Caller may already hold project lock (re-entrant).
1101
+ _health_mod._do_add_to_pending(root, file, msg) if hasattr(
1102
+ _health_mod, "_do_add_to_pending"
1103
+ ) else _health_mod.add_to_pending(root, file, msg)
1104
+ return
1044
1105
  p = root / "pending_updates.md"
1045
1106
  line = f"- {file}: {msg}\n"
1046
1107
  with open(p, "a", encoding="utf-8") as f:
@@ -1050,8 +1111,14 @@ def _add_to_pending(root: Path, file: str, msg: str) -> None:
1050
1111
 
1051
1112
 
1052
1113
  def _remove_from_pending(root: Path, file: str) -> None:
1053
- """Best-effort removal (used by mark_green skeleton)."""
1114
+ """Best-effort removal (used by mark_green). Prefers health normalizer."""
1054
1115
  try:
1116
+ if _health_mod is not None and hasattr(_health_mod, "_do_remove_from_pending"):
1117
+ _health_mod._do_remove_from_pending(root, file)
1118
+ return
1119
+ if _health_mod is not None and hasattr(_health_mod, "remove_from_pending"):
1120
+ _health_mod.remove_from_pending(root, file)
1121
+ return
1055
1122
  p = root / "pending_updates.md"
1056
1123
  if p.exists():
1057
1124
  lines = [ln for ln in p.read_text(encoding="utf-8").splitlines() if file not in ln]
@@ -1161,6 +1228,8 @@ def check_changes(project_root: Optional[Union[str, Path]] = None) -> Dict[str,
1161
1228
  ghosts_marked = 0
1162
1229
  if _health_mod is not None:
1163
1230
  root_res = root.resolve()
1231
+ # Prefer unlocked helpers while we already hold project lock
1232
+ _upsert = getattr(_health_mod, "_do_upsert_entry", None) or _health_mod.upsert_entry
1164
1233
  for p in dirty_batch:
1165
1234
  try:
1166
1235
  pr = Path(p).resolve()
@@ -1169,13 +1238,20 @@ def check_changes(project_root: Optional[Union[str, Path]] = None) -> Dict[str,
1169
1238
  rel = str(pr.relative_to(root_res))
1170
1239
  except Exception:
1171
1240
  continue
1172
- _health_mod.upsert_entry(
1241
+ _upsert(
1173
1242
  root, rel, "🟡 Yellow",
1174
1243
  "mtime changed since last check-changes (Python primary auto-detected)"
1175
1244
  )
1176
1245
  _add_to_pending(root, rel, "Auto-detected modification — review and run mark-green after wiki update")
1177
1246
  _ensure_journal_entry(root, "auto-detected", rel, "File mtime changed (check_changes Python primary)")
1178
1247
  changed_count += 1
1248
+ # Keep pending queue aligned with lean monitored_paths (no flood outside scope)
1249
+ if hasattr(_health_mod, "prune_pending_to_monitored"):
1250
+ try:
1251
+ pr = _health_mod.prune_pending_to_monitored(root)
1252
+ result["pending_pruned"] = pr.get("removed", 0)
1253
+ except Exception:
1254
+ pass
1179
1255
 
1180
1256
  # G7: surface ghost health entries (tracked path missing on disk, not already DELETED)
1181
1257
  try:
@@ -1265,9 +1341,13 @@ def record_deletion(file: str, reason: str, project_root: Optional[Union[str, Pa
1265
1341
 
1266
1342
  G7: marks 🔴 DELETED, pending + journal, and best-effort prunes barrel cache
1267
1343
  references so deleted paths do not keep invalidating importers forever.
1344
+ Rejects flag-like paths (`--help`) so CLI misuse cannot pollute health.
1268
1345
  """
1269
1346
  root = _get_effective_root(project_root)
1270
1347
  result: Dict[str, Any] = {"success": False, "file": file, "project_root": str(root), "action": "deletion"}
1348
+ if not file or str(file).startswith("-") or str(file) in ("--help", "-h", "help"):
1349
+ result["error"] = "file must be a project path, not a flag/empty string"
1350
+ return result
1271
1351
  try:
1272
1352
  lock_ctx = (locking.file_lock(root) if locking is not None else _nullcontext())
1273
1353
  with lock_ctx:
@@ -1279,7 +1359,11 @@ def record_deletion(file: str, reason: str, project_root: Optional[Union[str, Pa
1279
1359
  except Exception:
1280
1360
  rel = file
1281
1361
  if _health_mod is not None:
1282
- _health_mod.upsert_entry(root, rel, "🔴 Red", f"DELETED {reason}")
1362
+ # Prefer unlocked upsert when we already hold the project lock.
1363
+ if hasattr(_health_mod, "_do_upsert_entry"):
1364
+ _health_mod._do_upsert_entry(root, rel, "🔴 Red", f"DELETED — {reason}")
1365
+ else:
1366
+ _health_mod.upsert_entry(root, rel, "🔴 Red", f"DELETED — {reason}")
1283
1367
  _add_to_pending(root, rel, f"File was deleted. Consider wiki archival. {reason}")
1284
1368
  _ensure_journal_entry(root, "record-deletion", rel, reason or "No reason provided.")
1285
1369
  prune_stats: Dict[str, Any] = {}