wikifier 4.5.4__tar.gz → 4.5.5__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 (53) hide show
  1. {wikifier-4.5.4/wikifier.egg-info → wikifier-4.5.5}/PKG-INFO +1 -1
  2. {wikifier-4.5.4 → wikifier-4.5.5}/tests/test_gap_closure.py +66 -0
  3. {wikifier-4.5.4 → wikifier-4.5.5}/wikifier/__init__.py +1 -1
  4. {wikifier-4.5.4 → wikifier-4.5.5}/wikifier/cli.py +50 -6
  5. {wikifier-4.5.4 → wikifier-4.5.5}/wikifier/health.py +128 -77
  6. {wikifier-4.5.4 → wikifier-4.5.5}/wikifier/mcp/server.py +14 -2
  7. {wikifier-4.5.4 → wikifier-4.5.5/wikifier.egg-info}/PKG-INFO +1 -1
  8. {wikifier-4.5.4 → wikifier-4.5.5}/CONTRIBUTING.md +0 -0
  9. {wikifier-4.5.4 → wikifier-4.5.5}/LICENSE +0 -0
  10. {wikifier-4.5.4 → wikifier-4.5.5}/MANIFEST.in +0 -0
  11. {wikifier-4.5.4 → wikifier-4.5.5}/README.md +0 -0
  12. {wikifier-4.5.4 → wikifier-4.5.5}/diagnostics.html +0 -0
  13. {wikifier-4.5.4 → wikifier-4.5.5}/docs/spec.md +0 -0
  14. {wikifier-4.5.4 → wikifier-4.5.5}/index.html +0 -0
  15. {wikifier-4.5.4 → wikifier-4.5.5}/pyproject.toml +0 -0
  16. {wikifier-4.5.4 → wikifier-4.5.5}/setup.cfg +0 -0
  17. {wikifier-4.5.4 → wikifier-4.5.5}/skills/run.md +0 -0
  18. {wikifier-4.5.4 → wikifier-4.5.5}/tests/test_barrel_invalidation.py +0 -0
  19. {wikifier-4.5.4 → wikifier-4.5.5}/tests/test_health.py +0 -0
  20. {wikifier-4.5.4 → wikifier-4.5.5}/tests/test_import_cache.py +0 -0
  21. {wikifier-4.5.4 → wikifier-4.5.5}/tests/test_multi_lang_parsers.py +0 -0
  22. {wikifier-4.5.4 → wikifier-4.5.5}/tests/test_parsers.py +0 -0
  23. {wikifier-4.5.4 → wikifier-4.5.5}/tests/test_selftest_wrappers.py +0 -0
  24. {wikifier-4.5.4 → wikifier-4.5.5}/wikifier/__main__.py +0 -0
  25. {wikifier-4.5.4 → wikifier-4.5.5}/wikifier/contracts.py +0 -0
  26. {wikifier-4.5.4 → wikifier-4.5.5}/wikifier/daemon.py +0 -0
  27. {wikifier-4.5.4 → wikifier-4.5.5}/wikifier/diagnostics.py +0 -0
  28. {wikifier-4.5.4 → wikifier-4.5.5}/wikifier/import_cache.py +0 -0
  29. {wikifier-4.5.4 → wikifier-4.5.5}/wikifier/index.html +0 -0
  30. {wikifier-4.5.4 → wikifier-4.5.5}/wikifier/library.py +0 -0
  31. {wikifier-4.5.4 → wikifier-4.5.5}/wikifier/locking.py +0 -0
  32. {wikifier-4.5.4 → wikifier-4.5.5}/wikifier/mcp/__init__.py +0 -0
  33. {wikifier-4.5.4 → wikifier-4.5.5}/wikifier/parsers/__init__.py +0 -0
  34. {wikifier-4.5.4 → wikifier-4.5.5}/wikifier/parsers/_edge.py +0 -0
  35. {wikifier-4.5.4 → wikifier-4.5.5}/wikifier/parsers/bree.py +0 -0
  36. {wikifier-4.5.4 → wikifier-4.5.5}/wikifier/parsers/c_cpp.py +0 -0
  37. {wikifier-4.5.4 → wikifier-4.5.5}/wikifier/parsers/cdia.py +0 -0
  38. {wikifier-4.5.4 → wikifier-4.5.5}/wikifier/parsers/csharp.py +0 -0
  39. {wikifier-4.5.4 → wikifier-4.5.5}/wikifier/parsers/go_lang.py +0 -0
  40. {wikifier-4.5.4 → wikifier-4.5.5}/wikifier/parsers/java.py +0 -0
  41. {wikifier-4.5.4 → wikifier-4.5.5}/wikifier/parsers/javascript.py +0 -0
  42. {wikifier-4.5.4 → wikifier-4.5.5}/wikifier/parsers/python.py +0 -0
  43. {wikifier-4.5.4 → wikifier-4.5.5}/wikifier/parsers/rust.py +0 -0
  44. {wikifier-4.5.4 → wikifier-4.5.5}/wikifier/resolution.py +0 -0
  45. {wikifier-4.5.4 → wikifier-4.5.5}/wikifier/scripts/wikifier.bat +0 -0
  46. {wikifier-4.5.4 → wikifier-4.5.5}/wikifier/scripts/wikifier.ps1 +0 -0
  47. {wikifier-4.5.4 → wikifier-4.5.5}/wikifier/scripts/wikifier.sh +0 -0
  48. {wikifier-4.5.4 → wikifier-4.5.5}/wikifier/serve.py +0 -0
  49. {wikifier-4.5.4 → wikifier-4.5.5}/wikifier.egg-info/SOURCES.txt +0 -0
  50. {wikifier-4.5.4 → wikifier-4.5.5}/wikifier.egg-info/dependency_links.txt +0 -0
  51. {wikifier-4.5.4 → wikifier-4.5.5}/wikifier.egg-info/entry_points.txt +0 -0
  52. {wikifier-4.5.4 → wikifier-4.5.5}/wikifier.egg-info/requires.txt +0 -0
  53. {wikifier-4.5.4 → wikifier-4.5.5}/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.5
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
@@ -123,6 +123,72 @@ class TestGhostAndDeletionG7(TempProjectTestCase):
123
123
  self.assertIn("🔴", entries["gone.py"]["status"])
