wikifier 4.6.5__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.
- {wikifier-4.6.5/wikifier.egg-info → wikifier-4.6.7}/PKG-INFO +5 -4
- {wikifier-4.6.5 → wikifier-4.6.7}/README.md +4 -3
- {wikifier-4.6.5 → wikifier-4.6.7}/skills/run.md +4 -2
- {wikifier-4.6.5 → wikifier-4.6.7}/tests/test_import_cache.py +7 -1
- wikifier-4.6.7/tests/test_index_map_paths.py +459 -0
- {wikifier-4.6.5 → wikifier-4.6.7}/wikifier/__init__.py +1 -1
- {wikifier-4.6.5 → wikifier-4.6.7}/wikifier/cache_store.py +66 -21
- wikifier-4.6.7/wikifier/candidates.py +702 -0
- {wikifier-4.6.5 → wikifier-4.6.7}/wikifier/cli.py +56 -17
- {wikifier-4.6.5 → wikifier-4.6.7/wikifier.egg-info}/PKG-INFO +5 -4
- {wikifier-4.6.5 → wikifier-4.6.7}/wikifier.egg-info/SOURCES.txt +1 -0
- wikifier-4.6.5/wikifier/candidates.py +0 -409
- {wikifier-4.6.5 → wikifier-4.6.7}/CONTRIBUTING.md +0 -0
- {wikifier-4.6.5 → wikifier-4.6.7}/LICENSE +0 -0
- {wikifier-4.6.5 → wikifier-4.6.7}/MANIFEST.in +0 -0
- {wikifier-4.6.5 → wikifier-4.6.7}/diagnostics.html +0 -0
- {wikifier-4.6.5 → wikifier-4.6.7}/docs/spec.md +0 -0
- {wikifier-4.6.5 → wikifier-4.6.7}/index.html +0 -0
- {wikifier-4.6.5 → wikifier-4.6.7}/pyproject.toml +0 -0
- {wikifier-4.6.5 → wikifier-4.6.7}/setup.cfg +0 -0
- {wikifier-4.6.5 → wikifier-4.6.7}/tests/test_agent_loop.py +0 -0
- {wikifier-4.6.5 → wikifier-4.6.7}/tests/test_agent_scale.py +0 -0
- {wikifier-4.6.5 → wikifier-4.6.7}/tests/test_barrel_invalidation.py +0 -0
- {wikifier-4.6.5 → wikifier-4.6.7}/tests/test_cache_store.py +0 -0
- {wikifier-4.6.5 → wikifier-4.6.7}/tests/test_gap_closure.py +0 -0
- {wikifier-4.6.5 → wikifier-4.6.7}/tests/test_health.py +0 -0
- {wikifier-4.6.5 → wikifier-4.6.7}/tests/test_multi_lang_parsers.py +0 -0
- {wikifier-4.6.5 → wikifier-4.6.7}/tests/test_parsers.py +0 -0
- {wikifier-4.6.5 → wikifier-4.6.7}/tests/test_selftest_wrappers.py +0 -0
- {wikifier-4.6.5 → wikifier-4.6.7}/tests/test_walk_coverage_resolvers.py +0 -0
- {wikifier-4.6.5 → wikifier-4.6.7}/wikifier/__main__.py +0 -0
- {wikifier-4.6.5 → wikifier-4.6.7}/wikifier/agent_loop.py +0 -0
- {wikifier-4.6.5 → wikifier-4.6.7}/wikifier/contracts.py +0 -0
- {wikifier-4.6.5 → wikifier-4.6.7}/wikifier/daemon.py +0 -0
- {wikifier-4.6.5 → wikifier-4.6.7}/wikifier/diagnostics.py +0 -0
- {wikifier-4.6.5 → wikifier-4.6.7}/wikifier/health.py +0 -0
- {wikifier-4.6.5 → wikifier-4.6.7}/wikifier/import_cache.py +0 -0
- {wikifier-4.6.5 → wikifier-4.6.7}/wikifier/index.html +0 -0
- {wikifier-4.6.5 → wikifier-4.6.7}/wikifier/library.py +0 -0
- {wikifier-4.6.5 → wikifier-4.6.7}/wikifier/locking.py +0 -0
- {wikifier-4.6.5 → wikifier-4.6.7}/wikifier/mcp/__init__.py +0 -0
- {wikifier-4.6.5 → wikifier-4.6.7}/wikifier/mcp/server.py +0 -0
- {wikifier-4.6.5 → wikifier-4.6.7}/wikifier/parsers/__init__.py +0 -0
- {wikifier-4.6.5 → wikifier-4.6.7}/wikifier/parsers/_edge.py +0 -0
- {wikifier-4.6.5 → wikifier-4.6.7}/wikifier/parsers/bree.py +0 -0
- {wikifier-4.6.5 → wikifier-4.6.7}/wikifier/parsers/c_cpp.py +0 -0
- {wikifier-4.6.5 → wikifier-4.6.7}/wikifier/parsers/cdia.py +0 -0
- {wikifier-4.6.5 → wikifier-4.6.7}/wikifier/parsers/csharp.py +0 -0
- {wikifier-4.6.5 → wikifier-4.6.7}/wikifier/parsers/go_lang.py +0 -0
- {wikifier-4.6.5 → wikifier-4.6.7}/wikifier/parsers/java.py +0 -0
- {wikifier-4.6.5 → wikifier-4.6.7}/wikifier/parsers/javascript.py +0 -0
- {wikifier-4.6.5 → wikifier-4.6.7}/wikifier/parsers/python.py +0 -0
- {wikifier-4.6.5 → wikifier-4.6.7}/wikifier/parsers/rust.py +0 -0
- {wikifier-4.6.5 → wikifier-4.6.7}/wikifier/project_root.py +0 -0
- {wikifier-4.6.5 → wikifier-4.6.7}/wikifier/resolution.py +0 -0
- {wikifier-4.6.5 → wikifier-4.6.7}/wikifier/scripts/wikifier.bat +0 -0
- {wikifier-4.6.5 → wikifier-4.6.7}/wikifier/scripts/wikifier.ps1 +0 -0
- {wikifier-4.6.5 → wikifier-4.6.7}/wikifier/scripts/wikifier.sh +0 -0
- {wikifier-4.6.5 → wikifier-4.6.7}/wikifier/serve.py +0 -0
- {wikifier-4.6.5 → wikifier-4.6.7}/wikifier.egg-info/dependency_links.txt +0 -0
- {wikifier-4.6.5 → wikifier-4.6.7}/wikifier.egg-info/entry_points.txt +0 -0
- {wikifier-4.6.5 → wikifier-4.6.7}/wikifier.egg-info/requires.txt +0 -0
- {wikifier-4.6.5 → 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.
|
|
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,9 +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.
|
|
113
|
-
- **
|
|
114
|
-
- **
|
|
112
|
+
- **Warm agent maps (4.6.3–4.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
|
|
115
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
|
|
116
117
|
- **Scale** — reverse index + barrel invalidation so one edit doesn’t re-scan the monorepo
|
|
117
118
|
- **MCP tools** — optional server for Claude, Cursor, Cline, and other MCP clients
|
|
@@ -77,9 +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.
|
|
81
|
-
- **
|
|
82
|
-
- **
|
|
80
|
+
- **Warm agent maps (4.6.3–4.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
|
|
83
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
|
|
84
85
|
- **Scale** — reverse index + barrel invalidation so one edit doesn’t re-scan the monorepo
|
|
85
86
|
- **MCP tools** — optional server for Claude, Cursor, Cline, and other MCP clients
|
|
@@ -12,8 +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:**
|
|
16
|
-
- **
|
|
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.
|
|
17
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`).
|
|
18
20
|
- **CLI target:** `wikifier --target /abs/project health --summary` (flags stripped before the command). Or `WIKIFIER_PROJECT_ROOT=…`.
|
|
19
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
|
-
|
|
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()
|