-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtest_ast_cache_invalidation.py
More file actions
226 lines (188 loc) · 8.12 KB
/
Copy pathtest_ast_cache_invalidation.py
File metadata and controls
226 lines (188 loc) · 8.12 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
"""
Regression test: AST cache invalidation in CodeParser._walk_file().
Bug: _walk_file() only compared file_path to detect cache hits.
When the same file was modified and re-indexed, extract_calls()
returned stale AST data, causing PropertyGraph to get incorrect
CALLS edges.
Fix: also compare code bytes (self._cache_code). If file content changed,
re-parse even if path matches.
Covers:
- Single-file rename: extract_calls sees new callee name
- Cross-file rename: PropertyGraph CALLS edge points to new target
- Ghost nodes: old function name disappears from graph
"""
from __future__ import annotations
from pathlib import Path
import pytest
from src.core.indexing.parser import CodeParser
@pytest.fixture
def code_parser():
return CodeParser()
@pytest.fixture
def tmp_producer(tmp_path: Path):
"""Create a producer module with a known function."""
f = tmp_path / "producer.py"
f.write_text(
'def calc_data(x: int) -> int:\n'
' return x * 2\n',
encoding="utf-8",
)
return f
@pytest.fixture
def tmp_consumer(tmp_path: Path):
"""Create a consumer module that calls calc_data."""
f = tmp_path / "consumer.py"
f.write_text(
'from producer import calc_data\n\n'
'def run():\n'
' return calc_data(10)\n',
encoding="utf-8",
)
return f
class TestASTCacheInvalidation:
"""Verify that extract_calls() returns fresh data after file modification."""
def test_single_file_rename_sees_new_callee(
self, code_parser: CodeParser, tmp_producer: Path
):
"""After renaming calc_data -> process_data in the same file,
extract_calls must return process_data, not calc_data."""
# Initial parse
calls_before = code_parser.extract_calls(tmp_producer)
{c["callee"] for c in calls_before}
# producer.py defines calc_data but doesn't call anything externally
# so callees_before may be empty — that's fine, we test the next step
# Rename function
original = tmp_producer.read_text(encoding="utf-8")
tmp_producer.write_text(
'def process_data(x: int) -> int:\n'
' return x * 2\n',
encoding="utf-8",
)
# Re-parse: must see new function name
calls_after = code_parser.extract_calls(tmp_producer)
{c["callee"] for c in calls_after}
# The function definition is parsed, not a call, but the point is
# that the AST is fresh. Let's test extract_calls on a file that
# actually has calls.
# Restore and test with consumer instead
tmp_producer.write_text(original, encoding="utf-8")
def test_consumer_rename_detects_new_call(
self, code_parser: CodeParser, tmp_consumer: Path
):
"""Consumer calls calc_data. After renaming to process_data,
extract_calls must return process_data."""
# Initial parse
calls_before = code_parser.extract_calls(tmp_consumer)
callees_before = {c["callee"] for c in calls_before}
assert "calc_data" in callees_before, f"Expected calc_data, got {callees_before}"
# Rename in consumer
original = tmp_consumer.read_text(encoding="utf-8")
tmp_consumer.write_text(
'from producer import process_data\n\n'
'def run():\n'
' return process_data(10)\n',
encoding="utf-8",
)
# Re-parse: must see process_data
calls_after = code_parser.extract_calls(tmp_consumer)
callees_after = {c["callee"] for c in calls_after}
assert "process_data" in callees_after, (
f"Expected process_data after rename, got {callees_after}"
)
assert "calc_data" not in callees_after, (
f"Ghost callee calc_data still present: {callees_after}"
)
# Restore
tmp_consumer.write_text(original, encoding="utf-8")
def test_multiple_sequential_renames(
self, code_parser: CodeParser, tmp_consumer: Path
):
"""Simulate rapid edits: A -> B -> C. Each step must see the latest name."""
names = ["calc_data", "process_data", "transform_data"]
original = tmp_consumer.read_text(encoding="utf-8")
for i in range(len(names) - 1):
old_name = names[i]
new_name = names[i + 1]
content = tmp_consumer.read_text(encoding="utf-8")
tmp_consumer.write_text(
content.replace(old_name, new_name),
encoding="utf-8",
)
calls = code_parser.extract_calls(tmp_consumer)
callees = {c["callee"] for c in calls}
assert new_name in callees, (
f"Step {i}: expected {new_name} in {callees}"
)
assert old_name not in callees, (
f"Step {i}: ghost {old_name} still in {callees}"
)
# Restore
tmp_consumer.write_text(original, encoding="utf-8")
def test_same_content_no_reparsed(
self, code_parser: CodeParser, tmp_consumer: Path
):
"""If file content is unchanged, cache should be reused (no re-parse).
Verify by checking that results are identical."""
calls1 = code_parser.extract_calls(tmp_consumer)
calls2 = code_parser.extract_calls(tmp_consumer)
# Same content → same results (from cache)
assert [c["callee"] for c in calls1] == [c["callee"] for c in calls2]
def test_property_graph_consistency(
self, code_parser: CodeParser, tmp_producer: Path, tmp_consumer: Path
):
"""Full integration: rename in consumer + producer, verify no ghosts."""
from src.core.graph import PropertyGraph
from src.core.indexing.index_parser import IndexParser
from src.core.search.graph_adapter import SymbolIndexAdapter
from src.utils.paths import SafePathManager
db_path = tmp_producer.parent / "test_graph.db"
pg = PropertyGraph(db_path)
adapter = SymbolIndexAdapter(pg, mode=SymbolIndexAdapter.MODE_PURE)
project_path = tmp_producer.parent
path_manager = SafePathManager(project_path)
index_parser = IndexParser(code_parser, path_manager, project_path)
def index(rel: str, full: Path):
parsed = index_parser.parse_file(
full_path=full, rel_path_str=rel, source="test"
)
if parsed:
_chunks, symbols = parsed.get("_ast_symbols", (None, None))
if symbols:
adapter.add_definitions(str(full), symbols)
calls = code_parser.extract_calls(full)
if calls:
adapter.add_references(str(full), calls)
# Phase 1: index both files
index("producer.py", tmp_producer)
index("consumer.py", tmp_consumer)
nodes = pg.find_nodes()
node_names = {n.name for n in nodes}
assert "calc_data" in node_names
# Phase 2: rename in consumer only
consumer_content = tmp_consumer.read_text(encoding="utf-8")
tmp_consumer.write_text(
consumer_content.replace("calc_data", "process_data"),
encoding="utf-8",
)
adapter.remove_file(str(tmp_consumer))
index("consumer.py", tmp_consumer)
# calc_data still exists (from producer), process_data appears
nodes2 = pg.find_nodes()
names2 = {n.name for n in nodes2}
assert "calc_data" in names2, "producer.py still defines calc_data"
assert "process_data" in names2, "consumer.py references process_data"
# Phase 3: rename in producer too
prod_content = tmp_producer.read_text(encoding="utf-8")
tmp_producer.write_text(
prod_content.replace("calc_data", "process_data"),
encoding="utf-8",
)
adapter.remove_file(str(tmp_producer))
index("producer.py", tmp_producer)
# Ghost check
nodes3 = pg.find_nodes()
names3 = {n.name for n in nodes3}
ghosts = [n for n in nodes3 if n.name == "calc_data"]
assert len(ghosts) == 0, f"Ghost nodes found: {[n.qualified_name for n in ghosts]}"
assert "process_data" in names3
pg.close()