wikifier 4.6.3__tar.gz → 4.6.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 (63) hide show
  1. {wikifier-4.6.3/wikifier.egg-info → wikifier-4.6.5}/PKG-INFO +5 -3
  2. {wikifier-4.6.3 → wikifier-4.6.5}/README.md +4 -2
  3. {wikifier-4.6.3 → wikifier-4.6.5}/skills/run.md +3 -1
  4. wikifier-4.6.5/tests/test_cache_store.py +193 -0
  5. wikifier-4.6.5/tests/test_walk_coverage_resolvers.py +203 -0
  6. {wikifier-4.6.3 → wikifier-4.6.5}/wikifier/__init__.py +2 -1
  7. {wikifier-4.6.3 → wikifier-4.6.5}/wikifier/agent_loop.py +78 -6
  8. wikifier-4.6.5/wikifier/cache_store.py +385 -0
  9. wikifier-4.6.5/wikifier/candidates.py +409 -0
  10. {wikifier-4.6.3 → wikifier-4.6.5}/wikifier/cli.py +242 -228
  11. {wikifier-4.6.3 → wikifier-4.6.5}/wikifier/import_cache.py +110 -38
  12. {wikifier-4.6.3 → wikifier-4.6.5}/wikifier/parsers/c_cpp.py +33 -10
  13. wikifier-4.6.5/wikifier/parsers/csharp.py +168 -0
  14. wikifier-4.6.5/wikifier/parsers/go_lang.py +164 -0
  15. {wikifier-4.6.3 → wikifier-4.6.5/wikifier.egg-info}/PKG-INFO +5 -3
  16. {wikifier-4.6.3 → wikifier-4.6.5}/wikifier.egg-info/SOURCES.txt +6 -0
  17. wikifier-4.6.3/wikifier/parsers/csharp.py +0 -64
  18. wikifier-4.6.3/wikifier/parsers/go_lang.py +0 -87
  19. {wikifier-4.6.3 → wikifier-4.6.5}/CONTRIBUTING.md +0 -0
  20. {wikifier-4.6.3 → wikifier-4.6.5}/LICENSE +0 -0
  21. {wikifier-4.6.3 → wikifier-4.6.5}/MANIFEST.in +0 -0
  22. {wikifier-4.6.3 → wikifier-4.6.5}/diagnostics.html +0 -0
  23. {wikifier-4.6.3 → wikifier-4.6.5}/docs/spec.md +0 -0
  24. {wikifier-4.6.3 → wikifier-4.6.5}/index.html +0 -0
  25. {wikifier-4.6.3 → wikifier-4.6.5}/pyproject.toml +0 -0
  26. {wikifier-4.6.3 → wikifier-4.6.5}/setup.cfg +0 -0
  27. {wikifier-4.6.3 → wikifier-4.6.5}/tests/test_agent_loop.py +0 -0
  28. {wikifier-4.6.3 → wikifier-4.6.5}/tests/test_agent_scale.py +0 -0
  29. {wikifier-4.6.3 → wikifier-4.6.5}/tests/test_barrel_invalidation.py +0 -0
  30. {wikifier-4.6.3 → wikifier-4.6.5}/tests/test_gap_closure.py +0 -0
  31. {wikifier-4.6.3 → wikifier-4.6.5}/tests/test_health.py +0 -0
  32. {wikifier-4.6.3 → wikifier-4.6.5}/tests/test_import_cache.py +0 -0
  33. {wikifier-4.6.3 → wikifier-4.6.5}/tests/test_multi_lang_parsers.py +0 -0
  34. {wikifier-4.6.3 → wikifier-4.6.5}/tests/test_parsers.py +0 -0
  35. {wikifier-4.6.3 → wikifier-4.6.5}/tests/test_selftest_wrappers.py +0 -0
  36. {wikifier-4.6.3 → wikifier-4.6.5}/wikifier/__main__.py +0 -0
  37. {wikifier-4.6.3 → wikifier-4.6.5}/wikifier/contracts.py +0 -0
  38. {wikifier-4.6.3 → wikifier-4.6.5}/wikifier/daemon.py +0 -0
  39. {wikifier-4.6.3 → wikifier-4.6.5}/wikifier/diagnostics.py +0 -0
  40. {wikifier-4.6.3 → wikifier-4.6.5}/wikifier/health.py +0 -0
  41. {wikifier-4.6.3 → wikifier-4.6.5}/wikifier/index.html +0 -0
  42. {wikifier-4.6.3 → wikifier-4.6.5}/wikifier/library.py +0 -0
  43. {wikifier-4.6.3 → wikifier-4.6.5}/wikifier/locking.py +0 -0
  44. {wikifier-4.6.3 → wikifier-4.6.5}/wikifier/mcp/__init__.py +0 -0
  45. {wikifier-4.6.3 → wikifier-4.6.5}/wikifier/mcp/server.py +0 -0
  46. {wikifier-4.6.3 → wikifier-4.6.5}/wikifier/parsers/__init__.py +0 -0
  47. {wikifier-4.6.3 → wikifier-4.6.5}/wikifier/parsers/_edge.py +0 -0
  48. {wikifier-4.6.3 → wikifier-4.6.5}/wikifier/parsers/bree.py +0 -0
  49. {wikifier-4.6.3 → wikifier-4.6.5}/wikifier/parsers/cdia.py +0 -0
  50. {wikifier-4.6.3 → wikifier-4.6.5}/wikifier/parsers/java.py +0 -0
  51. {wikifier-4.6.3 → wikifier-4.6.5}/wikifier/parsers/javascript.py +0 -0
  52. {wikifier-4.6.3 → wikifier-4.6.5}/wikifier/parsers/python.py +0 -0
  53. {wikifier-4.6.3 → wikifier-4.6.5}/wikifier/parsers/rust.py +0 -0
  54. {wikifier-4.6.3 → wikifier-4.6.5}/wikifier/project_root.py +0 -0
  55. {wikifier-4.6.3 → wikifier-4.6.5}/wikifier/resolution.py +0 -0
  56. {wikifier-4.6.3 → wikifier-4.6.5}/wikifier/scripts/wikifier.bat +0 -0
  57. {wikifier-4.6.3 → wikifier-4.6.5}/wikifier/scripts/wikifier.ps1 +0 -0
  58. {wikifier-4.6.3 → wikifier-4.6.5}/wikifier/scripts/wikifier.sh +0 -0
  59. {wikifier-4.6.3 → wikifier-4.6.5}/wikifier/serve.py +0 -0
  60. {wikifier-4.6.3 → wikifier-4.6.5}/wikifier.egg-info/dependency_links.txt +0 -0
  61. {wikifier-4.6.3 → wikifier-4.6.5}/wikifier.egg-info/entry_points.txt +0 -0
  62. {wikifier-4.6.3 → wikifier-4.6.5}/wikifier.egg-info/requires.txt +0 -0
  63. {wikifier-4.6.3 → wikifier-4.6.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.6.3
3
+ Version: 4.6.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
@@ -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+)** — zero-dirty fast path skips full graph/`library.md` rewrite when nothing reparsed; content-hash dirty so mtime thrash ≠ reparse; `--directory=` scopes the candidate walk (monorepo budgets)
113
- - **Selective agent work** — health + suggest bias to 🔴/actionable 🟡 only; **ACS v1.3** `reason_code` / `agent_signal` (`skip`|`investigate`); prefer `actionable_low_conf_edges` over raw low-conf noise
112
+ - **Warm agent maps (4.6.3–4.6.5)** — zero-dirty fast path; **stdlib SQLite** cache; **candidate list reuse** + scoped/monitored-first walks (no whole-tree thrash); content-hash dirty
113
+ - **Partial-map honesty** — `map_coverage` on `update_maps` / bootstrap / **`suggest_next`**; incomplete maps yield `update_maps_until_complete` actions
114
+ - **Cache ops** — `wikifier cache-status` (backend, bytes, ACS, coverage, dual-write policy)
115
+ - **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
114
116
  - **Scale** — reverse index + barrel invalidation so one edit doesn’t re-scan the monorepo
