cseq 0.0.1__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.
@@ -0,0 +1,468 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from bisect import bisect_right
5
+ import gc
6
+ import re
7
+ from typing import Any, Iterable
8
+
9
+ from .fast_parser import FastEligibility
10
+ from .model import (
11
+ CallSiteRecord,
12
+ Diagnostic,
13
+ FileParseArtifact,
14
+ FunctionRecord,
15
+ ParseStatus,
16
+ SourceRange,
17
+ TranslationUnit,
18
+ )
19
+ from .parser import ParserBackend, _preprocessor_guard_regions
20
+ from .parser_environment import load_parser_dependency_lock
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class TreeSitterPrepared:
25
+ source_bytes: bytes
26
+ node_types: frozenset[str]
27
+ eligibility: FastEligibility
28
+ has_error_or_missing: bool
29
+ root_type: str
30
+ artifact: FileParseArtifact
31
+
32
+
33
+ class TreeSitterCParser(ParserBackend):
34
+ """Canonical fast C syntax front-end backed by tree-sitter-c.
35
+
36
+ This backend deliberately owns syntax only. Constructs that require C type
37
+ or compiler semantics are surfaced through ``eligibility.reasons`` so the
38
+ HybridParser can route them to selective Clang refinement. The syntax tree
39
+ itself is still usable for fail-soft evidence when Clang is unavailable.
40
+ """
41
+
42
+ name = "tree-sitter-c-v1"
43
+
44
+ def __init__(self) -> None:
45
+ try:
46
+ from tree_sitter import Language, Parser, Query, QueryCursor
47
+ import tree_sitter_c
48
+ except Exception as exc: # pragma: no cover - exercised on wheel env
49
+ raise RuntimeError("locked tree-sitter runtime is not available") from exc
50
+
51
+ language = Language(tree_sitter_c.language())
52
+ lock = load_parser_dependency_lock()
53
+ expected_abi = int(lock["packages"]["tree-sitter-c"]["grammar_abi"])
54
+ observed_abi = int(getattr(language, "abi_version", -1))
55
+ if observed_abi != expected_abi:
56
+ raise RuntimeError(
57
+ f"tree-sitter-c ABI mismatch: observed={observed_abi} expected={expected_abi}"
58
+ )
59
+ self.language = language
60
+ self.parser = Parser(language)
61
+ self._QueryCursor = QueryCursor
62
+ self._function_query = Query(language, "(function_definition declarator: (function_declarator declarator: (identifier) @name) @decl) @fn")
63
+ self._syntax_evidence_query = Query(
64
+ language,
65
+ "[(function_definition) (call_expression) (return_statement) "
66
+ "(break_statement) (if_statement) (while_statement) (for_statement) "
67
+ "(field_expression) (struct_specifier) (type_definition) (preproc_ifdef)] @node",
68
+ )
69
+ self._all_call_query = Query(language, "(call_expression) @call")
70
+ self._direct_call_query = Query(language, "(call_expression function: (identifier)) @call")
71
+ self._call_detail_query = Query(language, "(call_expression function: (_) @target) @call")
72
+
73
+ def prepare(self, translation_unit: TranslationUnit) -> TreeSitterPrepared:
74
+ if translation_unit.source_text is not None:
75
+ source = translation_unit.source_text.encode("utf-8")
76
+ else:
77
+ source = translation_unit.source.path.read_bytes()
78
+ source_text = source.decode("utf-8", errors="replace")
79
+ line_starts = _byte_line_starts(source)
80
+ guarded_regions = _preprocessor_guard_regions(
81
+ source, translation_unit.source.project_relative_path
82
+ )
83
+
84
+ tree = self.parser.parse(source)
85
+ root = tree.root_node
86
+ syntax_cursor = self._QueryCursor(self._syntax_evidence_query)
87
+ syntax_captures = syntax_cursor.captures(root)
88
+ evidence_nodes = syntax_captures.get("node", ())
89
+ all_call_cursor = self._QueryCursor(self._all_call_query)
90
+ all_call_captures = all_call_cursor.captures(root)
91
+ all_calls = all_call_captures.get("call", ())
92
+ direct_call_cursor = self._QueryCursor(self._direct_call_query)
93
+ direct_call_captures = direct_call_cursor.captures(root)
94
+ direct_calls = direct_call_captures.get("call", ())
95
+ function_cursor = self._QueryCursor(self._function_query)
96
+ function_matches = function_cursor.matches(root)
97
+ call_detail_cursor = self._QueryCursor(self._call_detail_query)
98
+ call_matches = call_detail_cursor.matches(root)
99
+
100
+ gc_was_enabled = gc.isenabled()
101
+ if gc_was_enabled:
102
+ gc.disable()
103
+ raw_functions: list[tuple] = []
104
+ raw_calls: list[tuple] = []
105
+ raw_problems: list[tuple] = []
106
+ try:
107
+ node_types_set: set[str] = {root.type}
108
+ node_types_set.update(node.type for node in evidence_nodes)
109
+ node_types = frozenset(node_types_set)
110
+ indirect_call_seen = len(direct_calls) != len(all_calls)
111
+ has_error_or_missing = bool(root.has_error)
112
+ root_type = root.type
113
+
114
+ reasons: list[str] = []
115
+ if node_types.intersection({"type_definition", "struct_specifier", "field_expression"}):
116
+ reasons.append("TYPE_AMBIGUOUS")
117
+ if re.search(rb"\b__(?:[A-Za-z_]\w*)\b", source):
118
+ reasons.append("COMPILER_EXTENSION")
119
+ if re.search(rb"(?m)^\s*#\s*(?:define|if|ifdef|ifndef|elif|else|endif)\b", source):
120
+ reasons.append("PREPROCESSOR_SEMANTICS")
121
+ if re.search(rb"\(\s*\*\s*[A-Za-z_]\w*\s*\)\s*\(", source):
122
+ reasons.append("INDIRECT_CALL")
123
+ if indirect_call_seen:
124
+ reasons.append("INDIRECT_CALL")
125
+ eligibility = FastEligibility(not reasons, tuple(dict.fromkeys(reasons)))
126
+
127
+ fn_spans: list[tuple[int, int, str, str]] = []
128
+ for _pattern_index, captures in function_matches:
129
+ fn_node = captures["fn"][0]
130
+ name_node = captures["name"][0]
131
+ declarator = captures["decl"][0]
132
+ fn_start, fn_end = fn_node.start_byte, fn_node.end_byte
133
+ name_start, name_end = name_node.start_byte, name_node.end_byte
134
+ decl_start, decl_end = declarator.start_byte, declarator.end_byte
135
+ name = source[name_start:name_end].decode("utf-8", errors="replace")
136
+ prefix = source[fn_start:decl_start].decode("utf-8", errors="replace")
137
+ sm = re.search(r"\b(static|extern)\b", prefix)
138
+ storage = sm.group(1) if sm else None
139
+ type_qualifier = prefix.strip() or None
140
+ decl_text = source[decl_start:decl_end].decode("utf-8", errors="replace")
141
+ params = _parameter_names_from_declarator_text(decl_text)
142
+ name_line, name_col = _line_col_from_byte(name_start, line_starts)
143
+ qid = _function_id(
144
+ translation_unit, name, name_line, name_col, storage, type_qualifier
145
+ )
146
+ raw_functions.append(
147
+ (name, qid, storage, type_qualifier, params, fn_start, fn_end)
148
+ )
149
+ fn_spans.append((fn_start, fn_end, qid, name))
150
+
151
+ fn_starts = [x[0] for x in fn_spans]
152
+ for _pattern_index, captures in call_matches:
153
+ call_node = captures["call"][0]
154
+ target = captures["target"][0]
155
+ call_start, call_end = call_node.start_byte, call_node.end_byte
156
+ pos = bisect_right(fn_starts, call_start) - 1
157
+ if pos < 0 or call_end > fn_spans[pos][1]:
158
+ continue
159
+ _fs, _fe, caller_id, caller_name = fn_spans[pos]
160
+ callee = (
161
+ source[target.start_byte:target.end_byte].decode("utf-8", errors="replace")
162
+ if target.type == "identifier" else None
163
+ )
164
+ raw_calls.append(
165
+ (
166
+ caller_id, caller_name, callee,
167
+ source[call_start:call_end].decode("utf-8", errors="replace"),
168
+ call_start, call_end, callee is None,
169
+ _control_context(call_node, source),
170
+ )
171
+ )
172
+
173
+ if has_error_or_missing:
174
+ for node in _walk(root):
175
+ is_problem = (
176
+ node.type == "ERROR"
177
+ or bool(getattr(node, "is_error", False))
178
+ or bool(getattr(node, "is_missing", False))
179
+ )
180
+ if is_problem:
181
+ raw_problems.append(
182
+ (
183
+ node.start_byte, node.end_byte, node.type,
184
+ bool(getattr(node, "is_missing", False)),
185
+ source[node.start_byte:node.end_byte].decode("utf-8", errors="replace"),
186
+ )
187
+ )
188
+ finally:
189
+ captures = None
190
+ fn_node = name_node = declarator = call_node = target = node = None
191
+ call_matches = call_detail_cursor = None
192
+ function_matches = function_cursor = None
193
+ evidence_nodes = syntax_captures = syntax_cursor = None
194
+ all_calls = all_call_captures = all_call_cursor = None
195
+ direct_calls = direct_call_captures = direct_call_cursor = None
196
+ root = tree = None
197
+ if gc_was_enabled:
198
+ gc.enable()
199
+ if gc_was_enabled:
200
+ gc.collect()
201
+
202
+ functions = [
203
+ FunctionRecord(
204
+ name=name, qualified_id=qid,
205
+ source_path=translation_unit.source.project_relative_path,
206
+ source_range=_range_from_bytes(start, end, line_starts),
207
+ storage_class=storage, type_qualifier=type_qualifier, parameters=params,
208
+ )
209
+ for name, qid, storage, type_qualifier, params, start, end in raw_functions
210
+ ]
211
+ callsites = [
212
+ CallSiteRecord(
213
+ caller_id=caller_id, caller_name=caller_name, callee_name=callee,
214
+ raw_text=raw_text, source_path=translation_unit.source.project_relative_path,
215
+ source_range=_range_from_bytes(start, end, line_starts),
216
+ unknown_possible=unknown, control_context=context,
217
+ )
218
+ for caller_id, caller_name, callee, raw_text, start, end, unknown, context in raw_calls
219
+ ]
220
+ diagnostics: list[Diagnostic] = []
221
+ opaque_regions: list[SourceRange] = []
222
+ for start, end, node_type, missing, raw in raw_problems:
223
+ opaque_regions.append(_range_from_bytes(start, end, line_starts))
224
+ diagnostics.append(
225
+ Diagnostic(
226
+ severity="error",
227
+ message=f"tree-sitter syntax recovery node type={node_type} missing={missing}",
228
+ raw=raw,
229
+ )
230
+ )
231
+ status = (
232
+ ParseStatus.ERROR_RECOVERED if has_error_or_missing and (functions or callsites)
233
+ else ParseStatus.PARTIAL if has_error_or_missing
234
+ else ParseStatus.OK
235
+ )
236
+ artifact = FileParseArtifact(
237
+ translation_unit=translation_unit, status=status, functions=functions,
238
+ callsites=callsites, diagnostics=diagnostics, opaque_regions=opaque_regions,
239
+ guarded_regions=guarded_regions, parser_backend=self.name,
240
+ )
241
+ return TreeSitterPrepared(
242
+ source_bytes=source, node_types=node_types, eligibility=eligibility,
243
+ has_error_or_missing=has_error_or_missing, root_type=root_type, artifact=artifact,
244
+ )
245
+
246
+ def parse(self, translation_unit: TranslationUnit) -> FileParseArtifact:
247
+ prepared = self.prepare(translation_unit)
248
+ if not prepared.eligibility.eligible:
249
+ raise ValueError(
250
+ "semantic refinement required: " + ", ".join(prepared.eligibility.reasons)
251
+ )
252
+ return self.parse_prepared(translation_unit, prepared)
253
+
254
+ def parse_prepared(
255
+ self, translation_unit: TranslationUnit, prepared: TreeSitterPrepared
256
+ ) -> FileParseArtifact:
257
+ # ``prepare`` closes the native Tree-sitter lifetime boundary and stores
258
+ # only pure Python evidence/artifacts. No Node/Cursor crosses this API.
259
+ return prepared.artifact
260
+
261
+
262
+ def _walk(root: Any) -> Iterable[Any]:
263
+ # TreeCursor avoids materialising each node's full ``children`` tuple.
264
+ # That is both cheaper and avoids a Windows py-tree-sitter access-violation
265
+ # observed on large translation units with hundreds of function siblings.
266
+ cursor = root.walk()
267
+ while True:
268
+ yield cursor.node
269
+ if cursor.goto_first_child():
270
+ continue
271
+ if cursor.goto_next_sibling():
272
+ continue
273
+ while True:
274
+ if not cursor.goto_parent():
275
+ return
276
+ if cursor.goto_next_sibling():
277
+ break
278
+
279
+
280
+ def _text(source: bytes, node: Any | None) -> str:
281
+ if node is None:
282
+ return ""
283
+ return source[node.start_byte : node.end_byte].decode("utf-8", errors="replace")
284
+
285
+
286
+ def _range(node: Any) -> SourceRange:
287
+ return SourceRange(
288
+ node.start_point.row + 1,
289
+ node.start_point.column + 1,
290
+ node.end_point.row + 1,
291
+ node.end_point.column + 1,
292
+ )
293
+
294
+
295
+ def _declarator_identifier(node: Any | None) -> Any | None:
296
+ if node is None:
297
+ return None
298
+ if node.type == "identifier":
299
+ return node
300
+ # C declarator nesting is represented through a named `declarator` field.
301
+ child = node.child_by_field_name("declarator")
302
+ if child is not None:
303
+ found = _declarator_identifier(child)
304
+ if found is not None:
305
+ return found
306
+ # Conservative fallback for parenthesized/pointer declarators. Avoid the
307
+ # parameter list so parameter identifiers are not mistaken for function names.
308
+ for candidate in getattr(node, "named_children", ()) or ():
309
+ if candidate.type == "parameter_list":
310
+ continue
311
+ found = _declarator_identifier(candidate)
312
+ if found is not None:
313
+ return found
314
+ return None
315
+
316
+
317
+ def _parameter_names(source: bytes, declarator: Any | None) -> tuple[str, ...]:
318
+ if declarator is None:
319
+ return ()
320
+ parameter_list = next((n for n in _walk(declarator) if n.type == "parameter_list"), None)
321
+ if parameter_list is None:
322
+ return ()
323
+ out: list[str] = []
324
+ for child in getattr(parameter_list, "named_children", ()) or ():
325
+ if child.type != "parameter_declaration":
326
+ continue
327
+ decl = child.child_by_field_name("declarator")
328
+ ident = _declarator_identifier(decl)
329
+ if ident is not None:
330
+ out.append(_text(source, ident))
331
+ return tuple(out)
332
+
333
+
334
+ def _byte_line_starts(source: bytes) -> tuple[int, ...]:
335
+ starts = [0]
336
+ starts.extend(i + 1 for i, b in enumerate(source) if b == 10)
337
+ return tuple(starts)
338
+
339
+
340
+ def _line_col_from_byte(offset: int, line_starts: tuple[int, ...]) -> tuple[int, int]:
341
+ line_index = bisect_right(line_starts, offset) - 1
342
+ return line_index + 1, offset - line_starts[line_index] + 1
343
+
344
+
345
+ def _range_from_bytes(start: int, end: int, line_starts: tuple[int, ...]) -> SourceRange:
346
+ sl, sc = _line_col_from_byte(start, line_starts)
347
+ el, ec = _line_col_from_byte(end, line_starts)
348
+ return SourceRange(sl, sc, el, ec)
349
+
350
+
351
+ def _parameter_names_from_declarator_text(declarator: str) -> tuple[str, ...]:
352
+ left = declarator.find("(")
353
+ right = declarator.rfind(")")
354
+ if left < 0 or right <= left:
355
+ return ()
356
+ text = declarator[left + 1:right].strip()
357
+ if not text or text == "void":
358
+ return ()
359
+ names: list[str] = []
360
+ for part in text.split(","):
361
+ ids = re.findall(r"[A-Za-z_]\w*", part)
362
+ if not ids:
363
+ continue
364
+ candidate = ids[-1]
365
+ if candidate not in {"void", "char", "short", "int", "long", "float", "double", "signed", "unsigned", "const", "volatile", "struct", "union", "enum"}:
366
+ names.append(candidate)
367
+ return tuple(names)
368
+
369
+
370
+ def _storage_class(source: bytes, function_node: Any, declarator: Any | None = None) -> str | None:
371
+ # Avoid named_children enumeration here: Windows py-tree-sitter 0.26.0
372
+ # can access-violate on large sibling sets. Storage-class keywords occur
373
+ # before the function declarator, so the raw prefix is sufficient.
374
+ end = declarator.start_byte if declarator is not None else function_node.end_byte
375
+ prefix = source[function_node.start_byte:end].decode("utf-8", errors="replace")
376
+ match = re.search(r"\b(static|extern)\b", prefix)
377
+ return match.group(1) if match else None
378
+
379
+
380
+ def _type_qualifier(source: bytes, function_node: Any, declarator: Any | None) -> str | None:
381
+ if declarator is None:
382
+ return None
383
+ prefix = source[function_node.start_byte : declarator.start_byte].decode("utf-8", errors="replace").strip()
384
+ return prefix or None
385
+
386
+
387
+ def _function_id(
388
+ tu: TranslationUnit,
389
+ name: str,
390
+ line: int,
391
+ col: int,
392
+ storage: str | None,
393
+ qual_type: str | None,
394
+ ) -> str:
395
+ return f"{tu.source.project_relative_path}:{line}:{col}:{storage or 'external'}:{name}:{qual_type or '?'}"
396
+
397
+
398
+ def _control_context(node: Any, source: bytes) -> tuple[str, ...]:
399
+ contexts: list[str] = []
400
+ child = node
401
+ parent = getattr(node, "parent", None)
402
+ while parent is not None and parent.type != "function_definition":
403
+ if parent.type == "if_statement":
404
+ condition = parent.child_by_field_name("condition")
405
+ cond = " ".join(_text(source, condition).split()) if condition is not None else ""
406
+ base = f"IF:if{cond}" if cond.startswith("(") else f"IF:if({cond})"
407
+ alt = parent.child_by_field_name("alternative")
408
+ branch = "/else" if alt is not None and _node_contains(alt, child) else "/then"
409
+ contexts.append(base + branch)
410
+ elif parent.type in {"while_statement", "for_statement", "do_statement"}:
411
+ raw = " ".join(_text(source, parent).split())
412
+ condition = parent.child_by_field_name("condition")
413
+ if parent.type == "while_statement":
414
+ cond = " ".join(_text(source, condition).split()) if condition is not None else ""
415
+ header = f"while {cond}" if cond.startswith("(") else f"while ({cond})"
416
+ compact = "".join(header.split()).lower()
417
+ contexts.append("LOOP:" + (("[infinite] " if compact in {"while(1)", "while(true)"} else "") + header))
418
+ elif parent.type == "for_statement":
419
+ header = raw.split("{",1)[0].strip()
420
+ compact = "".join(header.split()).lower()
421
+ contexts.append("LOOP:" + (("[infinite] " if compact == "for(;;)" else "") + header))
422
+ else:
423
+ compact = "".join(raw.split()).lower()
424
+ infinite = compact.endswith("while(1);") or compact.endswith("while(true);")
425
+ contexts.append("LOOP:" + (("[infinite] " if infinite else "") + ("do/while(1)" if infinite else "do/while")))
426
+ elif parent.type == "case_statement":
427
+ labels = _tree_sitter_case_labels(parent, source)
428
+ contexts.append("DEFAULT" if labels == ["DEFAULT"] else "CASE:" + "|".join(labels))
429
+ elif parent.type == "switch_statement":
430
+ condition = parent.child_by_field_name("condition")
431
+ cond = " ".join(_text(source, condition).split()) if condition is not None else ""
432
+ contexts.append(f"SWITCH:switch{cond}" if cond.startswith("(") else f"SWITCH:switch({cond})")
433
+ child = parent
434
+ parent = getattr(parent, "parent", None)
435
+ contexts.reverse()
436
+ return tuple(contexts)
437
+
438
+
439
+ def _node_contains(ancestor: Any, node: Any) -> bool:
440
+ return ancestor.start_byte <= node.start_byte and node.end_byte <= ancestor.end_byte
441
+
442
+
443
+ def _tree_sitter_case_labels(case_node: Any, source: bytes) -> list[str]:
444
+ value = case_node.child_by_field_name("value")
445
+ if value is None:
446
+ return ["DEFAULT"]
447
+ labels = [_text(source, value).strip() or "?"]
448
+ parent = getattr(case_node, "parent", None)
449
+ if parent is None:
450
+ return labels
451
+ named = list(getattr(parent, "named_children", ()))
452
+ try:
453
+ pos = named.index(case_node)
454
+ except ValueError:
455
+ return labels
456
+ i = pos - 1
457
+ prefix: list[str] = []
458
+ while i >= 0 and named[i].type == "case_statement":
459
+ prev = named[i]
460
+ prev_value = prev.child_by_field_name("value")
461
+ # A label-only case has no named child other than its value. It falls
462
+ # through to the current case and therefore represents an alternative.
463
+ if prev_value is None or len(getattr(prev, "named_children", ())) != 1:
464
+ break
465
+ prefix.append(_text(source, prev_value).strip() or "?")
466
+ i -= 1
467
+ prefix.reverse()
468
+ return prefix + labels
cseq/valueflow.py ADDED
@@ -0,0 +1,179 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import replace
4
+
5
+ from .model import (
6
+ CallSiteRecord,
7
+ CallTargetKind,
8
+ FlowEdge,
9
+ FlowEdgeKind,
10
+ FunctionRecord,
11
+ ProjectIndex,
12
+ ValueSlotKind,
13
+ )
14
+
15
+
16
+ def bind_direct_call_arguments(index: ProjectIndex) -> None:
17
+ target_calls = [
18
+ c for c in index.callsites
19
+ if c.target_kind == CallTargetKind.INTERNAL_EXACT and c.target_function_id
20
+ ]
21
+ if not target_calls:
22
+ return
23
+
24
+ # Demand-driven: only callees of exact direct calls need parameter metadata.
25
+ # Avoid a project-wide qualified-id dictionary on million-function projects.
26
+ needed_names = {c.callee_name for c in target_calls if c.callee_name}
27
+ needed_ids = {c.target_function_id for c in target_calls if c.target_function_id}
28
+ functions_by_id = {}
29
+ for f in index.functions_named(needed_names):
30
+ if f.name in needed_names and f.qualified_id in needed_ids:
31
+ functions_by_id[f.qualified_id] = f
32
+ if len(functions_by_id) == len(needed_ids):
33
+ break
34
+
35
+ existing = {(e.source_key, e.target_key, e.kind) for e in index.flow_edges}
36
+ new_edges: list[FlowEdge] = []
37
+
38
+ for call in target_calls:
39
+ target = functions_by_id.get(call.target_function_id)
40
+ if not target:
41
+ continue
42
+ for arg_slot, param_name in zip(call.argument_slot_keys, target.parameters):
43
+ if not arg_slot:
44
+ continue
45
+ param_key = f"{target.qualified_id}::param::{param_name}"
46
+ key = (arg_slot, param_key, FlowEdgeKind.ARG_TO_PARAM)
47
+ if key not in existing:
48
+ new_edges.append(
49
+ FlowEdge(
50
+ arg_slot,
51
+ param_key,
52
+ FlowEdgeKind.ARG_TO_PARAM,
53
+ call.source_path,
54
+ call.source_range,
55
+ )
56
+ )
57
+ existing.add(key)
58
+ index.flow_edges.extend(new_edges)
59
+
60
+
61
+ def resolve_indirect_calls(index: ProjectIndex) -> None:
62
+ """Resolve indirect calls with a small demand-driven ValueSlot solver.
63
+
64
+ The PoC supports:
65
+ * local function-pointer assignments/initializers
66
+ * conditional initializers (multiple function sources)
67
+ * struct-field callback assignment propagated through direct-call args
68
+ * global callback arrays, exact and unknown indices
69
+
70
+ Unknown sources remain representable and can be extended later without
71
+ changing the CallSite model.
72
+ """
73
+
74
+ indirect_calls = [
75
+ c for c in index.callsites
76
+ if c.callee_slot_key is not None and c.target_kind == CallTargetKind.UNKNOWN
77
+ ]
78
+ if not indirect_calls:
79
+ return
80
+
81
+ needed_function_names = {
82
+ edge.source_key.split("::", 1)[1]
83
+ for edge in index.flow_edges
84
+ if edge.source_key.startswith("function::")
85
+ }
86
+ function_by_name: dict[str, list[FunctionRecord]] = {}
87
+ if needed_function_names:
88
+ for fn in index.functions_named(needed_function_names):
89
+ if fn.name in needed_function_names:
90
+ function_by_name.setdefault(fn.name, []).append(fn)
91
+
92
+ incoming: dict[str, list[FlowEdge]] = {}
93
+ for edge in index.flow_edges:
94
+ incoming.setdefault(edge.target_key, []).append(edge)
95
+
96
+ slot_kind = {slot.key: slot.kind for slot in index.value_slots}
97
+
98
+ def resolve_slot(slot_key: str, seen: set[str] | None = None) -> tuple[set[str], bool]:
99
+ seen = set() if seen is None else set(seen)
100
+ if slot_key in seen:
101
+ return set(), True
102
+ seen.add(slot_key)
103
+
104
+ targets: set[str] = set()
105
+ unknown = False
106
+ had_evidence = False
107
+
108
+ for edge in incoming.get(slot_key, []):
109
+ had_evidence = True
110
+ if edge.source_key.startswith("function::"):
111
+ name = edge.source_key.split("::", 1)[1]
112
+ matches = function_by_name.get(name, [])
113
+ if matches:
114
+ targets.update(f.qualified_id for f in matches)
115
+ else:
116
+ unknown = True
117
+ else:
118
+ sub_targets, sub_unknown = resolve_slot(edge.source_key, seen)
119
+ targets.update(sub_targets)
120
+ unknown |= sub_unknown
121
+
122
+ # Field-sensitive propagation through object/parameter alias bindings.
123
+ # Example: main::local::h.cb -> dispatch::param::h.cb, where the base
124
+ # object binding main::local::h -> dispatch::param::h is ARG_TO_PARAM.
125
+ if "." in slot_key:
126
+ base_key, field = slot_key.rsplit(".", 1)
127
+ for edge in incoming.get(base_key, []):
128
+ if edge.kind not in {FlowEdgeKind.ARG_TO_PARAM, FlowEdgeKind.ASSIGN, FlowEdgeKind.INIT}:
129
+ continue
130
+ # An allocation-site edge records provenance of the base pointer;
131
+ # it is not another named object whose field should be recursively
132
+ # read. Actual field writes through aliases are handled by the named
133
+ # pointer/object edges.
134
+ if slot_kind.get(edge.source_key) == ValueSlotKind.HEAP_ALLOCATION:
135
+ continue
136
+ had_evidence = True
137
+ aliased_field = f"{edge.source_key}.{field}"
138
+ sub_targets, sub_unknown = resolve_slot(aliased_field, seen)
139
+ targets.update(sub_targets)
140
+ unknown |= sub_unknown
141
+
142
+ # No evidence for a non-function slot means unknown remains possible.
143
+ if not had_evidence and slot_kind.get(slot_key) not in {
144
+ ValueSlotKind.GLOBAL_ARRAY_ELEMENT,
145
+ ValueSlotKind.GLOBAL_ARRAY_SUMMARY,
146
+ }:
147
+ unknown = True
148
+ return targets, unknown
149
+
150
+ resolved_calls: list[CallSiteRecord] = []
151
+ for call in index.callsites:
152
+ if call.callee_slot_key is None or call.target_kind != CallTargetKind.UNKNOWN:
153
+ resolved_calls.append(call)
154
+ continue
155
+ targets, unknown = resolve_slot(call.callee_slot_key)
156
+ ordered = tuple(sorted(targets))
157
+ if len(ordered) == 1 and not unknown:
158
+ resolved_calls.append(
159
+ replace(
160
+ call,
161
+ target_kind=CallTargetKind.INDIRECT_EXACT,
162
+ target_function_id=ordered[0],
163
+ target_function_ids=ordered,
164
+ unknown_possible=False,
165
+ )
166
+ )
167
+ elif ordered:
168
+ resolved_calls.append(
169
+ replace(
170
+ call,
171
+ target_kind=CallTargetKind.INDIRECT_POSSIBLE,
172
+ target_function_id=None,
173
+ target_function_ids=ordered,
174
+ unknown_possible=unknown,
175
+ )
176
+ )
177
+ else:
178
+ resolved_calls.append(replace(call, unknown_possible=True))
179
+ index.callsites[:] = resolved_calls