code-oracle 0.1.0__py3-none-any.whl
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.
- code_oracle/__init__.py +30 -0
- code_oracle/cli.py +795 -0
- code_oracle/config.py +145 -0
- code_oracle/dataset.py +5325 -0
- code_oracle/dead_code/__init__.py +32 -0
- code_oracle/dead_code/detector.py +379 -0
- code_oracle/dead_code/entrypoints.py +333 -0
- code_oracle/dead_code/models.py +255 -0
- code_oracle/dead_code/semantics.py +416 -0
- code_oracle/decision.py +906 -0
- code_oracle/engine.py +430 -0
- code_oracle/export_onnx.py +436 -0
- code_oracle/hook.py +531 -0
- code_oracle/indexer.py +894 -0
- code_oracle/languages/__init__.py +114 -0
- code_oracle/languages/common.py +127 -0
- code_oracle/languages/go.py +395 -0
- code_oracle/languages/python.py +336 -0
- code_oracle/languages/rust.py +474 -0
- code_oracle/languages/typescript.py +775 -0
- code_oracle/linearizer.py +166 -0
- code_oracle/locator.py +301 -0
- code_oracle/models.py +237 -0
- code_oracle/perf_lint/__init__.py +38 -0
- code_oracle/perf_lint/engine.py +234 -0
- code_oracle/perf_lint/models.py +229 -0
- code_oracle/perf_lint/rules/__init__.py +31 -0
- code_oracle/perf_lint/rules/async_blocking.py +143 -0
- code_oracle/perf_lint/rules/n_plus_one.py +232 -0
- code_oracle/perf_lint/rules/nested_loops.py +137 -0
- code_oracle/perf_lint/rules/unclosed_res.py +494 -0
- code_oracle/perf_lint/visitor.py +299 -0
- code_oracle/server.py +184 -0
- code_oracle/slicer.py +225 -0
- code_oracle/symbolic.py +459 -0
- code_oracle-0.1.0.dist-info/METADATA +225 -0
- code_oracle-0.1.0.dist-info/RECORD +40 -0
- code_oracle-0.1.0.dist-info/WHEEL +4 -0
- code_oracle-0.1.0.dist-info/entry_points.txt +2 -0
- code_oracle-0.1.0.dist-info/licenses/LICENSE +190 -0
code_oracle/slicer.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Stage 3: k-Hop Neighborhood Slicer.
|
|
3
|
+
Isolates a compact directed subgraph (10 to 50 nodes) representing immediate callers,
|
|
4
|
+
callees, importers, and inheritance (k=1 or k=2), capped by a fan-out threshold to prevent graph explosion.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Dict, List, Optional, Set
|
|
8
|
+
|
|
9
|
+
from code_oracle.indexer import WorkspaceIndexer
|
|
10
|
+
from code_oracle.models import SlicedGraph, SliceEdge, SliceNode, Symbol
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def slice_neighborhood(
|
|
14
|
+
seeds: List[Symbol],
|
|
15
|
+
indexer: WorkspaceIndexer,
|
|
16
|
+
k: int = 1,
|
|
17
|
+
max_fanout: int = 20,
|
|
18
|
+
max_nodes: int = 50,
|
|
19
|
+
) -> SlicedGraph:
|
|
20
|
+
"""
|
|
21
|
+
Extract a directed neighborhood subgraph around seed symbols.
|
|
22
|
+
Expands forward (callees, base classes) and backward (callers, subclasses, importers) up to k hops,
|
|
23
|
+
enforcing a strict degree cutoff (max_fanout) per node and computing the induced subgraph.
|
|
24
|
+
"""
|
|
25
|
+
nodes: Dict[str, SliceNode] = {}
|
|
26
|
+
edges: List[SliceEdge] = []
|
|
27
|
+
seen_edges: Set[tuple] = set()
|
|
28
|
+
seed_ids: Set[str] = set()
|
|
29
|
+
graph_truncated = False
|
|
30
|
+
|
|
31
|
+
# Initialize seed nodes (capped by max_nodes)
|
|
32
|
+
if len(seeds) > max_nodes:
|
|
33
|
+
seeds = seeds[:max_nodes]
|
|
34
|
+
graph_truncated = True
|
|
35
|
+
|
|
36
|
+
current_symbols: List[Symbol] = []
|
|
37
|
+
for s in seeds:
|
|
38
|
+
node = SliceNode(
|
|
39
|
+
id=s.id,
|
|
40
|
+
name=s.name,
|
|
41
|
+
file_path=s.file_path,
|
|
42
|
+
kind=s.kind,
|
|
43
|
+
signature=s.signature,
|
|
44
|
+
is_seed=True,
|
|
45
|
+
is_modified=True,
|
|
46
|
+
symbol=s,
|
|
47
|
+
)
|
|
48
|
+
nodes[s.id] = node
|
|
49
|
+
seed_ids.add(s.id)
|
|
50
|
+
current_symbols.append(s)
|
|
51
|
+
|
|
52
|
+
visited_symbol_ids: Set[str] = set(seed_ids)
|
|
53
|
+
|
|
54
|
+
# Breadth-first expansion up to k hops
|
|
55
|
+
for hop in range(1, k + 1):
|
|
56
|
+
next_symbols: List[Symbol] = []
|
|
57
|
+
|
|
58
|
+
for curr_sym in current_symbols:
|
|
59
|
+
u_id = curr_sym.id
|
|
60
|
+
node_degree = 0
|
|
61
|
+
|
|
62
|
+
# 1. Forward edges: Callees called by curr_sym
|
|
63
|
+
for call in curr_sym.calls:
|
|
64
|
+
callee_def = indexer.resolve_callee(call, caller_sym=curr_sym)
|
|
65
|
+
if callee_def:
|
|
66
|
+
v_id = callee_def.id
|
|
67
|
+
if node_degree >= max_fanout:
|
|
68
|
+
nodes[u_id].truncated = True
|
|
69
|
+
graph_truncated = True
|
|
70
|
+
break
|
|
71
|
+
|
|
72
|
+
edge_key = (u_id, v_id, "CALLS")
|
|
73
|
+
if edge_key not in seen_edges:
|
|
74
|
+
seen_edges.add(edge_key)
|
|
75
|
+
edges.append(SliceEdge(source=u_id, target=v_id, relation="CALLS"))
|
|
76
|
+
node_degree += 1
|
|
77
|
+
|
|
78
|
+
if v_id not in nodes and len(nodes) < max_nodes:
|
|
79
|
+
nodes[v_id] = SliceNode(
|
|
80
|
+
id=v_id,
|
|
81
|
+
name=callee_def.name,
|
|
82
|
+
file_path=callee_def.file_path,
|
|
83
|
+
kind=callee_def.kind,
|
|
84
|
+
signature=callee_def.signature,
|
|
85
|
+
symbol=callee_def,
|
|
86
|
+
)
|
|
87
|
+
if v_id not in visited_symbol_ids:
|
|
88
|
+
visited_symbol_ids.add(v_id)
|
|
89
|
+
next_symbols.append(callee_def)
|
|
90
|
+
|
|
91
|
+
# 2. Forward edges: Base classes (INHERITS)
|
|
92
|
+
for base_name in getattr(curr_sym, "bases", []):
|
|
93
|
+
base_def = indexer.get_definition(base_name)
|
|
94
|
+
if base_def:
|
|
95
|
+
v_id = base_def.id
|
|
96
|
+
if node_degree >= max_fanout:
|
|
97
|
+
nodes[u_id].truncated = True
|
|
98
|
+
graph_truncated = True
|
|
99
|
+
break
|
|
100
|
+
|
|
101
|
+
edge_key = (u_id, v_id, "INHERITS")
|
|
102
|
+
if edge_key not in seen_edges:
|
|
103
|
+
seen_edges.add(edge_key)
|
|
104
|
+
edges.append(SliceEdge(source=u_id, target=v_id, relation="INHERITS"))
|
|
105
|
+
node_degree += 1
|
|
106
|
+
|
|
107
|
+
if v_id not in nodes and len(nodes) < max_nodes:
|
|
108
|
+
nodes[v_id] = SliceNode(
|
|
109
|
+
id=v_id,
|
|
110
|
+
name=base_def.name,
|
|
111
|
+
file_path=base_def.file_path,
|
|
112
|
+
kind=base_def.kind,
|
|
113
|
+
signature=base_def.signature,
|
|
114
|
+
symbol=base_def,
|
|
115
|
+
)
|
|
116
|
+
if v_id not in visited_symbol_ids:
|
|
117
|
+
visited_symbol_ids.add(v_id)
|
|
118
|
+
next_symbols.append(base_def)
|
|
119
|
+
|
|
120
|
+
# 3. Backward edges: Callers that call curr_sym
|
|
121
|
+
callers = indexer.get_callers(curr_sym.qualname)
|
|
122
|
+
if curr_sym.name != curr_sym.qualname:
|
|
123
|
+
for c in indexer.get_callers(curr_sym.name):
|
|
124
|
+
if c not in callers:
|
|
125
|
+
callers.append(c)
|
|
126
|
+
|
|
127
|
+
for call in callers:
|
|
128
|
+
if not call.caller:
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
caller_def = indexer.get_definition(call.caller)
|
|
132
|
+
v_id = caller_def.id if caller_def else call.caller
|
|
133
|
+
|
|
134
|
+
if node_degree >= max_fanout:
|
|
135
|
+
nodes[u_id].truncated = True
|
|
136
|
+
graph_truncated = True
|
|
137
|
+
break
|
|
138
|
+
|
|
139
|
+
edge_key = (v_id, u_id, "CALLS")
|
|
140
|
+
if edge_key not in seen_edges:
|
|
141
|
+
seen_edges.add(edge_key)
|
|
142
|
+
edges.append(SliceEdge(source=v_id, target=u_id, relation="CALLS"))
|
|
143
|
+
node_degree += 1
|
|
144
|
+
|
|
145
|
+
if v_id not in nodes and len(nodes) < max_nodes:
|
|
146
|
+
node_name = caller_def.name if caller_def else call.caller.split("::")[-1]
|
|
147
|
+
file_path = caller_def.file_path if caller_def else (
|
|
148
|
+
call.caller.split("::")[0] if "::" in call.caller else ""
|
|
149
|
+
)
|
|
150
|
+
signature = caller_def.signature if caller_def else f"def {node_name}(...)"
|
|
151
|
+
kind = caller_def.kind if caller_def else "function"
|
|
152
|
+
|
|
153
|
+
nodes[v_id] = SliceNode(
|
|
154
|
+
id=v_id,
|
|
155
|
+
name=node_name,
|
|
156
|
+
file_path=file_path,
|
|
157
|
+
kind=kind,
|
|
158
|
+
signature=signature,
|
|
159
|
+
symbol=caller_def,
|
|
160
|
+
)
|
|
161
|
+
if caller_def and v_id not in visited_symbol_ids:
|
|
162
|
+
visited_symbol_ids.add(v_id)
|
|
163
|
+
next_symbols.append(caller_def)
|
|
164
|
+
|
|
165
|
+
# 4. Backward edges: Subclasses (INHERITS)
|
|
166
|
+
subclasses = indexer.get_subclasses(curr_sym.name)
|
|
167
|
+
if curr_sym.name != curr_sym.qualname:
|
|
168
|
+
subclasses.extend(indexer.get_subclasses(curr_sym.qualname))
|
|
169
|
+
for sub in subclasses:
|
|
170
|
+
v_id = sub.id
|
|
171
|
+
if node_degree >= max_fanout:
|
|
172
|
+
nodes[u_id].truncated = True
|
|
173
|
+
graph_truncated = True
|
|
174
|
+
break
|
|
175
|
+
|
|
176
|
+
edge_key = (v_id, u_id, "INHERITS")
|
|
177
|
+
if edge_key not in seen_edges:
|
|
178
|
+
seen_edges.add(edge_key)
|
|
179
|
+
edges.append(SliceEdge(source=v_id, target=u_id, relation="INHERITS"))
|
|
180
|
+
node_degree += 1
|
|
181
|
+
|
|
182
|
+
if v_id not in nodes and len(nodes) < max_nodes:
|
|
183
|
+
nodes[v_id] = SliceNode(
|
|
184
|
+
id=v_id,
|
|
185
|
+
name=sub.name,
|
|
186
|
+
file_path=sub.file_path,
|
|
187
|
+
kind=sub.kind,
|
|
188
|
+
signature=sub.signature,
|
|
189
|
+
symbol=sub,
|
|
190
|
+
)
|
|
191
|
+
if v_id not in visited_symbol_ids:
|
|
192
|
+
visited_symbol_ids.add(v_id)
|
|
193
|
+
next_symbols.append(sub)
|
|
194
|
+
|
|
195
|
+
current_symbols = next_symbols
|
|
196
|
+
if not current_symbols or len(nodes) >= max_nodes:
|
|
197
|
+
break
|
|
198
|
+
|
|
199
|
+
# 5. Induced Subgraph Completion: Connect any calls/inheritance between nodes already in the slice
|
|
200
|
+
for u_id, u_node in list(nodes.items()):
|
|
201
|
+
if not u_node.symbol:
|
|
202
|
+
continue
|
|
203
|
+
# Check calls
|
|
204
|
+
for call in u_node.symbol.calls:
|
|
205
|
+
target_def = indexer.resolve_callee(call, caller_sym=u_node.symbol)
|
|
206
|
+
if target_def and target_def.id in nodes:
|
|
207
|
+
edge_key = (u_id, target_def.id, "CALLS")
|
|
208
|
+
if edge_key not in seen_edges:
|
|
209
|
+
seen_edges.add(edge_key)
|
|
210
|
+
edges.append(SliceEdge(source=u_id, target=target_def.id, relation="CALLS"))
|
|
211
|
+
# Check base classes
|
|
212
|
+
for base_name in getattr(u_node.symbol, "bases", []):
|
|
213
|
+
base_def = indexer.get_definition(base_name)
|
|
214
|
+
if base_def and base_def.id in nodes:
|
|
215
|
+
edge_key = (u_id, base_def.id, "INHERITS")
|
|
216
|
+
if edge_key not in seen_edges:
|
|
217
|
+
seen_edges.add(edge_key)
|
|
218
|
+
edges.append(SliceEdge(source=u_id, target=base_def.id, relation="INHERITS"))
|
|
219
|
+
|
|
220
|
+
return SlicedGraph(
|
|
221
|
+
nodes=nodes,
|
|
222
|
+
edges=edges,
|
|
223
|
+
seed_ids=seed_ids,
|
|
224
|
+
truncated=graph_truncated,
|
|
225
|
+
)
|
code_oracle/symbolic.py
ADDED
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Deterministic Symbolic Gate: Graph Cycle Detection and Invariant Checks.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Dict, List, Optional, Set
|
|
8
|
+
|
|
9
|
+
from code_oracle.indexer import WorkspaceIndexer
|
|
10
|
+
from code_oracle.models import CallReference, GateResult, PatchResult, SlicedGraph, Symbol
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def find_cycles_tarjan(
|
|
14
|
+
graph: Dict[str, List[str]], include_self_loops: bool = False
|
|
15
|
+
) -> List[List[str]]:
|
|
16
|
+
"""
|
|
17
|
+
Find strongly connected components with size > 1 (cycles) using Tarjan's algorithm.
|
|
18
|
+
Deterministic O(V + E) runtime with sorted node traversal.
|
|
19
|
+
"""
|
|
20
|
+
index = 0
|
|
21
|
+
indices: Dict[str, int] = {}
|
|
22
|
+
lowlink: Dict[str, int] = {}
|
|
23
|
+
on_stack: Set[str] = set()
|
|
24
|
+
stack: List[str] = []
|
|
25
|
+
cycles: List[List[str]] = []
|
|
26
|
+
|
|
27
|
+
def strongconnect(node: str):
|
|
28
|
+
nonlocal index
|
|
29
|
+
indices[node] = index
|
|
30
|
+
lowlink[node] = index
|
|
31
|
+
index += 1
|
|
32
|
+
stack.append(node)
|
|
33
|
+
on_stack.add(node)
|
|
34
|
+
|
|
35
|
+
for neighbor in sorted(graph.get(node, [])):
|
|
36
|
+
if neighbor not in indices:
|
|
37
|
+
strongconnect(neighbor)
|
|
38
|
+
lowlink[node] = min(lowlink[node], lowlink[neighbor])
|
|
39
|
+
elif neighbor in on_stack:
|
|
40
|
+
lowlink[node] = min(lowlink[node], indices[neighbor])
|
|
41
|
+
|
|
42
|
+
if lowlink[node] == indices[node]:
|
|
43
|
+
component = []
|
|
44
|
+
while True:
|
|
45
|
+
w = stack.pop()
|
|
46
|
+
on_stack.remove(w)
|
|
47
|
+
component.append(w)
|
|
48
|
+
if w == node:
|
|
49
|
+
break
|
|
50
|
+
|
|
51
|
+
if len(component) > 1:
|
|
52
|
+
# Rotate cycle so minimum element is first for determinism
|
|
53
|
+
min_idx = component.index(min(component))
|
|
54
|
+
rotated = component[min_idx:] + component[:min_idx]
|
|
55
|
+
cycles.append(rotated)
|
|
56
|
+
elif include_self_loops and len(component) == 1:
|
|
57
|
+
if node in graph.get(node, []):
|
|
58
|
+
cycles.append(component)
|
|
59
|
+
|
|
60
|
+
for node in sorted(graph.keys()):
|
|
61
|
+
if node not in indices:
|
|
62
|
+
strongconnect(node)
|
|
63
|
+
|
|
64
|
+
# Sort cycles by their starting element
|
|
65
|
+
cycles.sort(key=lambda c: c[0] if c else "")
|
|
66
|
+
return cycles
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def validate_call_site(
|
|
70
|
+
call: CallReference,
|
|
71
|
+
callee_sym: Symbol,
|
|
72
|
+
caller_label: str,
|
|
73
|
+
) -> List[str]:
|
|
74
|
+
"""
|
|
75
|
+
Validate a single call site against the target symbol's signature.
|
|
76
|
+
Returns a list of violation messages.
|
|
77
|
+
"""
|
|
78
|
+
violations: List[str] = []
|
|
79
|
+
|
|
80
|
+
# If method (not @staticmethod), the first param ('self'/'cls') is bound at runtime
|
|
81
|
+
if callee_sym.is_method:
|
|
82
|
+
formal_params = callee_sym.params[1:] if len(callee_sym.params) > 0 else []
|
|
83
|
+
else:
|
|
84
|
+
formal_params = callee_sym.params[:]
|
|
85
|
+
|
|
86
|
+
pos_params = [
|
|
87
|
+
p for p in formal_params if not p.is_kwonly and not p.is_vararg and not p.is_kwarg
|
|
88
|
+
]
|
|
89
|
+
kwonly_params = [p for p in formal_params if p.is_kwonly]
|
|
90
|
+
has_vararg = any(p.is_vararg for p in formal_params)
|
|
91
|
+
has_kwarg = any(p.is_kwarg for p in formal_params)
|
|
92
|
+
|
|
93
|
+
effective_max_args = None if has_vararg else len(pos_params)
|
|
94
|
+
effective_min_args = len([p for p in pos_params if not p.has_default])
|
|
95
|
+
|
|
96
|
+
# Check max positional args
|
|
97
|
+
if not call.has_vararg and effective_max_args is not None and call.args_count > effective_max_args:
|
|
98
|
+
violations.append(
|
|
99
|
+
f"ARITY_MISMATCH: Caller '{caller_label}' (line {call.lineno}) calls "
|
|
100
|
+
f"'{callee_sym.qualname}' with {call.args_count} positional arguments, "
|
|
101
|
+
f"but '{callee_sym.qualname}' accepts at most {effective_max_args} positional arguments."
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# Check which positional arguments were supplied
|
|
105
|
+
supplied_pos_params = pos_params[: min(call.args_count, len(pos_params))]
|
|
106
|
+
unsatisfied_pos_params = pos_params[min(call.args_count, len(pos_params)) :]
|
|
107
|
+
|
|
108
|
+
# Check duplicate arguments (passed positionally and by keyword)
|
|
109
|
+
supplied_pos_names = {p.name for p in supplied_pos_params}
|
|
110
|
+
for kw in call.kwargs:
|
|
111
|
+
if kw in supplied_pos_names:
|
|
112
|
+
violations.append(
|
|
113
|
+
f"DUPLICATE_ARGUMENT: Caller '{caller_label}' (line {call.lineno}) provides "
|
|
114
|
+
f"multiple values for argument '{kw}' when calling '{callee_sym.qualname}'."
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
# Check positional-only parameter called as keyword
|
|
118
|
+
posonly_names = {p.name for p in pos_params if p.is_posonly}
|
|
119
|
+
for p in pos_params:
|
|
120
|
+
if p.is_posonly and p.name in call.kwargs:
|
|
121
|
+
violations.append(
|
|
122
|
+
f"KEYWORD_MISMATCH: Caller '{caller_label}' (line {call.lineno}) passed "
|
|
123
|
+
f"positional-only argument '{p.name}' as keyword when calling '{callee_sym.qualname}'."
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
# Check unexpected keyword arguments
|
|
127
|
+
if not has_kwarg:
|
|
128
|
+
accepted_names = {p.name for p in pos_params if not p.is_posonly} | {
|
|
129
|
+
p.name for p in kwonly_params
|
|
130
|
+
}
|
|
131
|
+
for kw in call.kwargs:
|
|
132
|
+
if kw not in accepted_names and kw not in posonly_names:
|
|
133
|
+
violations.append(
|
|
134
|
+
f"KEYWORD_MISMATCH: Caller '{caller_label}' (line {call.lineno}) calls "
|
|
135
|
+
f"'{callee_sym.qualname}' with unexpected keyword argument '{kw}'."
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# Check missing required positional arguments (if not satisfied via kwargs or dynamic unpacking)
|
|
139
|
+
if not call.has_vararg:
|
|
140
|
+
for p in unsatisfied_pos_params:
|
|
141
|
+
if not p.has_default and p.name not in call.kwargs:
|
|
142
|
+
if not (call.has_kwarg and not p.is_posonly):
|
|
143
|
+
violations.append(
|
|
144
|
+
f"ARITY_MISMATCH: Caller '{caller_label}' (line {call.lineno}) missing "
|
|
145
|
+
f"required argument '{p.name}' when calling '{callee_sym.qualname}' "
|
|
146
|
+
f"(requires at least {effective_min_args} arguments)."
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
# Check missing required keyword-only arguments
|
|
150
|
+
if not call.has_kwarg:
|
|
151
|
+
for p in kwonly_params:
|
|
152
|
+
if not p.has_default and p.name not in call.kwargs:
|
|
153
|
+
violations.append(
|
|
154
|
+
f"KEYWORD_MISMATCH: Caller '{caller_label}' (line {call.lineno}) missing "
|
|
155
|
+
f"required keyword argument '{p.name}' when calling '{callee_sym.qualname}'."
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
return violations
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def verify_symbolic_gate(
|
|
162
|
+
patch_result: PatchResult,
|
|
163
|
+
slice_graph: SlicedGraph,
|
|
164
|
+
indexer: WorkspaceIndexer,
|
|
165
|
+
) -> GateResult:
|
|
166
|
+
"""
|
|
167
|
+
Deterministic Symbolic Gate (Stage 4):
|
|
168
|
+
Verifies:
|
|
169
|
+
1. Syntax validity of patched content.
|
|
170
|
+
2. Absence of cyclic dependency / circular calls and imports (Tarjan SCC).
|
|
171
|
+
3. Parameter arity and keyword invariants across callers and callees.
|
|
172
|
+
4. Deleted symbol references (callers and importers) and dangling imports.
|
|
173
|
+
Runs deterministically in sub-2ms.
|
|
174
|
+
"""
|
|
175
|
+
violations: List[str] = []
|
|
176
|
+
cycles_detected: List[List[str]] = []
|
|
177
|
+
|
|
178
|
+
# 1. Syntax check
|
|
179
|
+
if patch_result.syntax_error:
|
|
180
|
+
violations.append(f"SYNTAX_ERROR: {patch_result.syntax_error}")
|
|
181
|
+
return GateResult(
|
|
182
|
+
status="REJECTED",
|
|
183
|
+
confidence=1.0,
|
|
184
|
+
cycles=[],
|
|
185
|
+
violations=violations,
|
|
186
|
+
details={"error_type": "SYNTAX_ERROR"},
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
# 2. Cycle detection via Tarjan's SCC
|
|
190
|
+
# 2A. Call graph cycle detection
|
|
191
|
+
call_graph: Dict[str, List[str]] = {}
|
|
192
|
+
for node_id in slice_graph.nodes:
|
|
193
|
+
call_graph.setdefault(node_id, [])
|
|
194
|
+
|
|
195
|
+
for edge in slice_graph.edges:
|
|
196
|
+
if edge.relation == "CALLS":
|
|
197
|
+
call_graph.setdefault(edge.source, []).append(edge.target)
|
|
198
|
+
|
|
199
|
+
# Ensure calls inside modified/added symbols are included in graph
|
|
200
|
+
for sym in patch_result.affected_symbols + patch_result.added_symbols:
|
|
201
|
+
u_id = sym.id
|
|
202
|
+
call_graph.setdefault(u_id, [])
|
|
203
|
+
for call in sym.calls:
|
|
204
|
+
callee_def = indexer.resolve_callee(call, caller_sym=sym)
|
|
205
|
+
if callee_def:
|
|
206
|
+
v_id = callee_def.id
|
|
207
|
+
if v_id not in call_graph[u_id]:
|
|
208
|
+
call_graph[u_id].append(v_id)
|
|
209
|
+
|
|
210
|
+
raw_call_cycles = find_cycles_tarjan(call_graph)
|
|
211
|
+
if raw_call_cycles:
|
|
212
|
+
cycles_detected.extend(raw_call_cycles)
|
|
213
|
+
for cycle in raw_call_cycles:
|
|
214
|
+
cycle_repr = " -> ".join([c.split("::")[-1] for c in cycle] + [cycle[0].split("::")[-1]])
|
|
215
|
+
violations.append(f"CIRCULAR_DEPENDENCY: Detected call cycle: {cycle_repr}")
|
|
216
|
+
|
|
217
|
+
# 2B. Import cycle detection (direct and multi-hop across workspace)
|
|
218
|
+
if hasattr(indexer, "_import_graph") and indexer._import_graph:
|
|
219
|
+
import_graph: Dict[str, List[str]] = {k: list(v) for k, v in indexer._import_graph.items()}
|
|
220
|
+
else:
|
|
221
|
+
import_graph = {}
|
|
222
|
+
for f_path, f_data in indexer._file_cache.items():
|
|
223
|
+
import_graph.setdefault(f_path, [])
|
|
224
|
+
for imp_data in f_data.get("imports", []):
|
|
225
|
+
imp_obj = indexer._deserialize_import(imp_data)
|
|
226
|
+
target_f = indexer.resolve_import_to_file(imp_obj, f_path)
|
|
227
|
+
if target_f and target_f != f_path:
|
|
228
|
+
if target_f not in import_graph[f_path]:
|
|
229
|
+
import_graph[f_path].append(target_f)
|
|
230
|
+
|
|
231
|
+
raw_import_cycles = find_cycles_tarjan(import_graph)
|
|
232
|
+
for cycle in raw_import_cycles:
|
|
233
|
+
if patch_result.file_path in cycle:
|
|
234
|
+
if cycle not in cycles_detected:
|
|
235
|
+
cycles_detected.append(cycle)
|
|
236
|
+
cycle_repr = " -> ".join(cycle + [cycle[0]])
|
|
237
|
+
violations.append(f"CIRCULAR_DEPENDENCY: Detected import cycle: {cycle_repr}")
|
|
238
|
+
|
|
239
|
+
# 3. Contract / Arity Invariant Checks (Bidirectional)
|
|
240
|
+
seen_call_sites: Set[tuple] = set()
|
|
241
|
+
|
|
242
|
+
# 3A. Existing callers calling modified/added symbols
|
|
243
|
+
for sym in patch_result.affected_symbols + patch_result.added_symbols:
|
|
244
|
+
if sym.kind not in ("function", "async_function", "method"):
|
|
245
|
+
continue
|
|
246
|
+
|
|
247
|
+
callers = indexer.get_callers(sym.qualname)
|
|
248
|
+
if sym.qualname != sym.name:
|
|
249
|
+
for c in indexer.get_callers(sym.name):
|
|
250
|
+
if c not in callers:
|
|
251
|
+
callers.append(c)
|
|
252
|
+
|
|
253
|
+
# If __init__ or constructor method of a class, callers might be instantiating the class by its class name
|
|
254
|
+
if sym.name in ("__init__", "constructor") and "." in sym.qualname:
|
|
255
|
+
class_qualname = sym.qualname.rsplit(".", 1)[0]
|
|
256
|
+
for c in indexer.get_callers(class_qualname):
|
|
257
|
+
if c not in callers:
|
|
258
|
+
callers.append(c)
|
|
259
|
+
class_simple = class_qualname.split(".")[-1]
|
|
260
|
+
if class_simple != class_qualname:
|
|
261
|
+
for c in indexer.get_callers(class_simple):
|
|
262
|
+
if c not in callers:
|
|
263
|
+
callers.append(c)
|
|
264
|
+
|
|
265
|
+
for call in callers:
|
|
266
|
+
caller_label = call.caller or "unknown_caller"
|
|
267
|
+
call_key = (caller_label, call.lineno, sym.id, call.args_count, tuple(sorted(call.kwargs)))
|
|
268
|
+
if call_key in seen_call_sites:
|
|
269
|
+
continue
|
|
270
|
+
seen_call_sites.add(call_key)
|
|
271
|
+
|
|
272
|
+
violations.extend(validate_call_site(call, sym, caller_label))
|
|
273
|
+
|
|
274
|
+
# 3B. Calls MADE BY modified/added symbols (or module level)
|
|
275
|
+
for caller_sym in patch_result.affected_symbols + patch_result.added_symbols:
|
|
276
|
+
caller_label = caller_sym.id
|
|
277
|
+
for call in caller_sym.calls:
|
|
278
|
+
callee_def = indexer.resolve_callee(call, caller_sym=caller_sym)
|
|
279
|
+
if not callee_def:
|
|
280
|
+
continue
|
|
281
|
+
|
|
282
|
+
target_sym = callee_def
|
|
283
|
+
if callee_def.kind == "class":
|
|
284
|
+
# Class instantiation invokes __init__
|
|
285
|
+
init_def = indexer.resolve_class_init(callee_def)
|
|
286
|
+
if init_def:
|
|
287
|
+
target_sym = init_def
|
|
288
|
+
else:
|
|
289
|
+
# Class without custom __init__ accepts 0 arguments
|
|
290
|
+
call_key = (caller_label, call.lineno, callee_def.id, call.args_count, tuple(sorted(call.kwargs)))
|
|
291
|
+
if call_key not in seen_call_sites:
|
|
292
|
+
seen_call_sites.add(call_key)
|
|
293
|
+
if not call.has_vararg and call.args_count > 0:
|
|
294
|
+
violations.append(
|
|
295
|
+
f"ARITY_MISMATCH: Caller '{caller_label}' (line {call.lineno}) calls "
|
|
296
|
+
f"'{callee_def.qualname}' with {call.args_count} positional arguments, "
|
|
297
|
+
f"but '{callee_def.qualname}' accepts at most 0 positional arguments."
|
|
298
|
+
)
|
|
299
|
+
if not call.has_kwarg and call.kwargs:
|
|
300
|
+
for kw in call.kwargs:
|
|
301
|
+
violations.append(
|
|
302
|
+
f"KEYWORD_MISMATCH: Caller '{caller_label}' (line {call.lineno}) calls "
|
|
303
|
+
f"'{callee_def.qualname}' with unexpected keyword argument '{kw}'."
|
|
304
|
+
)
|
|
305
|
+
continue
|
|
306
|
+
|
|
307
|
+
if target_sym.kind in ("function", "async_function", "method"):
|
|
308
|
+
call_key = (caller_label, call.lineno, target_sym.id, call.args_count, tuple(sorted(call.kwargs)))
|
|
309
|
+
if call_key in seen_call_sites:
|
|
310
|
+
continue
|
|
311
|
+
seen_call_sites.add(call_key)
|
|
312
|
+
|
|
313
|
+
violations.extend(validate_call_site(call, target_sym, caller_label))
|
|
314
|
+
|
|
315
|
+
# 4. Deleted Symbol & Broken Reference Invariants
|
|
316
|
+
active_caller_ids = {s.id for s in patch_result.all_patched_symbols}
|
|
317
|
+
for del_sym in patch_result.deleted_symbols:
|
|
318
|
+
# Check callers
|
|
319
|
+
callers = indexer.get_callers(del_sym.name)
|
|
320
|
+
if del_sym.qualname != del_sym.name:
|
|
321
|
+
callers.extend(indexer.get_callers(del_sym.qualname))
|
|
322
|
+
|
|
323
|
+
active_callers = []
|
|
324
|
+
for c in callers:
|
|
325
|
+
if not c.caller:
|
|
326
|
+
continue
|
|
327
|
+
if c.caller.startswith(f"{patch_result.file_path}::"):
|
|
328
|
+
if c.caller in active_caller_ids:
|
|
329
|
+
active_callers.append(c)
|
|
330
|
+
else:
|
|
331
|
+
active_callers.append(c)
|
|
332
|
+
|
|
333
|
+
for c in active_callers:
|
|
334
|
+
violations.append(
|
|
335
|
+
f"BROKEN_REFERENCE: Symbol '{del_sym.qualname}' was deleted in patch, "
|
|
336
|
+
f"but is called by '{c.caller or 'unknown'}' at line {c.lineno}."
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
# Check importers
|
|
340
|
+
importers = indexer.get_importers(del_sym.name)
|
|
341
|
+
if del_sym.qualname != del_sym.name:
|
|
342
|
+
importers.extend(indexer.get_importers(del_sym.qualname))
|
|
343
|
+
|
|
344
|
+
active_importers = [
|
|
345
|
+
imp for imp in importers
|
|
346
|
+
if imp.file_path != patch_result.file_path
|
|
347
|
+
]
|
|
348
|
+
for imp in active_importers:
|
|
349
|
+
violations.append(
|
|
350
|
+
f"BROKEN_REFERENCE: Symbol '{del_sym.qualname}' was deleted in patch, "
|
|
351
|
+
f"but is imported by '{imp.file_path}' at line {imp.lineno}."
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
# 4B. Check if any symbol imported by the patch does not exist in workspace module
|
|
355
|
+
for imp in patch_result.imports:
|
|
356
|
+
if (imp.module or imp.level > 0) and imp.name != "*":
|
|
357
|
+
target_f = indexer.resolve_import_to_file(imp, patch_result.file_path)
|
|
358
|
+
if not target_f:
|
|
359
|
+
mod_str = imp.module or ""
|
|
360
|
+
if mod_str.startswith("./") or mod_str.startswith("../") or imp.level > 0:
|
|
361
|
+
violations.append(
|
|
362
|
+
f"BROKEN_REFERENCE: Cannot resolve relative import '{mod_str}' in '{patch_result.file_path}' (line {imp.lineno}) - file does not exist."
|
|
363
|
+
)
|
|
364
|
+
continue
|
|
365
|
+
|
|
366
|
+
if target_f in indexer._file_cache:
|
|
367
|
+
cached = indexer._file_cache[target_f]
|
|
368
|
+
defined_names = {s["name"] for s in cached.get("symbols", [])}
|
|
369
|
+
imported_names = {i.get("asname") or i["name"] for i in cached.get("imports", [])}
|
|
370
|
+
|
|
371
|
+
# If target module re-exports with star import, allow dynamic symbols
|
|
372
|
+
if "*" in {i["name"] for i in cached.get("imports", [])}:
|
|
373
|
+
continue
|
|
374
|
+
|
|
375
|
+
if imp.name not in defined_names and imp.name not in imported_names:
|
|
376
|
+
target_full = indexer.workspace_root / target_f
|
|
377
|
+
target_dir = target_full.parent
|
|
378
|
+
submod_cands = [
|
|
379
|
+
target_dir / f"{imp.name}.py",
|
|
380
|
+
target_dir / imp.name / "__init__.py",
|
|
381
|
+
target_dir / f"{imp.name}.ts",
|
|
382
|
+
target_dir / f"{imp.name}.tsx",
|
|
383
|
+
target_dir / f"{imp.name}.js",
|
|
384
|
+
target_dir / imp.name / "index.ts",
|
|
385
|
+
target_dir / imp.name / "index.js",
|
|
386
|
+
target_dir / f"{imp.name}.rs",
|
|
387
|
+
target_dir / imp.name / "mod.rs",
|
|
388
|
+
target_dir / f"{imp.name}.go",
|
|
389
|
+
]
|
|
390
|
+
has_symbol = any(c.exists() for c in submod_cands)
|
|
391
|
+
if not has_symbol and target_f.endswith(".go"):
|
|
392
|
+
# In Go, imports are package-level (e.g. import "scorp-agent/agent").
|
|
393
|
+
# The identifier is the package namespace, which is valid if the package dir has Go files.
|
|
394
|
+
if target_dir.is_dir() and any(target_dir.glob("*.go")):
|
|
395
|
+
has_symbol = True
|
|
396
|
+
if not has_symbol and target_full.exists():
|
|
397
|
+
if target_f.endswith(".py"):
|
|
398
|
+
try:
|
|
399
|
+
src = target_full.read_text(encoding="utf-8", errors="ignore")
|
|
400
|
+
tree = ast.parse(src)
|
|
401
|
+
for node in ast.walk(tree):
|
|
402
|
+
if isinstance(node, (ast.Assign, ast.AnnAssign)):
|
|
403
|
+
target_list = node.targets if isinstance(node, ast.Assign) else [node.target]
|
|
404
|
+
for t in target_list:
|
|
405
|
+
for child in ast.walk(t):
|
|
406
|
+
if isinstance(child, ast.Name) and child.id == imp.name:
|
|
407
|
+
has_symbol = True
|
|
408
|
+
break
|
|
409
|
+
if has_symbol:
|
|
410
|
+
break
|
|
411
|
+
elif isinstance(node, ast.NamedExpr):
|
|
412
|
+
if isinstance(node.target, ast.Name) and node.target.id == imp.name:
|
|
413
|
+
has_symbol = True
|
|
414
|
+
break
|
|
415
|
+
elif isinstance(node, getattr(ast, "TypeAlias", ())):
|
|
416
|
+
if isinstance(node.name, ast.Name) and node.name.id == imp.name:
|
|
417
|
+
has_symbol = True
|
|
418
|
+
break
|
|
419
|
+
if has_symbol:
|
|
420
|
+
break
|
|
421
|
+
except Exception:
|
|
422
|
+
has_symbol = True
|
|
423
|
+
else:
|
|
424
|
+
# For TypeScript, Go, Rust: check extracted symbols from target file
|
|
425
|
+
try:
|
|
426
|
+
src = target_full.read_text(encoding="utf-8", errors="ignore")
|
|
427
|
+
from code_oracle.languages import extract_symbols
|
|
428
|
+
syms = extract_symbols(src, file_path=target_f)
|
|
429
|
+
if any(s.name == imp.name for s in syms):
|
|
430
|
+
has_symbol = True
|
|
431
|
+
except Exception:
|
|
432
|
+
pass
|
|
433
|
+
if not has_symbol:
|
|
434
|
+
module_label = imp.module if imp.module else ("." * imp.level)
|
|
435
|
+
violations.append(
|
|
436
|
+
f"BROKEN_REFERENCE: Symbol '{imp.name}' imported from '{module_label}' "
|
|
437
|
+
f"does not exist in '{target_f}' (line {imp.lineno})."
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
# 5. Verdict
|
|
441
|
+
if violations or cycles_detected:
|
|
442
|
+
status = "REJECTED"
|
|
443
|
+
confidence = 0.95
|
|
444
|
+
else:
|
|
445
|
+
status = "APPROVED"
|
|
446
|
+
confidence = 0.98
|
|
447
|
+
|
|
448
|
+
return GateResult(
|
|
449
|
+
status=status,
|
|
450
|
+
confidence=confidence,
|
|
451
|
+
cycles=cycles_detected,
|
|
452
|
+
violations=violations,
|
|
453
|
+
details={
|
|
454
|
+
"nodes_checked": len(slice_graph.nodes),
|
|
455
|
+
"edges_checked": len(slice_graph.edges),
|
|
456
|
+
"cycles_count": len(cycles_detected),
|
|
457
|
+
"violations_count": len(violations),
|
|
458
|
+
},
|
|
459
|
+
)
|