124
124
  self.assertIn("DELETED", entries["gone.py"]["reason"])
125
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
+
126
192
 
127
193
  if __name__ == "__main__":
128
194
  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.5"
@@ -805,16 +805,40 @@ def main():
805
805
  if res.get("message"):
806
806
  print(res["message"])
807
807
  return 0 if res.get("success", True) else 1
808
- if _cmd0 == "record-change" and len(_args) >= 2:
808
+ if _cmd0 == "record-change":
809
+ if not _args or _args[0] in ("--help", "-h", "help"):
810
+ print('Usage: wikifier record-change <file> "<reason>"')
811
+ return 0
812
+ if _args[0].startswith("-") or len(_args) < 2:
813
+ print('Usage: wikifier record-change <file> "<reason>"', file=sys.stderr)
814
+ return 1
809
815
  res = record_change(_args[0], " ".join(_args[1:]), project_root=project_root)
810
816
  print(res.get("message") or res)
811
817
  return 0 if res.get("success") else 1
812
- if _cmd0 == "mark-green" and len(_args) >= 1:
818
+ if _cmd0 == "mark-green":
819
+ if not _args or _args[0] in ("--help", "-h", "help"):
820
+ print("Usage: wikifier mark-green <file> [reason]")
821
+ return 0
822
+ if _args[0].startswith("-"):
823
+ print("Usage: wikifier mark-green <file> [reason]", file=sys.stderr)
824
+ return 1
813
825
  reason = " ".join(_args[1:]) if len(_args) > 1 else ""
814
826
  res = mark_green(_args[0], reason, project_root=project_root)
815
827
  print(res.get("message") or res)
816
828
  return 0 if res.get("success") else 1
817
- if _cmd0 == "record-deletion" and len(_args) >= 1:
829
+ if _cmd0 == "record-deletion":
830
+ # Guard: `record-deletion --help` must not treat `--help` as a file path
831
+ # (that polluted health with a 🔴 DELETED "--help" key).
832
+ if not _args or _args[0] in ("--help", "-h", "help"):
833
+ print('Usage: wikifier record-deletion <file> "<reason>"')
834
+ return 0
835
+ if _args[0].startswith("-"):
836
+ print(
837
+ 'Usage: wikifier record-deletion <file> "<reason>"\n'
838
+ " <file> must be a project path, not a flag.",
839
+ file=sys.stderr,
840
+ )
841
+ return 1
818
842
  reason = " ".join(_args[1:]) if len(_args) > 1 else "removed"