115
117
  - **MCP tools** — optional server for Claude, Cursor, Cline, and other MCP clients
116
118
  - **Zero core dependencies** — stdlib only; forks can add their own stack on top
@@ -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+)** — zero-dirty fast path skips full graph/`library.md` rewrite when nothing reparsed; content-hash dirty so mtime thrash ≠ reparse; `--directory=` scopes the candidate walk (monorepo budgets)
81
- - **Selective agent work** — health + suggest bias to 🔴/actionable 🟡 only; **ACS v1.3** `reason_code` / `agent_signal` (`skip`|`investigate`); prefer `actionable_low_conf_edges` over raw low-conf noise
80
+ - **Warm agent maps (4.6.3–4.6.5)** — zero-dirty fast path; **stdlib SQLite** cache; **candidate list reuse** + scoped/monitored-first walks (no whole-tree thrash); content-hash dirty
81
+ - **Partial-map honesty** — `map_coverage` on `update_maps` / bootstrap / **`suggest_next`**; incomplete maps yield `update_maps_until_complete` actions
82
+ - **Cache ops** — `wikifier cache-status` (backend, bytes, ACS, coverage, dual-write policy)
83
+ - **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
82
84
  - **Scale** — reverse index + barrel invalidation so one edit doesn’t re-scan the monorepo
83
85
  - **MCP tools** — optional server for Claude, Cursor, Cline, and other MCP clients
84
86
  - **Zero core dependencies** — stdlib only; forks can add their own stack on top
