wikifier 4.6.4__tar.gz → 4.6.7__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 (63) hide show
  1. {wikifier-4.6.4/wikifier.egg-info → wikifier-4.6.7}/PKG-INFO +5 -3
  2. {wikifier-4.6.4 → wikifier-4.6.7}/README.md +4 -2
  3. {wikifier-4.6.4 → wikifier-4.6.7}/skills/run.md +4 -1
  4. {wikifier-4.6.4 → wikifier-4.6.7}/tests/test_import_cache.py +7 -1
  5. wikifier-4.6.7/tests/test_index_map_paths.py +459 -0
  6. wikifier-4.6.7/tests/test_walk_coverage_resolvers.py +203 -0
  7. {wikifier-4.6.4 → wikifier-4.6.7}/wikifier/__init__.py +2 -1
  8. {wikifier-4.6.4 → wikifier-4.6.7}/wikifier/agent_loop.py +40 -3
  9. {wikifier-4.6.4 → wikifier-4.6.7}/wikifier/cache_store.py +99 -17
  10. wikifier-4.6.7/wikifier/candidates.py +702 -0
  11. {wikifier-4.6.4 → wikifier-4.6.7}/wikifier/cli.py +145 -178
  12. {wikifier-4.6.4 → wikifier-4.6.7}/wikifier/parsers/c_cpp.py +33 -10
  13. wikifier-4.6.7/wikifier/parsers/csharp.py +168 -0
  14. {wikifier-4.6.4 → wikifier-4.6.7/wikifier.egg-info}/PKG-INFO +5 -3
  15. {wikifier-4.6.4 → wikifier-4.6.7}/wikifier.egg-info/SOURCES.txt +4 -0
  16. wikifier-4.6.4/wikifier/parsers/csharp.py +0 -64
  17. {wikifier-4.6.4 → wikifier-4.6.7}/CONTRIBUTING.md +0 -0
  18. {wikifier-4.6.4 → wikifier-4.6.7}/LICENSE +0 -0
  19. {wikifier-4.6.4 → wikifier-4.6.7}/MANIFEST.in +0 -0
  20. {wikifier-4.6.4 → wikifier-4.6.7}/diagnostics.html +0 -0
  21. {wikifier-4.6.4 → wikifier-4.6.7}/docs/spec.md +0 -0
  22. {wikifier-4.6.4 → wikifier-4.6.7}/index.html +0 -0
  23. {wikifier-4.6.4 → wikifier-4.6.7}/pyproject.toml +0 -0
  24. {wikifier-4.6.4 → wikifier-4.6.7}/setup.cfg +0 -0
  25. {wikifier-4.6.4 → wikifier-4.6.7}/tests/test_agent_loop.py +0 -0
  26. {wikifier-4.6.4 → wikifier-4.6.7}/tests/test_agent_scale.py +0 -0
  27. {wikifier-4.6.4 → wikifier-4.6.7}/tests/test_barrel_invalidation.py +0 -0
  28. {wikifier-4.6.4 → wikifier-4.6.7}/tests/test_cache_store.py +0 -0
  29. {wikifier-4.6.4 → wikifier-4.6.7}/tests/test_gap_closure.py +0 -0
  30. {wikifier-4.6.4 → wikifier-4.6.7}/tests/test_health.py +0 -0
  31. {wikifier-4.6.4 → wikifier-4.6.7}/tests/test_multi_lang_parsers.py +0 -0
  32. {wikifier-4.6.4 → wikifier-4.6.7}/tests/test_parsers.py +0 -0
  33. {wikifier-4.6.4 → wikifier-4.6.7}/tests/test_selftest_wrappers.py +0 -0
  34. {wikifier-4.6.4 → wikifier-4.6.7}/wikifier/__main__.py +0 -0
  35. {wikifier-4.6.4 → wikifier-4.6.7}/wikifier/contracts.py +0 -0
  36. {wikifier-4.6.4 → wikifier-4.6.7}/wikifier/daemon.py +0 -0
  37. {wikifier-4.6.4 → wikifier-4.6.7}/wikifier/diagnostics.py +0 -0
  38. {wikifier-4.6.4 → wikifier-4.6.7}/wikifier/health.py +0 -0
  39. {wikifier-4.6.4 → wikifier-4.6.7}/wikifier/import_cache.py +0 -0
  40. {wikifier-4.6.4 → wikifier-4.6.7}/wikifier/index.html +0 -0
  41. {wikifier-4.6.4 → wikifier-4.6.7}/wikifier/library.py +0 -0
  42. {wikifier-4.6.4 → wikifier-4.6.7}/wikifier/locking.py +0 -0
  43. {wikifier-4.6.4 → wikifier-4.6.7}/wikifier/mcp/__init__.py +0 -0
  44. {wikifier-4.6.4 → wikifier-4.6.7}/wikifier/mcp/server.py +0 -0
  45. {wikifier-4.6.4 → wikifier-4.6.7}/wikifier/parsers/__init__.py +0 -0
  46. {wikifier-4.6.4 → wikifier-4.6.7}/wikifier/parsers/_edge.py +0 -0
  47. {wikifier-4.6.4 → wikifier-4.6.7}/wikifier/parsers/bree.py +0 -0
  48. {wikifier-4.6.4 → wikifier-4.6.7}/wikifier/parsers/cdia.py +0 -0
  49. {wikifier-4.6.4 → wikifier-4.6.7}/wikifier/parsers/go_lang.py +0 -0
  50. {wikifier-4.6.4 → wikifier-4.6.7}/wikifier/parsers/java.py +0 -0
  51. {wikifier-4.6.4 → wikifier-4.6.7}/wikifier/parsers/javascript.py +0 -0
  52. {wikifier-4.6.4 → wikifier-4.6.7}/wikifier/parsers/python.py +0 -0
  53. {wikifier-4.6.4 → wikifier-4.6.7}/wikifier/parsers/rust.py +0 -0
  54. {wikifier-4.6.4 → wikifier-4.6.7}/wikifier/project_root.py +0 -0
  55. {wikifier-4.6.4 → wikifier-4.6.7}/wikifier/resolution.py +0 -0
  56. {wikifier-4.6.4 → wikifier-4.6.7}/wikifier/scripts/wikifier.bat +0 -0
  57. {wikifier-4.6.4 → wikifier-4.6.7}/wikifier/scripts/wikifier.ps1 +0 -0
  58. {wikifier-4.6.4 → wikifier-4.6.7}/wikifier/scripts/wikifier.sh +0 -0
  59. {wikifier-4.6.4 → wikifier-4.6.7}/wikifier/serve.py +0 -0
  60. {wikifier-4.6.4 → wikifier-4.6.7}/wikifier.egg-info/dependency_links.txt +0 -0
  61. {wikifier-4.6.4 → wikifier-4.6.7}/wikifier.egg-info/entry_points.txt +0 -0
  62. {wikifier-4.6.4 → wikifier-4.6.7}/wikifier.egg-info/requires.txt +0 -0
  63. {wikifier-4.6.4 → wikifier-4.6.7}/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.6.4
3
+ Version: 4.6.7
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
@@ -109,8 +109,10 @@ Advanced intel as needed: `get_dependencies`, `get_dependents`, `get_cycles`, ba
109
109
 
110
110
  - **Import analysis** — Python, JS/TS (ESM/CJS, barrels), Rust (`use`/`mod` + best-effort `crate::` paths), Go, C/C++ includes, C# usings; per-edge confidence; barrel expansion for TS/JS
111
111
  - **Incremental pipeline** — pure-Python `update-maps`: dirty parse → import cache → reverse deps → cycles → `library.md`
112
- - **Warm agent maps (4.6.3+ / 4.6.4+)** — zero-dirty fast path skips full graph/`library.md` rewrite; **stdlib SQLite** import cache (dual-read legacy JSON) so warm dirty scans use mtime/hash index not multi‑MB pair JSON; content-hash dirty; `--directory=` scopes walks
113
- - **Partial-map honesty** — `map_coverage` on `update_maps` / bootstrap (`complete`, `files_remaining_dirty`, `acs_version`, `cache_backend`); budgeted runs are not “done”
112
+ - **Warm agent maps (4.6.34.6.6)** — zero-dirty + **index-first** candidates (re-list only on fingerprint/index disagreement); **stdlib SQLite**; content-hash dirty
113
+ - **Two path lists** — `map_paths.txt` = map package roots; `monitored_paths.txt` = wiki/health watch (independent)
114
+ - **Partial-map honesty** — `map_coverage` on `update_maps` / bootstrap / **`suggest_next`**; `update_maps_until_complete` when incomplete
115
+ - **Cache ops** — `wikifier cache-status`; JSON dual-write **opt-in only** (`WIKIFIER_CACHE_JSON=1`); dual-read for migrate
114
116
  - **Selective agent work** — health + suggest bias to 🔴/actionable 🟡 only; **ACS v1.3** `reason_code` / `agent_signal`; prefer `actionable_low_conf_edges` + reason codes — **never** raw `low_conf_edges` alone
115
117
  - **Scale** — reverse index + barrel invalidation so one edit doesn’t re-scan the monorepo
116
118
  - **MCP tools** — optional server for Claude, Cursor, Cline, and other MCP clients
@@ -77,8 +77,10 @@ Advanced intel as needed: `get_dependencies`, `get_dependents`, `get_cycles`, ba
77
77
 
78
78
  - **Import analysis** — Python, JS/TS (ESM/CJS, barrels), Rust (`use`/`mod` + best-effort `crate::` paths), Go, C/C++ includes, C# usings; per-edge confidence; barrel expansion for TS/JS
79
79
  - **Incremental pipeline** — pure-Python `update-maps`: dirty parse → import cache → reverse deps → cycles → `library.md`
80
- - **Warm agent maps (4.6.3+ / 4.6.4+)** — zero-dirty fast path skips full graph/`library.md` rewrite; **stdlib SQLite** import cache (dual-read legacy JSON) so warm dirty scans use mtime/hash index not multi‑MB pair JSON; content-hash dirty; `--directory=` scopes walks
81
- - **Partial-map honesty** — `map_coverage` on `update_maps` / bootstrap (`complete`, `files_remaining_dirty`, `acs_version`, `cache_backend`); budgeted runs are not “done”
80
+ - **Warm agent maps (4.6.34.6.6)** — zero-dirty + **index-first** candidates (re-list only on fingerprint/index disagreement); **stdlib SQLite**; content-hash dirty
81
+ - **Two path lists** — `map_paths.txt` = map package roots; `monitored_paths.txt` = wiki/health watch (independent)
82
+ - **Partial-map honesty** — `map_coverage` on `update_maps` / bootstrap / **`suggest_next`**; `update_maps_until_complete` when incomplete
83
+ - **Cache ops** — `wikifier cache-status`; JSON dual-write **opt-in only** (`WIKIFIER_CACHE_JSON=1`); dual-read for migrate
82
84
  - **Selective agent work** — health + suggest bias to 🔴/actionable 🟡 only; **ACS v1.3** `reason_code` / `agent_signal`; prefer `actionable_low_conf_edges` + reason codes — **never** raw `low_conf_edges` alone
83
85
  - **Scale** — reverse index + barrel invalidation so one edit doesn’t re-scan the monorepo
84
86
  - **MCP tools** — optional server for Claude, Cursor, Cline, and other MCP clients
@@ -12,7 +12,10 @@
12
12
  - **First-run:** `init` → `update-maps` → `health --summary` → `suggest_next_actions`. Map is automatic; wiki *prose* is agent-filled over time.
13
13
  - **Map-first ≠ wiki-done:** `health_score` **Map Ready** + many 🟡 *Initial stubs* means structural coverage only. Do **not** bulk-wiki stubs. Wiki a file when you edit it, then `mark-green`. Prefer `actionable_yellow` / 🔴 over raw yellow counts.
14
14
  - **Steady-state selective work:** only 🔴 and *actionable* 🟡 (never re-wiki 🟢; never re-wiki the whole stub set). **ACS v1.3:** use `actionable_low_conf_edges` + `reason_code_counts` / `agent_signal` (`skip`|`investigate`). Do **not** thrash on raw `low_conf_edges` (includes external/bare scores ~0.48). Prefer `map_coverage.complete` / `files_remaining_dirty` after budgeted `update-maps` — `success: true` ≠ map complete.
15
- - **Warm maps:** import cache prefers stdlib SQLite (`.wikifier_staging/import_cache.sqlite`); zero-dirty path uses mtime index + meta, not full pair payload.
15
+ - **Warm maps:** SQLite primary; zero-dirty uses mtime index + meta. **Index-first:** re-list candidates only when fingerprint / map-scoped index / live count disagree (`index_first_dirty` / `candidates_reused` on `update_maps`). Prefer `--directory=` or `map_paths.txt` package roots (not bare `.`). Collect, live count, index filter, and prune share **MapScope** so full-tree→`map_paths` narrow does not thrash.
16
+ - **map_paths vs monitored_paths:** `map_paths.txt` = import-map package roots; `monitored_paths.txt` = wiki/health thrash bound. Independent. Wiki-only monitored lists do **not** define the map.
17
+ - **Cache ops:** `wikifier cache-status`. Dual-write JSON **deprecated default-off** (`WIKIFIER_CACHE_JSON=1` to opt in). Dual-read legacy JSON for migrate remains.
18
+ - **ACS:** prefer `actionable_low_conf_edges` + `reason_code_counts` / `agent_signal`; never thrash on raw `low_conf_edges` alone.
16
19
  - **Dual scope:** `monitored_paths` = check-changes/health scope (keep lean). `update-maps --directory=` / `--max-files=` = map walk. Never set `project_root` to a multi-repo parent (e.g. `cloned_sample_projects`).
17
20
  - **CLI target:** `wikifier --target /abs/project health --summary` (flags stripped before the command). Or `WIKIFIER_PROJECT_ROOT=…`.
18
21
  - **Long-horizon:** `wikifier autonomous-status` (or `readiness`) before unattended runs; daemon writes `.wikifier_staging/daemon_heartbeat.json`. Soak ≥72h is still evidence work, not automatic.
@@ -32,7 +32,13 @@ class TestCacheRoundTrip(TempProjectTestCase):
32
32
  cache["_barrel_resolutions"] = {"deadbeef": {"importers": ["a.py"]}}
33
33
 
34
34
  ic.save_cache(self.root, cache)
35
- self.assertTrue((self.root / ".wikifier_staging" / "import_cache.json").exists())
35
+ # SQLite is primary (4.6.6+); JSON dual-write is opt-in only
36
+ staging = self.root / ".wikifier_staging"
37
+ self.assertTrue(
38
+ (staging / "import_cache.sqlite").exists()
39
+ or (staging / "import_cache.json").exists(),
40
+ "expected sqlite or legacy json cache",
41
+ )
36
42
 
37
43
  loaded = ic.load_cache(self.root)
38
44
  self.assertEqual(loaded, cache)
@@ -0,0 +1,459 @@
1
+ """Index-first dirty, map_paths vs monitored_paths, JSON dual-write deprecation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import unittest
7
+ from pathlib import Path
8
+
9
+ from tests._base import TempProjectTestCase
10
+
11
+ from wikifier import cli
12
+ from wikifier import cache_store as cs
13
+ from wikifier.candidates import (
14
+ collect_candidate_source_files,
15
+ resolve_candidates,
16
+ try_cached_candidate_rels,
17
+ scope_fingerprint,
18
+ )
19
+ from wikifier import import_cache as ic
20
+
21
+
22
+ class TestMapPathsSeparate(TempProjectTestCase):
23
+ def test_map_paths_not_wiki_monitored(self):
24
+ """Wiki monitored_paths + map_paths package dirs: map covers package sources."""
25
+ self.write("skills/run.md", "# wiki\n")
26
+ self.write("monitored_paths.txt", "skills/run.md\nREADME.md\n")
27
+ self.write("README.md", "# r\n")
28
+ self.write("map_paths.txt", "pkg/\n")
29
+ self.write("pkg/a.py", "x=1\n")
30
+ self.write("pkg/b.py", "y=2\n")
31
+ self.write("other/c.py", "z=3\n")
32
+ cands = collect_candidate_source_files(self.root, directory=None)
33
+ rels = []
34
+ for p in cands:
35
+ try:
36
+ rels.append(str(Path(p).resolve().relative_to(self.root.resolve())))
37
+ except Exception:
38
+ rels.append(str(p))
39
+ self.assertTrue(any(r.endswith("a.py") for r in rels), rels)
40
+ self.assertTrue(any(r.endswith("b.py") for r in rels), rels)
41
+ self.assertFalse(any("other" in r for r in rels), rels)
42
+ r = cli.update_maps(project_root=self.root, full=True)
43
+ self.assertTrue(r.get("success"), r)
44
+ self.assertGreaterEqual(int(r.get("parseable_files") or 0), 2, r)
45
+
46
+ def test_directory_overrides_map_paths(self):
47
+ self.write("map_paths.txt", "pkg/\n")
48
+ self.write("pkg/a.py", "1\n")
49
+ self.write("other/b.py", "2\n")
50
+ cands = collect_candidate_source_files(self.root, directory="other")
51
+ keys = [os.path.realpath(str(p)) for p in cands]
52
+ self.assertTrue(any(k.endswith("b.py") for k in keys), keys)
53
+ self.assertFalse(any("/pkg/" in k for k in keys), keys)
54
+
55
+ def test_map_paths_subset_reuses_despite_outside_sources(self):
56
+ """map_paths=pkg/ with extra sources outside: warm2 must reuse (live count scoped)."""
57
+ self.write("map_paths.txt", "pkg/\n")
58
+ self.write("monitored_paths.txt", "docs/note.md\n")
59
+ self.write("docs/note.md", "# wiki only\n")
60
+ self.write("pkg/a.py", "x=1\n")
61
+ self.write("pkg/b.py", "y=2\n")
62
+ self.write("outside/c.py", "z=3\n") # not in map_paths
63
+ r1 = cli.update_maps(project_root=self.root, full=True)
64
+ self.assertTrue(r1.get("success"), r1)
65
+ n1 = int(r1.get("parseable_files") or 0)
66
+ self.assertEqual(n1, 2, r1) # only pkg/, not outside/
67
+ # Second and third warm: live count must match map scope (2), not full tree (3)
68
+ r2 = cli.update_maps(project_root=self.root, full=False)
69
+ self.assertTrue(r2.get("success"), r2)
70
+ r3 = cli.update_maps(project_root=self.root, full=False)
71
+ self.assertTrue(r3.get("success"), r3)
72
+ self.assertEqual(int(r3.get("parseable_files") or 0), 2, r3)
73
+ self.assertTrue(
74
+ r3.get("candidates_reused") is True,
75
+ f"map_paths subset must reuse on warm; r2={r2.get('candidates_reused')} r3={r3}",
76
+ )
77
+ self.assertFalse(r3.get("candidates_relisted"), r3)
78
+
79
+
80
+ class TestIndexFirstResolve(TempProjectTestCase):
81
+ def test_resolve_candidates_index_first_flag(self):
82
+ self.write("a.py", "print(1)\n")
83
+ self.write("b.py", "print(2)\n")
84
+ r1 = cli.update_maps(project_root=self.root, full=True)
85
+ self.assertTrue(r1.get("success"), r1)
86
+ r2 = cli.update_maps(project_root=self.root, full=False)
87
+ self.assertTrue(r2.get("success"), r2)
88
+ r3 = cli.update_maps(project_root=self.root, full=False)
89
+ self.assertTrue(r3.get("success"), r3)
90
+ self.assertTrue(r3.get("candidates_reused") or r3.get("index_first_dirty"), r3)
91
+ self.assertFalse(r3.get("candidates_relisted"), r3)
92
+ self.assertEqual(int(r3.get("parseable_files") or 0), 2)
93
+
94
+ def test_poison_blob_rejected_via_index(self):
95
+ self.write("a.py", "1\n")
96
+ self.write("b.py", "2\n")
97
+ r1 = cli.update_maps(project_root=self.root, full=True)
98
+ self.assertGreaterEqual(int(r1.get("parseable_files") or 0), 2)
99
+ index = ic.load_mtime_index(self.root)
100
+ poison = {
101
+ "fp": scope_fingerprint(self.root, None),
102
+ "directory": None,
103
+ "count": 1,
104
+ "rels": ["a.py"],
105
+ }
106
+ cs.save_meta_key(self.root, "_candidate_list", poison)
107
+ meta = cs.load_meta(self.root, keys=("_candidate_list",))
108
+ # Full index has a+b → larger than blob → reject
109
+ self.assertIsNone(try_cached_candidate_rels(meta, self.root, None, index=index))
110
+ r2 = cli.update_maps(project_root=self.root, full=False)
111
+ self.assertGreaterEqual(int(r2.get("parseable_files") or 0), 2, r2)
112
+
113
+ def test_empty_index_uses_live_count_not_false_reuse(self):
114
+ """Empty index must not trust blob alone (live-count path)."""
115
+ self.write("a.py", "1\n")
116
+ self.write("b.py", "2\n")
117
+ r1 = cli.update_maps(project_root=self.root, full=True)
118
+ self.assertGreaterEqual(int(r1.get("parseable_files") or 0), 2)
119
+ poison = {
120
+ "fp": scope_fingerprint(self.root, None),
121
+ "directory": None,
122
+ "count": 1,
123
+ "rels": ["a.py"],
124
+ }
125
+ cs.save_meta_key(self.root, "_candidate_list", poison)
126
+ meta = cs.load_meta(self.root, keys=("_candidate_list",))
127
+ # empty dict and None both force live count → poison count 1 ≠ live 2
128
+ self.assertIsNone(try_cached_candidate_rels(meta, self.root, None, index={}))
129
+ self.assertIsNone(try_cached_candidate_rels(meta, self.root, None, index=None))
130
+
131
+ def test_partial_index_same_count_different_keys_relists(self):
132
+ """Index {a} vs blob {b} same count → must not reuse."""
133
+ self.write("a.py", "1\n")
134
+ self.write("b.py", "2\n")
135
+ cli.update_maps(project_root=self.root, full=True)
136
+ fp = scope_fingerprint(self.root, None)
137
+ blob = {"fp": fp, "directory": None, "count": 1, "rels": ["b.py"]}
138
+ partial_index = {"a.py": {"mtime": 1, "content_hash": None}}
139
+ meta = {"_candidate_list": blob}
140
+ self.assertIsNone(
141
+ try_cached_candidate_rels(meta, self.root, None, index=partial_index)
142
+ )
143
+
144
+ def test_partial_index_subset_requires_live_count(self):
145
+ """Index {a} subset of blob {a,b}: live count decides (not silent reuse on index alone)."""
146
+ self.write("a.py", "1\n")
147
+ self.write("b.py", "2\n")
148
+ cli.update_maps(project_root=self.root, full=True)
149
+ fp = scope_fingerprint(self.root, None)
150
+ # Correct blob for 2 files but partial index
151
+ blob = {
152
+ "fp": fp,
153
+ "directory": None,
154
+ "count": 2,
155
+ "rels": ["a.py", "b.py"],
156
+ }
157
+ partial = {"a.py": {"mtime": 1}}
158
+ meta = {"_candidate_list": blob}
159
+ # Live count == 2 → may reuse after live count; must not reject solely on partial
160
+ got = try_cached_candidate_rels(meta, self.root, None, index=partial)
161
+ self.assertIsNotNone(got)
162
+ self.assertEqual(len(got), 2)
163
+ # Poison blob count 1 with partial index {a}
164
+ poison = {"fp": fp, "directory": None, "count": 1, "rels": ["a.py"]}
165
+ self.assertIsNone(
166
+ try_cached_candidate_rels(
167
+ {"_candidate_list": poison}, self.root, None, index=partial
168
+ )
169
+ )
170
+
171
+
172
+ class TestJsonDualWriteDeprecated(TempProjectTestCase):
173
+ def test_default_save_sqlite_only(self):
174
+ self.write("x.py", "x=1\n")
175
+ os.environ.pop("WIKIFIER_CACHE_JSON", None)
176
+ r = cli.update_maps(project_root=self.root, full=True)
177
+ self.assertTrue(r.get("success"), r)
178
+ self.assertTrue(cs.has_sqlite(self.root))
179
+ st = cli.cache_status(project_root=self.root)
180
+ self.assertIn("DEPRECATED", (st.get("dual_write_policy") or "").upper())
181
+ cache = {"a.py": {"mtime": 1, "imports": [], "resolved_pairs": []}}
182
+ for i in range(5):
183
+ cache[f"f{i}.py"] = {"mtime": 1, "imports": [], "resolved_pairs": []}
184
+ backend = cs.save_cache_dict(self.root, cache)
185
+ self.assertEqual(backend, "sqlite", backend)
186
+
187
+ def test_opt_in_dual_write(self):
188
+ self.write("y.py", "y=1\n")
189
+ os.environ["WIKIFIER_CACHE_JSON"] = "1"
190
+ try:
191
+ cache = {"y.py": {"mtime": 1, "imports": [], "resolved_pairs": []}}
192
+ backend = cs.save_cache_dict(self.root, cache)
193
+ self.assertEqual(backend, "sqlite+json", backend)
194
+ self.assertTrue(cs.json_path(self.root).is_file())
195
+ finally:
196
+ os.environ.pop("WIKIFIER_CACHE_JSON", None)
197
+
198
+
199
+ class TestResolveCandidatesHelper(TempProjectTestCase):
200
+ def test_resolve_candidates_peel(self):
201
+ self.write("m.py", "1\n")
202
+ cli.update_maps(project_root=self.root, full=True)
203
+ index = ic.load_mtime_index(self.root)
204
+ meta = cs.load_meta(self.root, keys=("_candidate_list",))
205
+ r = resolve_candidates(
206
+ self.root, directory=None, force_full=False, index=index, meta=meta
207
+ )
208
+ self.assertTrue(r.get("reused") or r.get("relisted"))
209
+ self.assertGreaterEqual(len(r.get("paths") or []), 1)
210
+
211
+
212
+ class TestEnvDirNotExcluded(TempProjectTestCase):
213
+ def test_env_package_dir_collected(self):
214
+ """Bare 'env' must not be a DEFAULT_EXCLUDE (rust sys/env/*.rs)."""
215
+ self.write("sys/env/mod.rs", "pub fn x() {}\n")
216
+ self.write("sys/env/other.rs", "pub fn y() {}\n")
217
+ cands = collect_candidate_source_files(self.root, directory="sys")
218
+ names = [p.name for p in cands]
219
+ self.assertIn("mod.rs", names, names)
220
+ self.assertIn("other.rs", names, names)
221
+
222
+
223
+ class TestCandidateReuseScopeMatrix(TempProjectTestCase):
224
+ """Pure evaluate_candidate_reuse + MapScope filter matrix (no shell)."""
225
+
226
+ def test_evaluate_poison_n1_empty_index(self):
227
+ from wikifier.candidates import evaluate_candidate_reuse
228
+
229
+ blob = {
230
+ "fp": "fp1",
231
+ "directory": None,
232
+ "count": 1,
233
+ "rels": ["a.py"],
234
+ }
235
+ # empty index + live 2 → reject
236
+ self.assertIsNone(
237
+ evaluate_candidate_reuse(
238
+ blob,
239
+ fingerprint="fp1",
240
+ directory=None,
241
+ scoped_index_keys=set(),
242
+ live_count=2,
243
+ )
244
+ )
245
+ # None index + live 2 → reject
246
+ self.assertIsNone(
247
+ evaluate_candidate_reuse(
248
+ blob,
249
+ fingerprint="fp1",
250
+ directory=None,
251
+ scoped_index_keys=None,
252
+ live_count=2,
253
+ )
254
+ )
255
+ # live agrees with poison count 1 but would still "reuse" wrong set —
256
+ # caller's live_count must be real; here live=1 allows return
257
+ got = evaluate_candidate_reuse(
258
+ blob,
259
+ fingerprint="fp1",
260
+ directory=None,
261
+ scoped_index_keys=None,
262
+ live_count=1,
263
+ )
264
+ self.assertEqual(got, ["a.py"])
265
+
266
+ def test_evaluate_same_count_different_keys(self):
267
+ from wikifier.candidates import evaluate_candidate_reuse
268
+
269
+ blob = {
270
+ "fp": "fp1",
271
+ "directory": None,
272
+ "count": 1,
273
+ "rels": ["b.py"],
274
+ }
275
+ self.assertIsNone(
276
+ evaluate_candidate_reuse(
277
+ blob,
278
+ fingerprint="fp1",
279
+ directory=None,
280
+ scoped_index_keys={"a.py"},
281
+ live_count=1,
282
+ )
283
+ )
284
+
285
+ def test_evaluate_true_set_agreement(self):
286
+ from wikifier.candidates import evaluate_candidate_reuse
287
+
288
+ blob = {
289
+ "fp": "fp1",
290
+ "directory": None,
291
+ "count": 2,
292
+ "rels": ["pkg/a.py", "pkg/b.py"],
293
+ }
294
+ got = evaluate_candidate_reuse(
295
+ blob,
296
+ fingerprint="fp1",
297
+ directory=None,
298
+ scoped_index_keys={"pkg/a.py", "pkg/b.py"},
299
+ live_count=2,
300
+ )
301
+ self.assertEqual(set(got or []), {"pkg/a.py", "pkg/b.py"})
302
+
303
+ def test_evaluate_partial_subset_live_ok(self):
304
+ from wikifier.candidates import evaluate_candidate_reuse
305
+
306
+ blob = {
307
+ "fp": "fp1",
308
+ "directory": None,
309
+ "count": 2,
310
+ "rels": ["a.py", "b.py"],
311
+ }
312
+ got = evaluate_candidate_reuse(
313
+ blob,
314
+ fingerprint="fp1",
315
+ directory=None,
316
+ scoped_index_keys={"a.py"},
317
+ live_count=2,
318
+ )
319
+ self.assertIsNotNone(got)
320
+ self.assertEqual(len(got), 2)
321
+
322
+ def test_evaluate_fp_mismatch(self):
323
+ from wikifier.candidates import evaluate_candidate_reuse
324
+
325
+ blob = {"fp": "old", "directory": None, "count": 1, "rels": ["a.py"]}
326
+ self.assertIsNone(
327
+ evaluate_candidate_reuse(
328
+ blob,
329
+ fingerprint="new",
330
+ directory=None,
331
+ scoped_index_keys=None,
332
+ live_count=1,
333
+ )
334
+ )
335
+
336
+ def test_filter_index_to_map_scope_narrows(self):
337
+ from wikifier.candidates import (
338
+ filter_index_to_map_scope,
339
+ resolve_map_scope,
340
+ )
341
+
342
+ self.write("map_paths.txt", "pkg/\n")
343
+ self.write("pkg/a.py", "1\n")
344
+ self.write("outside/c.py", "2\n")
345
+ scope = resolve_map_scope(self.root, None)
346
+ self.assertFalse(scope.is_full_tree, scope)
347
+ self.assertIn("pkg", scope.rel_prefixes)
348
+ index = {
349
+ "pkg/a.py": {"mtime": 1},
350
+ "outside/c.py": {"mtime": 2},
351
+ "pkg/b.py": {"mtime": 3},
352
+ }
353
+ scoped = filter_index_to_map_scope(index, scope)
354
+ self.assertIn("pkg/a.py", scoped)
355
+ self.assertIn("pkg/b.py", scoped)
356
+ self.assertNotIn("outside/c.py", scoped)
357
+
358
+ def test_full_index_with_map_paths_does_not_poison_reuse(self):
359
+ """Leftover full-tree index keys must not force permanent re-list."""
360
+ from wikifier.candidates import (
361
+ evaluate_candidate_reuse,
362
+ filter_index_to_map_scope,
363
+ resolve_map_scope,
364
+ try_cached_candidate_rels,
365
+ )
366
+
367
+ self.write("map_paths.txt", "pkg/\n")
368
+ self.write("pkg/a.py", "1\n")
369
+ self.write("pkg/b.py", "2\n")
370
+ self.write("outside/c.py", "3\n")
371
+ # Simulate post-migration index still containing outside keys
372
+ full_index = {
373
+ "pkg/a.py": {"mtime": 1},
374
+ "pkg/b.py": {"mtime": 1},
375
+ "outside/c.py": {"mtime": 1},
376
+ }
377
+ scope = resolve_map_scope(self.root, None)
378
+ scoped = filter_index_to_map_scope(full_index, scope)
379
+ self.assertEqual(set(scoped.keys()), {"pkg/a.py", "pkg/b.py"})
380
+ fp = scope_fingerprint(self.root, None)
381
+ blob = {
382
+ "fp": fp,
383
+ "directory": None,
384
+ "count": 2,
385
+ "rels": ["pkg/a.py", "pkg/b.py"],
386
+ }
387
+ # Pure: map-scoped keys + live 2 → reuse
388
+ got = evaluate_candidate_reuse(
389
+ blob,
390
+ fingerprint=fp,
391
+ directory=None,
392
+ scoped_index_keys=set(scoped.keys()),
393
+ live_count=2,
394
+ )
395
+ self.assertIsNotNone(got)
396
+ # I/O path: try_cached must filter before set check
397
+ meta = {"_candidate_list": blob}
398
+ paths = try_cached_candidate_rels(
399
+ meta, self.root, None, index=full_index
400
+ )
401
+ self.assertIsNotNone(paths, "full leftover index must not poison map_paths reuse")
402
+ self.assertEqual(len(paths), 2)
403
+
404
+
405
+ class TestMapPathsMigration(TempProjectTestCase):
406
+ def test_map_paths_migration_reuses_after_full_tree_map(self):
407
+ """Phase0 full map n=3 → map_paths=pkg/ → warm2/3 candidates_reused."""
408
+ self.write("pkg/a.py", "x=1\n")
409
+ self.write("pkg/b.py", "y=2\n")
410
+ self.write("outside/c.py", "z=3\n")
411
+ # No map_paths yet: full tree
412
+ r0 = cli.update_maps(project_root=self.root, full=True)
413
+ self.assertTrue(r0.get("success"), r0)
414
+ n0 = int(r0.get("parseable_files") or 0)
415
+ self.assertEqual(n0, 3, r0)
416
+ # Narrow map surface
417
+ self.write("map_paths.txt", "pkg/\n")
418
+ # Warm after narrow: may re-list once, then reuse
419
+ r1 = cli.update_maps(project_root=self.root, full=False)
420
+ self.assertTrue(r1.get("success"), r1)
421
+ self.assertEqual(int(r1.get("parseable_files") or 0), 2, r1)
422
+ r2 = cli.update_maps(project_root=self.root, full=False)
423
+ self.assertTrue(r2.get("success"), r2)
424
+ r3 = cli.update_maps(project_root=self.root, full=False)
425
+ self.assertTrue(r3.get("success"), r3)
426
+ self.assertEqual(int(r3.get("parseable_files") or 0), 2, r3)
427
+ self.assertTrue(
428
+ r3.get("candidates_reused") is True,
429
+ f"after full→map_paths migration warm must reuse; r1={r1} r2={r2} r3={r3}",
430
+ )
431
+ self.assertFalse(r3.get("candidates_relisted"), r3)
432
+ # Index keys must be ⊆ pkg (pruned or filtered)
433
+ index = ic.load_mtime_index(self.root) or {}
434
+ outside = [k for k in index if isinstance(k, str) and k.startswith("outside")]
435
+ self.assertEqual(
436
+ outside,
437
+ [],
438
+ f"outside keys must be pruned after map_paths narrow: {list(index.keys())}",
439
+ )
440
+
441
+
442
+ class TestPruneFileIndexOutsideScope(TempProjectTestCase):
443
+ def test_prune_removes_outside_rows(self):
444
+ self.write("pkg/a.py", "1\n")
445
+ self.write("outside/c.py", "2\n")
446
+ r = cli.update_maps(project_root=self.root, full=True)
447
+ self.assertTrue(r.get("success"), r)
448
+ # Manually ensure both keys present then prune
449
+ index = ic.load_mtime_index(self.root) or {}
450
+ self.assertGreaterEqual(len(index), 2, index)
451
+ n = cs.prune_file_index_outside_scope(self.root, ["pkg"], is_full_tree=False)
452
+ self.assertGreaterEqual(n, 1, "should delete outside rows")
453
+ index2 = ic.load_mtime_index(self.root) or {}
454
+ self.assertTrue(all(not k.startswith("outside") for k in index2), index2)
455
+ self.assertTrue(any(k.startswith("pkg") for k in index2), index2)
456
+
457
+
458
+ if __name__ == "__main__":
459
+ unittest.main()