819
843
  res = record_deletion(_args[0], reason, project_root=project_root)
820
844
  print(res.get("message") or res)
@@ -1039,8 +1063,14 @@ def _ensure_journal_entry(root: Path, action: str, file: str, reason: str) -> No
1039
1063
 
1040
1064
 
1041
1065
  def _add_to_pending(root: Path, file: str, msg: str) -> None:
1042
- """Pure-Py pending_updates.md appender (skeleton, matches sh add_pending)."""
1066
+ """Append to pending_updates.md via health helpers (normalized empty/items)."""
1043
1067
  try:
1068
+ if _health_mod is not None and hasattr(_health_mod, "add_to_pending"):
1069
+ # Caller may already hold project lock (re-entrant).
1070
+ _health_mod._do_add_to_pending(root, file, msg) if hasattr(
1071
+ _health_mod, "_do_add_to_pending"
1072
+ ) else _health_mod.add_to_pending(root, file, msg)
1073
+ return
1044
1074
  p = root / "pending_updates.md"
1045
1075
  line = f"- {file}: {msg}\n"
1046
1076
  with open(p, "a", encoding="utf-8") as f:
@@ -1050,8 +1080,14 @@ def _add_to_pending(root: Path, file: str, msg: str) -> None:
1050
1080
 
1051
1081
 
1052
1082
  def _remove_from_pending(root: Path, file: str) -> None:
1053
- """Best-effort removal (used by mark_green skeleton)."""
1083
+ """Best-effort removal (used by mark_green). Prefers health normalizer."""
1054
1084
  try:
1085
+ if _health_mod is not None and hasattr(_health_mod, "_do_remove_from_pending"):
1086
+ _health_mod._do_remove_from_pending(root, file)
1087
+ return
1088
+ if _health_mod is not None and hasattr(_health_mod, "remove_from_pending"):
1089
+ _health_mod.remove_from_pending(root, file)
1090
+ return
1055
1091
  p = root / "pending_updates.md"
1056
1092
  if p.exists():
1057
1093
  lines = [ln for ln in p.read_text(encoding="utf-8").splitlines() if file not in ln]