@@ -11,7 +11,9 @@
11
11
  - File Tree + `wikifier serve`; MCP status/attention use library + emoji (not `[GREEN]` tags).
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
- - **Steady-state selective work:** only 🔴 and *actionable* 🟡 (never re-wiki 🟢; never re-wiki the whole stub set). ACS uses `actionable_low_conf_edges`.
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. Candidate lists reuse when scope fingerprint is unchanged; prefer `--directory=` / lean `monitored_paths` (not bare `.`).
16
+ - **Cache ops:** `wikifier cache-status` / `from wikifier import cache_status` — backend, sizes, `acs_version`, `map_coverage`. Dual-write JSON only ≤400 files or `WIKIFIER_CACHE_JSON=1` (set `0` to never dual-write).
15
17
  - **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`).
16
18
  - **CLI target:** `wikifier --target /abs/project health --summary` (flags stripped before the command). Or `WIKIFIER_PROJECT_ROOT=…`.
17
19
  - **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.
@@ -0,0 +1,193 @@
1
+ """SQLite cache store + map_coverage + warm path (shipped APIs, zero-dep)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import time
7
+ import unittest
8
+ from pathlib import Path
9
+
10
+ from tests._base import TempProjectTestCase
11
+
12
+ from wikifier import cli
13
+ from wikifier import import_cache as ic
14
+ from wikifier import cache_store as cs
15
+
16
+
17
+ class TestSqliteCacheRoundTrip(TempProjectTestCase):
18
+ def test_save_load_sqlite_primary(self):
19
+ self.write("a.py", "import os\n")
20
+ r = cli.update_maps(project_root=self.root, full=True)
21
+ self.assertTrue(r.get("success"), r)
22
+ self.assertTrue(cs.has_sqlite(self.root), "sqlite should exist after save")
23
+ cache = ic.load_cache(self.root)
24
+ self.assertIn("a.py", cache)
25
+ self.assertIsInstance(cache["a.py"].get("mtime"), int)
26
+ # light index works without requiring pairs field presence
27
+ idx = ic.load_mtime_index(self.root)
28
+ self.assertIn("a.py", idx)
29
+ self.assertEqual(idx["a.py"]["mtime"], cache["a.py"]["mtime"])
30
+
31
+ def test_legacy_json_dual_read_then_migrate(self):
32
+ # Plant legacy JSON only (no sqlite)
33
+ staging = self.root / ".wikifier_staging"
34
+ staging.mkdir(parents=True, exist_ok=True)
35
+ import json
36
+ legacy = {
37
+ "x.py": {
38
+ "mtime": 1,
39
+ "content_hash": "sha256:abc",
40
+ "imports": ["os"],
41
+ "resolved": [],
42
+ "resolved_pairs": [{"raw": "os", "resolved": "", "confidence_score": 0.48}],
43
+ },
44
+ "_acs_summary": {
45
+ "acs_version": "1.2",
46
+ "total_scored_edges": 1,
47
+ "actionable_low_conf_edges": 0,
48
+ "low_conf_edges": 1,
49
+ },
50
+ }
51
+ (staging / "import_cache.json").write_text(
52
+ json.dumps(legacy), encoding="utf-8"
53
+ )
54
+ loaded = ic.load_cache(self.root)
55
+ self.assertIn("x.py", loaded)
56
+ # update_maps migrates
57
+ self.write("x.py", "import os\n")
58
+ r = cli.update_maps(project_root=self.root, full=False)
59
+ self.assertTrue(r.get("success"), r)
60
+ self.assertTrue(cs.has_sqlite(self.root))
61
+
62
+
63
+ class TestWarmPathNoFullJsonTax(TempProjectTestCase):
64
+ def test_zero_dirty_reports_sqlite_backend_and_coverage(self):
65
+ self.write("m.py", "print(1)\n")
66
+ r1 = cli.update_maps(project_root=self.root, full=True)
67
+ self.assertTrue(r1.get("success"), r1)
68
+ r2 = cli.update_maps(project_root=self.root, full=False)
69
+ self.assertTrue(r2.get("success"), r2)
70
+ self.assertTrue(r2.get("zero_dirty_fast_path"), r2)
71
+ self.assertIn(r2.get("cache_backend"), ("sqlite", "sqlite+json", "json"))
72
+ cov = r2.get("map_coverage") or {}
73
+ self.assertTrue(cov.get("complete"), cov)
74
+ self.assertIn("files_remaining_dirty", cov)
75
+ self.assertEqual(int(cov["files_remaining_dirty"]), 0)
76
+
77
+ def test_zero_dirty_skips_full_payload_load_cache_dict(self):
78
+ """Shipped warm path must not deserialize full file payloads on 0-dirty.
79
+
80
+ Criterion: after SQLite is warm, zero-dirty update_maps must not call
81
+ cache_store.load_cache_dict (full pairs) or ic.load_cache (legacy full).
82
+ Light index + meta only.
83
+ """
84
+ self.write("warm.py", "print(0)\n")
85
+ r1 = cli.update_maps(project_root=self.root, full=True)
86
+ self.assertTrue(r1.get("success"), r1)
87
+ self.assertTrue(cs.has_sqlite(self.root))
88
+ # Second run seeds zero-dirty + sqlite
89
+ r_seed = cli.update_maps(project_root=self.root, full=False)
90
+ self.assertTrue(r_seed.get("zero_dirty_fast_path"), r_seed)
91
+
92
+ full_loads = {"load_cache_dict": 0, "load_cache": 0}
93
+ real_lcd = cs.load_cache_dict
94
+ real_lc = ic.load_cache
95
+
96
+ def counting_lcd(root):
97
+ full_loads["load_cache_dict"] += 1
98
+ return real_lcd(root)
99
+
100
+ def counting_lc(root):
101
+ full_loads["load_cache"] += 1
102
+ return real_lc(root)
103
+
104
+ # Patch at the modules the shipped path imports from
105
+ import wikifier.cache_store as cs_mod
106
+ import wikifier.import_cache as ic_mod
107
+ orig_cs = cs_mod.load_cache_dict
108
+ orig_ic = ic_mod.load_cache
109
+ cs_mod.load_cache_dict = counting_lcd # type: ignore
110
+ ic_mod.load_cache = counting_lc # type: ignore
111
+ try:
112
+ r2 = cli.update_maps(project_root=self.root, full=False)
113
+ finally:
114
+ cs_mod.load_cache_dict = orig_cs # type: ignore
115
+ ic_mod.load_cache = orig_ic # type: ignore
116
+
117
+ self.assertTrue(r2.get("success"), r2)
118
+ self.assertTrue(r2.get("zero_dirty_fast_path"), r2)
119
+ self.assertEqual(
120
+ full_loads["load_cache_dict"],
121
+ 0,
122
+ f"warm 0-dirty must not call load_cache_dict; counts={full_loads}",
123
+ )
124
+ self.assertEqual(
125
+ full_loads["load_cache"],
126
+ 0,
127
+ f"warm 0-dirty must not call load_cache; counts={full_loads}",
128
+ )
129
+
130
+ def test_max_files_coverage_incomplete(self):
131
+ for i in range(6):
132
+ self.write(f"f{i}.py", f"v={i}\n")
133
+ r = cli.update_maps(project_root=self.root, full=True, max_files=2)
134
+ self.assertTrue(r.get("success"), r)
135
+ cov = r.get("map_coverage") or {}
136
+ self.assertFalse(cov.get("complete"), cov)
137
+ self.assertEqual(int(cov.get("files_skipped") or 0), 4)
138
+ self.assertGreaterEqual(int(cov.get("files_remaining_dirty") or 0), 4)
139
+ self.assertIn("agent_note", cov)
140
+
141
+ def test_mtime_index_skips_content_stable(self):
142
+ p = self.write("s.py", "x=1\n")
143
+ cli.update_maps(project_root=self.root, full=True)
144
+ cache = ic.load_cache(self.root)
145
+ ent = cache["s.py"]
146
+ ent["mtime"] = 1
147
+ ic.save_cache(self.root, cache)
148
+ time.sleep(0.05)
149
+ os.utime(p, None)
150
+ updates = []
151
+ need = ic.compute_files_needing_reparse(
152
+ self.root, [p], content_stable_mtime_updates=updates
153
+ )
154
+ # content_hash should make this stable once hash stored
155
+ if ent.get("content_hash"):
156
+ self.assertEqual(need, [])
157
+ self.assertGreaterEqual(len(updates), 1)
158
+
159
+
160
+ class TestBootstrapCoverageAndAcsGuidance(TempProjectTestCase):
161
+ def test_bootstrap_surfaces_acs_guidance(self):
162
+ self.write("h.py", "import os\n")
163
+ cli.update_maps(project_root=self.root, full=True)
164
+ boot = cli.session_bootstrap(project_root=self.root)
165
+ self.assertTrue(boot.get("success"), boot)
166
+ self.assertIn("acs_guidance", boot)
167
+ self.assertIn("actionable_low_conf_edges", boot["acs_guidance"])
168
+ acs = boot.get("acs") or {}
169
+ self.assertIn("acs_version", acs)
170
+
171
+
172
+ class TestGoModResolve(TempProjectTestCase):
173
+ def test_same_module_import_resolves(self):
174
+ from wikifier.parsers import go_lang as go
175
+
176
+ self.write(
177
+ "go.mod",
178
+ "module example.com/demo\n\ngo 1.21\n",
179
+ )
180
+ self.write("pkg/util/util.go", "package util\nfunc X() {}\n")
181
+ main = self.write(
182
+ "cmd/main.go",
183
+ 'package main\nimport "example.com/demo/pkg/util"\n',
184
+ )
185
+ edges = go.parse_go_imports(str(main))
186
+ self.assertTrue(edges)
187
+ resolved = [e for e in edges if e.get("resolved_path")]
188
+ self.assertTrue(resolved, msg=f"edges={edges}")
189
+ self.assertIn("util", resolved[0]["resolved_path"])
190
+
191
+
192
+ if __name__ == "__main__":
193
+ unittest.main()
@@ -0,0 +1,203 @@
1
+ """Walk cost, Core map_coverage, thin C#/C++ resolve, cache-status (shipped APIs)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import time
7
+ import unittest
8
+ from pathlib import Path
9
+
10
+ from tests._base import TempProjectTestCase
11
+
12
+ from wikifier import cli
13
+ from wikifier import cache_store as cs
14
+ from wikifier.candidates import (
15
+ collect_candidate_source_files,
16
+ try_cached_candidate_rels,
17
+ candidate_list_meta,
18
+ )
19
+ from wikifier.agent_loop import build_structured_actions
20
+
21
+
22
+ class TestScopedCollect(TempProjectTestCase):
23
+ def test_directory_scope_limits_candidates(self):
24
+ self.write("pkg/a.py", "x=1\n")
25
+ self.write("other/b.py", "y=1\n")
26
+ cands = collect_candidate_source_files(self.root, directory="pkg")
27
+ rels = []
28
+ for p in cands:
29
+ try:
30
+ rels.append(str(Path(p).resolve().relative_to(self.root.resolve())))
31
+ except Exception:
32
+ rels.append(os.path.realpath(str(p)))
33
+ self.assertTrue(any("pkg" in r and r.endswith("a.py") for r in rels), rels)
34
+ self.assertFalse(any("other" in r for r in rels), rels)
35
+
36
+ def test_wiki_monitored_paths_do_not_collapse_map(self):
37
+ """monitored_paths with only wiki files must not yield a 1-file map."""
38
+ self.write("pkg/a.py", "x=1\n")
39
+ self.write("pkg/b.py", "y=1\n")
40
+ self.write("skills/run.md", "# wiki\n")
41
+ self.write(
42
+ "monitored_paths.txt",
43
+ "# wiki only\nskills/run.md\nREADME.md\npkg/a.py\n",
44
+ )
45
+ self.write("README.md", "# r\n")
46
+ cands = collect_candidate_source_files(self.root, directory=None, use_monitored=True)
47
+ # Must walk full tree, not just a.py from monitored
48
+ py = [p for p in cands if p.suffix == ".py"]
49
+ self.assertGreaterEqual(len(py), 2, [str(p) for p in py])
50
+
51
+ def test_candidate_reuse_true_when_git_clean(self):
52
+ """Second warm reuses candidate list when scope clean — honest assert."""
53
+ self.write("m.py", "print(1)\n")
54
+ r1 = cli.update_maps(project_root=self.root, full=True)
55
+ self.assertTrue(r1.get("success"), r1)
56
+ n1 = int(r1.get("parseable_files") or 0)
57
+ self.assertGreaterEqual(n1, 1)
58
+ r2 = cli.update_maps(project_root=self.root, full=False)
59
+ self.assertTrue(r2.get("success"), r2)
60
+ r3 = cli.update_maps(project_root=self.root, full=False)
61
+ self.assertTrue(r3.get("success"), r3)
62
+ self.assertTrue(
63
+ r3.get("candidates_reused") is True,
64
+ f"expected reuse on third warm; r2={r2.get('candidates_reused')} r3={r3}",
65
+ )
66
+ self.assertEqual(int(r3.get("parseable_files") or 0), n1)
67
+
68
+ def test_nested_new_file_invalidates_reuse(self):
69
+ """Adding a nested source file must expand candidates (not stale reuse)."""
70
+ self.write("pkg/a.py", "x=1\n")
71
+ r1 = cli.update_maps(project_root=self.root, full=True)
72
+ self.assertTrue(r1.get("success"), r1)
73
+ n1 = int(r1.get("parseable_files") or 0)
74
+ # Nested new file under existing package
75
+ self.write("pkg/nested/b.py", "y=2\n")
76
+ time.sleep(0.05)
77
+ r2 = cli.update_maps(project_root=self.root, full=False)
78
+ self.assertTrue(r2.get("success"), r2)
79
+ n2 = int(r2.get("parseable_files") or 0)
80
+ self.assertGreater(
81
+ n2, n1,
82
+ f"nested add must expand parseable_files: before={n1} after={n2} reused={r2.get('candidates_reused')}",
83
+ )
84
+ # Must not have reused stale list of size n1
85
+ if r2.get("candidates_reused"):
86
+ self.fail("must not reuse candidate list after nested source add")
87
+
88
+ def test_poisoned_one_file_blob_not_reused(self):
89
+ """Plant 1-file candidate blob with matching fp; live tree has 2 sources → no reuse."""
90
+ self.write("pkg/a.py", "x=1\n")
91
+ self.write("pkg/b.py", "y=2\n")
92
+ r1 = cli.update_maps(project_root=self.root, full=True)
93
+ self.assertTrue(r1.get("success"), r1)
94
+ live_n = int(r1.get("parseable_files") or 0)
95
+ self.assertGreaterEqual(live_n, 2)
96
+ # Poison meta: same directory scope, current fp, but only one rel
97
+ from wikifier.candidates import scope_fingerprint, candidate_list_meta
98
+ from wikifier import cache_store as cs
99
+ fp = scope_fingerprint(self.root, None)
100
+ poison = {
101
+ "fp": fp,
102
+ "directory": None,
103
+ "count": 1,
104
+ "rels": ["pkg/a.py"],
105
+ }
106
+ cs.save_meta_key(self.root, "_candidate_list", poison)
107
+ # Direct API: try_cached must return None
108
+ meta = cs.load_meta(self.root, keys=("_candidate_list",))
109
+ from wikifier.candidates import try_cached_candidate_rels
110
+ cached = try_cached_candidate_rels(meta, self.root, None)
111
+ self.assertIsNone(
112
+ cached,
113
+ f"poisoned 1-file blob must not reuse when live has {live_n} sources",
114
+ )
115
+ # Shipped update_maps must not report parseable_files=1 with reused=true
116
+ r2 = cli.update_maps(project_root=self.root, full=False)
117
+ self.assertTrue(r2.get("success"), r2)
118
+ self.assertGreaterEqual(int(r2.get("parseable_files") or 0), 2, r2)
119
+ self.assertFalse(
120
+ r2.get("candidates_reused") is True and int(r2.get("parseable_files") or 0) == 1,
121
+ r2,
122
+ )
123
+
124
+
125
+ class TestCoverageInCore(TempProjectTestCase):
126
+ def test_suggest_surfaces_incomplete_map_action(self):
127
+ for i in range(5):
128
+ self.write(f"f{i}.py", f"v={i}\n")
129
+ r = cli.update_maps(project_root=self.root, full=True, max_files=2)
130
+ self.assertTrue(r.get("success"), r)
131
+ cov = r.get("map_coverage") or {}
132
+ self.assertFalse(cov.get("complete"), cov)
133
+ sug = cli.suggest_next_actions(project_root=self.root, format="json")
134
+ self.assertTrue(sug.get("success"), sug)
135
+ self.assertIn("map_coverage", sug)
136
+ self.assertFalse((sug.get("map_coverage") or {}).get("complete"))
137
+ actions = sug.get("actions") or []
138
+ kinds = [a.get("action") for a in actions]
139
+ self.assertIn("update_maps_until_complete", kinds, actions)
140
+ text = " ".join(sug.get("suggestions") or [])
141
+ self.assertIn("INCOMPLETE", text.upper())
142
+
143
+ def test_build_structured_actions_incomplete(self):
144
+ acts = build_structured_actions(
145
+ clean=True,
146
+ map_coverage={"complete": False, "files_remaining_dirty": 9},
147
+ )
148
+ self.assertTrue(any(a["action"] == "update_maps_until_complete" for a in acts))
149
+
150
+
151
+ class TestCacheStatus(TempProjectTestCase):
152
+ def test_cache_status_shape(self):
153
+ self.write("z.py", "z=1\n")
154
+ cli.update_maps(project_root=self.root, full=True)
155
+ st = cli.cache_status(project_root=self.root)
156
+ self.assertTrue(st.get("success"), st)
157
+ self.assertIn(st.get("cache_backend"), ("sqlite", "sqlite+json", "json"))
158
+ self.assertIn("sqlite_bytes", st)
159
+ self.assertIn("dual_write_policy", st)
160
+ self.assertIn("migrate_note", st)
161
+ st2 = cs.cache_status(self.root)
162
+ self.assertEqual(st2.get("cache_backend"), st.get("cache_backend"))
163
+
164
+
165
+ class TestCsharpProjectResolve(TempProjectTestCase):
166
+ def test_project_namespace_resolves(self):
167
+ from wikifier.parsers import csharp as cs_parser
168
+
169
+ self.write(
170
+ "Demo.csproj",
171
+ "<Project><PropertyGroup><RootNamespace>Demo</RootNamespace></PropertyGroup></Project>\n",
172
+ )
173
+ self.write("Util/Helper.cs", "namespace Demo.Util { class Helper {} }\n")
174
+ main = self.write(
175
+ "Program.cs",
176
+ "using Demo.Util;\nnamespace Demo { class Program {} }\n",
177
+ )
178
+ edges = cs_parser.parse_csharp_imports(str(main))
179
+ self.assertTrue(edges)
180
+ resolved = [e for e in edges if e.get("resolved_path")]
181
+ self.assertTrue(resolved, msg=f"edges={edges}")
182
+ self.assertTrue(
183
+ any("Util" in (e.get("resolved_path") or "") for e in resolved),
184
+ resolved,
185
+ )
186
+
187
+
188
+ class TestCCppLocalInclude(TempProjectTestCase):
189
+ def test_include_dir_resolve(self):
190
+ from wikifier.parsers import c_cpp as cc
191
+
192
+ self.write("include/foo.h", "#pragma once\n")
193
+ src = self.write("src/main.c", '#include "foo.h"\nint main(){return 0;}\n')
194
+ edges = cc.parse_c_cpp_imports(str(src))
195
+ self.assertTrue(edges)
196
+ self.assertTrue(
197
+ any(e.get("resolved_path") and "foo.h" in e["resolved_path"] for e in edges),
198
+ edges,
199
+ )
200
+
201
+
202
+ if __name__ == "__main__":
203
+ unittest.main()
@@ -48,6 +48,7 @@ from .cli import (
48
48
  why_file,
49
49
  seed_source_content_hashes,
50
50
  list_core_tools,
51
+ cache_status,
51
52
  )
52
53
  from . import agent_loop
53
54
 
@@ -78,4 +79,4 @@ from .contracts import (
78
79
  compute_acs_confidence,
79
80
  )
80
81
 
81
- __version__ = "4.6.3"
82
+ __version__ = "4.6.5"
@@ -176,6 +176,7 @@ def build_structured_actions(
176
176
  scope_warnings: Optional[List[str]] = None,
177
177
  clean: bool = False,
178
178
  max_file_actions: int = 12,
179
+ map_coverage: Optional[Dict[str, Any]] = None,
179
180
  ) -> List[Dict[str, Any]]:
180
181
  """Build dispatchable action objects for agents (not prose-only).
181
182
 
@@ -183,6 +184,28 @@ def build_structured_actions(
183
184
  """
184
185
  actions: List[Dict[str, Any]] = []
185
186
  prio = 1
187
+ cov = map_coverage if isinstance(map_coverage, dict) else {}
188
+ remaining = int(cov.get("files_remaining_dirty") or 0)
189
+ complete = cov.get("complete")
190
+ # Incomplete map under budget — higher priority than ACS thrash
191
+ if complete is False or remaining > 0:
192
+ actions.append({
193
+ "action": "update_maps_until_complete",
194
+ "file": None,
195
+ "priority": 3,
196
+ "reason": (
197
+ f"Map incomplete: files_remaining_dirty={remaining}, complete={complete}. "
198
+ "Re-run update_maps (same directory/max_files) until map_coverage.complete=true; "
199
+ "success alone does not mean the map is done."
200
+ ),
201
+ "preflight": ["update_maps"],
202
+ "map_coverage": {
203
+ "complete": complete,
204
+ "files_remaining_dirty": remaining,
205
+ "files_skipped": cov.get("files_skipped"),
206
+ "budget_max_files": cov.get("budget_max_files"),
207
+ },
208
+ })
186
209
  for f in (red_files or [])[:max_file_actions]:
187
210
  actions.append({
188
211
  "action": "investigate_red",
@@ -201,7 +224,7 @@ def build_structured_actions(
201
224
  "preflight": ["prepare_edit", "why_file"],
202
225
  })
203
226
  prio = min(prio + 1, 9)
204
- if red == 0 and actionable_yellow == 0 and clean:
227
+ if red == 0 and actionable_yellow == 0 and clean and remaining == 0:
205
228
  actions.append({
206
229
  "action": "lookup_only",
207
230
  "file": None,
@@ -222,7 +245,10 @@ def build_structured_actions(
222
245
  "action": "review_acs",
223
246
  "file": None,
224
247
  "priority": 4,
225
- "reason": f"{acs_actionable} actionable low-confidence project edges (not external noise)",
248
+ "reason": (
249
+ f"{acs_actionable} actionable low-confidence project edges "
250
+ "(prefer actionable_low_conf_edges + reason_code_counts; not raw low_conf_edges)"
251
+ ),
226
252
  "preflight": ["get_dependencies"],
227
253
  })
228
254
  for w in (scope_warnings or [])[:2]:
@@ -233,7 +259,10 @@ def build_structured_actions(
233
259
  "reason": str(w),
234
260
  "preflight": [],
235
261
  })
236
- if not any(a["action"] == "update_maps" for a in actions):
262
+ if not any(
263
+ a["action"] in ("update_maps", "update_maps_until_complete", "update_maps_if_structure")
264
+ for a in actions
265
+ ):
237
266
  actions.append({
238
267
  "action": "update_maps_if_structure",
239
268
  "file": None,
@@ -548,8 +577,33 @@ def session_bootstrap(
548
577
  acs_actionable = 0
549
578
  try:
550
579
  from . import import_cache as ic
551
- cache = ic.load_cache(root) or {}
552
- acs = ic.ensure_acs_summary_persisted(cache, root) or {}
580
+ try:
581
+ from . import cache_store as cs
582
+ except Exception:
583
+ cs = None # type: ignore
584
+ # Prefer light meta read (SQLite) over full multi-MB pair deserialize
585
+ acs: Dict[str, Any] = {}
586
+ cyc: Dict[str, Any] = {}
587
+ if cs is not None:
588
+ meta = cs.load_meta(root, keys=("_acs_summary", "_cycles", "_map_coverage"))
589
+ acs = meta.get("_acs_summary") if isinstance(meta.get("_acs_summary"), dict) else {}
590
+ cyc = meta.get("_cycles") if isinstance(meta.get("_cycles"), dict) else {}
591
+ cov = meta.get("_map_coverage") if isinstance(meta.get("_map_coverage"), dict) else {}
592
+ if cov:
593
+ out["map_coverage"] = cov
594
+ out["cache_backend"] = cs.backend_name(root)
595
+ needs_full = (
596
+ not acs
597
+ or str(acs.get("acs_version") or "") < "1.3"
598
+ or "reason_code_counts" not in acs
599
+ or "actionable_low_conf_edges" not in acs
600
+ )
601
+ if needs_full:
602
+ cache = ic.load_cache(root) or {}
603
+ acs = ic.ensure_acs_summary_persisted(cache, root) or {}
604
+ cyc = cache.get("_cycles") or {}
605
+ if cs is not None and not out.get("cache_backend"):
606
+ out["cache_backend"] = cs.backend_name(root)
553
607
  acs_actionable = int(acs.get("actionable_low_conf_edges") or 0)
554
608
  out["acs"] = {
555
609
  "acs_version": acs.get("acs_version"),
@@ -557,12 +611,22 @@ def session_bootstrap(
557
611
  "low_conf_edges": acs.get("low_conf_edges"),
558
612
  "dynamic_literal_noise_edges": acs.get("dynamic_literal_noise_edges"),
559
613
  "avg_confidence": acs.get("avg_confidence"),
614
+ "reason_code_counts": acs.get("reason_code_counts"),
615
+ "agent_signal_counts": acs.get("agent_signal_counts"),
560
616
  }
561
- cyc = cache.get("_cycles") or {}
617
+ # Agents must not thrash on raw low_conf_edges (ACS v1.3 scores more edges)
618
+ out["acs_guidance"] = (
619
+ "Prefer actionable_low_conf_edges + reason_code_counts "
620
+ "(skip external_or_bare/dynamic_literal); do not use low_conf_edges alone as a work queue."
621
+ )
562
622
  out["cycles"] = {
563
623
  "cyclic_scc_count": (cyc.get("stats") or {}).get("cyclic_scc_count"),
564
624
  "all_cycle_files_sample": (cyc.get("all_cycle_files") or [])[:10],
565
625
  }
626
+ if "map_coverage" not in out and cs is not None:
627
+ snap = cs.get_map_coverage_from_meta(root)
628
+ if snap.get("map_coverage"):
629
+ out["map_coverage"] = snap["map_coverage"]
566
630
  except Exception as e:
567
631
  out["acs_error"] = str(e)
568
632
 
@@ -574,6 +638,7 @@ def session_bootstrap(
574
638
  if isinstance(out.get("scope"), dict):
575
639
  scope_warnings = list(out["scope"].get("warnings") or [])
576
640
 
641
+ map_cov = out.get("map_coverage") if isinstance(out.get("map_coverage"), dict) else {}
577
642
  out["actions"] = build_structured_actions(
578
643
  red_files=red_files,
579
644
  actionable_yellow_files=actionable_yellow_files,
@@ -583,12 +648,19 @@ def session_bootstrap(
583
648
  acs_actionable=acs_actionable,
584
649
  scope_warnings=scope_warnings,
585
650
  clean=clean,
651
+ map_coverage=map_cov,
586
652
  )
587
653
  out["work_items"] = out["actions"] # alias
588
654
  out["selective_work"] = True
589
655
  out["map_first"] = True
656
+ rem = int(map_cov.get("files_remaining_dirty") or 0) if map_cov else 0
590
657
  out["message"] = (
591
658
  "session_bootstrap ready — use actions[] / attention; "
592
659
  "prepare_edit(file) before large edits; record_change after edits."
660
+ + (
661
+ f" MAP INCOMPLETE: files_remaining_dirty={rem} — re-run update_maps until complete."
662
+ if rem > 0 or map_cov.get("complete") is False
663
+ else ""
664
+ )
593
665
  )
594
666
  return out