@@ -1265,9 +1301,13 @@ def record_deletion(file: str, reason: str, project_root: Optional[Union[str, Pa
1265
1301
 
1266
1302
  G7: marks 🔴 DELETED, pending + journal, and best-effort prunes barrel cache
1267
1303
  references so deleted paths do not keep invalidating importers forever.
1304
+ Rejects flag-like paths (`--help`) so CLI misuse cannot pollute health.
1268
1305
  """
1269
1306
  root = _get_effective_root(project_root)
1270
1307
  result: Dict[str, Any] = {"success": False, "file": file, "project_root": str(root), "action": "deletion"}
1308
+ if not file or str(file).startswith("-") or str(file) in ("--help", "-h", "help"):
1309
+ result["error"] = "file must be a project path, not a flag/empty string"
1310
+ return result
1271
1311
  try:
1272
1312
  lock_ctx = (locking.file_lock(root) if locking is not None else _nullcontext())
1273
1313
  with lock_ctx:
@@ -1279,7 +1319,11 @@ def record_deletion(file: str, reason: str, project_root: Optional[Union[str, Pa
1279
1319
  except Exception:
1280
1320
  rel = file
1281
1321
  if _health_mod is not None:
1282
- _health_mod.upsert_entry(root, rel, "🔴 Red", f"DELETED {reason}")
1322
+ # Prefer unlocked upsert when we already hold the project lock.
1323
+ if hasattr(_health_mod, "_do_upsert_entry"):
1324
+ _health_mod._do_upsert_entry(root, rel, "🔴 Red", f"DELETED — {reason}")
1325
+ else:
1326
+ _health_mod.upsert_entry(root, rel, "🔴 Red", f"DELETED — {reason}")
1283
1327
  _add_to_pending(root, rel, f"File was deleted. Consider wiki archival. {reason}")
1284
1328
  _ensure_journal_entry(root, "record-deletion", rel, reason or "No reason provided.")
1285
1329
  prune_stats: Dict[str, Any] = {}
@@ -64,13 +64,60 @@ def _coerce_root(root) -> Path:
64
64
 
65
65
 
66
66
  # Post-v4.0 + M5.3 gate complete + cleanup hygiene:
67
- # Aggressively prune superseded historical wiki-note entries (e.g. early "M5.3 Cycle1 evidence append (from early coord, before alt gate pass)")
68
- # that survive under-root checks because they live under "Findings/" but are not maintained agent reference docs (Progress etc).
69
- # These pollute the health matrix / pending_updates that agents use for token-efficient lookup.
70
- # Keep real M5 docs (Progress, Milestones, Assessment, p6, M5.1-cross) + current gate/4.0 records.
71
- # Explicit Red "DELETED" audit records from record-deletion are intentionally kept (1 non-Green historical marker is useful/observable).
67
+ # Aggressively prune superseded historical wiki-note entries (e.g. early "M5.3 Cycle1 evidence append ")
68
+ # that are free-form note keys, not real project paths. These pollute the health matrix that agents use.
69
+ # Keep real M5 docs (Progress, Milestones, Assessment, p6, M5.1-cross).
70
+ # Real path deletions via record-deletion (e.g. "src/foo.py" + 🔴 DELETED) stay as audit rows.
71
+ # Free-form DELETED non-path keys (e.g. accidental `record-deletion --help`) are always pruned.
72
72
  SUPERSEDED_PATTERNS = ["m5.3 cycle1", "cycle1 evidence append", "early m5.3 launch note", "m5.3 cycle1 evidence"]
73
73
 
74
+ # Path-like health keys (real files we may keep as DELETED audits). Everything else that is
75
+ # already DELETED and not path-shaped is pollution.
76
+ _SOURCE_SUFFIXES = (
77
+ ".py", ".js", ".ts", ".tsx", ".jsx", ".mjs", ".cjs", ".md", ".json",
78
+ ".sh", ".toml", ".yaml", ".yml", ".html", ".css", ".rs", ".go", ".java",
79
+ ".cs", ".c", ".h", ".cpp", ".hpp", ".cc",
80
+ )
81
+
82
+
83
+ def _looks_like_path_key(key: str) -> bool:
84
+ """True if health key looks like a project-relative or absolute file path."""
85
+ if not key or not isinstance(key, str):
86
+ return False
87
+ if key.startswith(("-", "--")):
88
+ return False
89
+ if "/" in key or "\\" in key:
90
+ return True
91
+ return key.endswith(_SOURCE_SUFFIXES)
92
+
93
+
94
+ def _is_pollution_health_key(key: str, entry: Optional[Dict[str, Any]] = None) -> bool:
95
+ """Keys that must never remain in the agent-facing health matrix."""
96
+ if not isinstance(key, str) or not key:
97
+ return True
98
+ kl = key.lower()
99
+ if any(p in kl for p in SUPERSEDED_PATTERNS):
100
+ return True
101
+ ent = entry or {}
102
+ reason = str(ent.get("reason") or "")
103
+ status = str(ent.get("status") or "")
104
+ # Accidental flag keys / free-form notes already marked DELETED (not real paths).
105
+ if not _looks_like_path_key(key) and ("DELETED" in reason or "DELETED" in status):
106
+ return True
107
+ return False
108
+
109
+
110
+ def _prune_entries(root: Path, entries: Dict[str, Any]) -> Dict[str, Any]:
111
+ """Drop out-of-tree pollution + superseded free-form note keys."""
112
+ pruned: Dict[str, Any] = {}
113
+ for k, v in (entries or {}).items():
114
+ if not _entry_is_under_root(root, k):
115
+ continue
116
+ if _is_pollution_health_key(k, v if isinstance(v, dict) else None):
117
+ continue
118
+ pruned[k] = v
119
+ return pruned
120
+
74
121
 
75
122
  def _get_health_path(root: "str | Path") -> Path:
76
123
  root = _coerce_root(root)
@@ -273,23 +320,9 @@ def load_health(root: "str | Path") -> Dict[str, Any]:
273
320
  data["entries"] = normalized_entries
274
321
  if version < 2:
275
322
  data["version"] = 2 # mark for future save; additive, no breakage
276
- # M5 dogfood fix: immediately drop any out-of-tree pollution entries persisted from
277
- # prior bad cross-project or cwd-mismatched runs (e.g. worktree paths in external targets).
278
- # Views (get_*) will be clean; next save will persist pruned json.
279
- entries = data.get("entries", {}) or {}
280
- pruned = {k: v for k, v in entries.items() if _entry_is_under_root(root, k)}
281
- # Post-v4.0 superseded historical prune (see SUPERSEDED_PATTERNS at top).
282
- # Leave explicit Red DELETED audit records (they are the agent-visible archival marker).
283
- for k in list(pruned.keys()):
284
- kl = str(k).lower()
285
- if any(p in kl for p in SUPERSEDED_PATTERNS):
286
- ent = pruned.get(k, {})
287
- status = str(ent.get("status", ""))
288
- reason = str(ent.get("reason", "")).lower()
289
- if status.startswith("🔴") and ("deleted" in reason or "superseded" in reason):
290
- continue # keep the audit Red
291
- del pruned[k]
292
- data["entries"] = pruned
323
+ # M5 dogfood fix: drop out-of-tree + free-form superseded/DELETED pollution.
324
+ # Views (get_*) are clean; next save persists pruned json.
325
+ data["entries"] = _prune_entries(root, data.get("entries", {}) or {})
293
326
  return data
294
327
 
295
328
  # Migration path: if JSON doesn't exist but MD does
@@ -306,20 +339,7 @@ def load_health(root: "str | Path") -> Dict[str, Any]:
306
339
  norm[k] = _normalize_health_entry_local(v)
307
340
  migrated["entries"] = norm
308
341
  migrated["version"] = 2
309
- # M5: prune pollution on migrate too (same as json path)
310
- entries = migrated.get("entries", {}) or {}
311
- pruned = {k: v for k, v in entries.items() if _entry_is_under_root(root, k)}
312
- # Post-v4.0 superseded historical prune (see SUPERSEDED_PATTERNS at top).
313
- for k in list(pruned.keys()):
314
- kl = str(k).lower()
315
- if any(p in kl for p in SUPERSEDED_PATTERNS):
316
- ent = pruned.get(k, {})
317
- status = str(ent.get("status", ""))
318
- reason = str(ent.get("reason", "")).lower()
319
- if status.startswith("🔴") and ("deleted" in reason or "superseded" in reason):
320
- continue
321
- del pruned[k]
322
- migrated["entries"] = pruned
342
+ migrated["entries"] = _prune_entries(root, migrated.get("entries", {}) or {})
323
343
  return migrated
324
344
 
325
345
  # Fresh project (start at v2 with Health B fields ready)
@@ -350,21 +370,9 @@ def _do_save_health(root: "str | Path", health_data: Dict[str, Any]) -> None:
350
370
  health_data["last_updated"] = _timestamp()
351
371
  health_data["version"] = 2 # B durable: ensure v2 on every save (additive fields)
352
372
 
353
- # M5: always prune out-of-tree on save so target's file_health.json stays clean forever
373
+ # M5: always prune out-of-tree + free-form pollution so file_health.json stays clean
354
374
  if "entries" in health_data:
355
- entries = health_data.get("entries") or {}
356
- pruned = {k: v for k, v in entries.items() if _entry_is_under_root(root, k)}
357
- # Post-v4.0 superseded historical prune (see SUPERSEDED_PATTERNS at top).
358
- for k in list(pruned.keys()):
359
- kl = str(k).lower()
360
- if any(p in kl for p in SUPERSEDED_PATTERNS):
361
- ent = pruned.get(k, {})
362
- status = str(ent.get("status", ""))
363
- reason = str(ent.get("reason", "")).lower()
364
- if status.startswith("🔴") and ("deleted" in reason or "superseded" in reason):
365
- continue
366
- del pruned[k]
367
- health_data["entries"] = pruned
375
+ health_data["entries"] = _prune_entries(root, health_data.get("entries") or {})
368
376
 
369
377
  with open(json_path, "w", encoding="utf-8") as f:
370
378
  json.dump(health_data, f, indent=2, ensure_ascii=False)
@@ -450,11 +458,9 @@ def _do_upsert_entry(root: "str | Path", file: str, status: str, reason: str = "
450
458
  file = _normalize_to_relative(root, file)
451
459
  if not _entry_is_under_root(root, file):
452
460
  return
453
- # Post-v4.0: skip superseded historical names (unless this is explicitly recording a Red DELETED audit).
454
- kl = file.lower()
455
- if any(p in kl for p in SUPERSEDED_PATTERNS):
456
- if not (status.startswith("🔴") and ("deleted" in reason.lower() or "superseded" in reason.lower())):
457
- return
461
+ # Never re-introduce free-form superseded notes or flag-key pollution.
462
+ if _is_pollution_health_key(file, {"status": status, "reason": reason}):
463
+ return
458
464
  health = load_health(root)
459
465
  existing = health.get("entries", {}).get(file, {})
460
466
  # Start from normalized existing to keep B fields
@@ -479,40 +485,74 @@ def _do_upsert_entry(root: "str | Path", file: str, status: str, reason: str = "
479
485
  # These ensure pending_updates.md mutations are atomic with health under the project lock
480
486
  # (per locking.py contract). Eliminates races/duplicates with add_pending from shell/monitor.
481
487
 
488
+ _EMPTY_PENDING_MARKER = "(no pending items — run check-changes after making edits)"
489
+ _EMPTY_PENDING_LINES = [
490
+ "# Pending Updates",
491
+ "",
492
+ _EMPTY_PENDING_MARKER,
493
+ ]
494
+
495
+
482
496
  def _get_pending_path(root: Path) -> Path:
483
497
  return root / PENDING_MD
484
498
 
485
499
 
500
+ def _is_pending_item_line(ln: str) -> bool:
501
+ """True for real work-queue bullets (`- path: msg`), not headers/empty markers."""
502
+ s = (ln or "").strip()
503
+ return s.startswith("- ") and not s.startswith("- (")
504
+
505
+
506
+ def _is_empty_pending_marker(ln: str) -> bool:
507
+ s = (ln or "").strip().lower()
508
+ if not s or s.startswith("#"):
509
+ return False
510
+ return "no pending" in s or "no active items" in s or s == _EMPTY_PENDING_MARKER.lower()
511
+
512
+
513
+ def _pending_item_lines(lines: List[str]) -> List[str]:
514
+ return [ln for ln in lines if _is_pending_item_line(ln)]
515
+
516
+
517
+ def _normalize_pending_lines(lines: List[str]) -> List[str]:
518
+ """Canonical pending_updates.md: header + empty marker XOR bullet list (never both)."""
519
+ items = _pending_item_lines(lines or [])
520
+ if not items:
521
+ return list(_EMPTY_PENDING_LINES)
522
+ seen = set()
523
+ uniq: List[str] = []
524
+ for it in items:
525
+ if it not in seen:
526
+ seen.add(it)
527
+ uniq.append(it)
528
+ return ["# Pending Updates", ""] + uniq
529
+
530
+
486
531
  def _read_pending_lines(root: Path) -> List[str]:
487
532
  """Read pending file as list of lines; return sensible default header if missing."""
488
533
  p = _get_pending_path(root)
489
534
  if not p.exists():
490
- return [
491
- "# Pending Updates",
492
- "",
493
- "(no pending items — run check-changes after making edits)"
494
- ]
535
+ return list(_EMPTY_PENDING_LINES)
495
536
  try:
496
537
  with open(p, "r", encoding="utf-8") as f:
497
538
  return f.read().splitlines(keepends=False)
498
539
  except Exception:
499
- # On read error, conservative: return current content best effort or default
500
- return ["# Pending Updates", "", "(no pending items — run check-changes after making edits)"]
540
+ return list(_EMPTY_PENDING_LINES)
501
541
 
502
542
 
503
543
  def _write_pending_lines(root: Path, lines: List[str]) -> None:
504
- """Atomic-ish write of pending (tmp + mv for safety on most FS)."""
544
+ """Atomic-ish write of pending (tmp + mv). Always normalized (no dual empty+items)."""
505
545
  p = _get_pending_path(root)
506
546
  p.parent.mkdir(parents=True, exist_ok=True)
547
+ normalized = _normalize_pending_lines(lines)
507
548
  tmp = p.with_suffix(".tmp")
508
549
  try:
509
550
  with open(tmp, "w", encoding="utf-8") as f:
510
- f.write("\n".join(lines) + "\n")
511
- os.replace(tmp, p) # atomic rename on POSIX
551
+ f.write("\n".join(normalized) + "\n")
552
+ os.replace(tmp, p)
512
553
  except Exception:
513
- # Fallback direct write
514
554
  with open(p, "w", encoding="utf-8") as f:
515
- f.write("\n".join(lines) + "\n")
555
+ f.write("\n".join(normalized) + "\n")
516
556
  try:
517
557
  if tmp.exists():
518
558
  tmp.unlink()
@@ -520,13 +560,19 @@ def _write_pending_lines(root: Path, lines: List[str]) -> None:
520
560
  pass
521
561
 
522
562
 
563
+ def count_pending(root: Path) -> int:
564
+ """Count real pending work items (bullet lines only; empty markers ignored)."""
565
+ return len(_pending_item_lines(_read_pending_lines(root)))
566
+
567
+
523
568
  def _do_remove_from_pending(root: Path, file: str) -> int:
524
- """Internal: idempotent remove of any lines containing file (fixed-string, like grep -vF)."""
569
+ """Internal: idempotent remove of any item lines containing file (fixed-string)."""
525
570
  lines = _read_pending_lines(root)
526
- new_lines = [ln for ln in lines if file not in ln]
527
- removed = len(lines) - len(new_lines)
528
- if removed > 0:
529
- _write_pending_lines(root, new_lines)
571
+ items_before = _pending_item_lines(lines)
572
+ new_items = [ln for ln in items_before if file not in ln]
573
+ removed = len(items_before) - len(new_items)
574
+ # Always rewrite so dual "(no active)" + items and stale markers get fixed.
575
+ _write_pending_lines(root, new_items)
530
576
  return removed
531
577
 
532
578
 
@@ -540,12 +586,13 @@ def remove_from_pending(root: Path, file: str) -> int:
540
586
 
541
587
 
542
588
  def _do_add_to_pending(root: Path, file: str, msg: str) -> None:
543
- """Internal add (idempotent: no exact dup entry)."""
589
+ """Internal add (idempotent). Strips empty markers when real items exist."""
544
590
  lines = _read_pending_lines(root)
545
591
  entry = f"- {file}: {msg}"
546
- if entry not in lines:
547
- lines.append(entry)
548
- _write_pending_lines(root, lines)
592
+ items = _pending_item_lines(lines)
593
+ if entry not in items:
594
+ items.append(entry)
595
+ _write_pending_lines(root, items)
549
596
 
550
597
 
551
598
  def add_to_pending(root: Path, file: str, msg: str) -> None:
@@ -875,12 +922,16 @@ def get_summary(root: Path, directory: Optional[str] = None, include_stale: bool
875
922
  elif "🔴" in status:
876
923
  red += 1
877
924
 
925
+ try:
926
+ pending_n = count_pending(root)
927
+ except Exception:
928
+ pending_n = 0
878
929
  out = {
879
930
  "total": total,
880
931
  "green": green,
881
932
  "yellow": yellow,
882
933
  "red": red,
883
- "pending_updates": 0,
934
+ "pending_updates": pending_n,
884
935
  "directory": directory or ".",
885
936
  "version": health.get("version", 2),
886
937
  "last_updated": health.get("last_updated")
@@ -1644,7 +1644,13 @@ def get_project_status(
1644
1644
  summary = health_module.get_summary(root, directory)
1645
1645
  pending = _read_file_safe("pending_updates.md", root=root)
1646
1646
 
1647
- pending_count = len([l for l in pending.splitlines() if l.strip() and not l.startswith("#")])
1647
+ if hasattr(health_module, "count_pending"):
1648
+ pending_count = int(health_module.count_pending(root))
1649
+ else:
1650
+ pending_count = len([
1651
+ l for l in pending.splitlines()
1652
+ if l.strip().startswith("- ") and not l.strip().startswith("- (")
1653
+ ])
1648
1654
 
1649
1655
  # ACS + CIABRE + Wave 2 Barrel/BRC surfacing uniformity: lightweight stats + invalidation reports foundation in project status (MCP primary for agents)
1650
1656
  dep_intel = {}
@@ -1770,7 +1776,13 @@ Use get_files_needing_attention() for the actual list. Use get_cycles(analysis=T
1770
1776
  # yielded zeros and lied to agents.
1771
1777
  root = _get_effective_root(project_root)
1772
1778
  pending = _read_file_safe("pending_updates.md", root=root)
1773
- pending_count = len([l for l in pending.splitlines() if l.strip() and not l.startswith("#")])
1779
+ if hasattr(health_module, "count_pending"):
1780
+ pending_count = int(health_module.count_pending(root))
1781
+ else:
1782
+ pending_count = len([
1783
+ l for l in pending.splitlines()
1784
+ if l.strip().startswith("- ") and not l.strip().startswith("- (")
1785
+ ])
1774
1786
  green = yellow = red = total = 0
1775
1787
  try:
1776
1788
  import importlib
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wikifier
3
- Version: 4.5.4
3
+ Version: 4.5.5
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
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes