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.
cseq/parser.py ADDED
@@ -0,0 +1,797 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ import shutil
6
+ import subprocess
7
+ from abc import ABC, abstractmethod
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from .model import (
12
+ CallSiteRecord,
13
+ Diagnostic,
14
+ FileParseArtifact,
15
+ FlowEdge,
16
+ FlowEdgeKind,
17
+ FunctionRecord,
18
+ GuardedSourceRegion,
19
+ ParseStatus,
20
+ SourceRange,
21
+ TranslationUnit,
22
+ ValueSlot,
23
+ ValueSlotKind,
24
+ )
25
+
26
+
27
+ class ParserBackend(ABC):
28
+ name: str
29
+
30
+ @abstractmethod
31
+ def parse(self, translation_unit: TranslationUnit) -> FileParseArtifact:
32
+ raise NotImplementedError
33
+
34
+
35
+ class ClangJsonParser(ParserBackend):
36
+ """PoC parser backend using clang's JSON AST dump.
37
+
38
+ The backend boundary is intentional: a Tree-sitter fast parser can be added
39
+ later without changing ProjectIndex / Program IR consumers.
40
+ """
41
+
42
+ name = "clang-json"
43
+
44
+ def __init__(self, clang_executable: str | None = None) -> None:
45
+ exe = clang_executable or shutil.which("clang")
46
+ if not exe:
47
+ raise RuntimeError("clang executable was not found")
48
+ self.clang_executable = exe
49
+
50
+ def parse(self, translation_unit: TranslationUnit) -> FileParseArtifact:
51
+ source = translation_unit.source.path
52
+ cmd = [
53
+ self.clang_executable,
54
+ "-Xclang", "-ast-dump=json",
55
+ "-fsyntax-only", "-Wno-everything",
56
+ *translation_unit.arguments,
57
+ str(source),
58
+ ]
59
+ proc = subprocess.run(
60
+ cmd,
61
+ cwd=translation_unit.working_directory,
62
+ stdout=subprocess.PIPE,
63
+ stderr=subprocess.PIPE,
64
+ text=True,
65
+ encoding="utf-8",
66
+ errors="replace",
67
+ check=False,
68
+ )
69
+ diagnostics = _parse_clang_diagnostics(proc.stderr)
70
+ opaque_regions = _opaque_regions_from_clang_diagnostics(proc.stderr)
71
+ guarded_regions = _preprocessor_guard_regions(source.read_bytes(), translation_unit.source.project_relative_path)
72
+ root = _load_ast_prefix(proc.stdout)
73
+ functions: list[FunctionRecord] = []
74
+ callsites: list[CallSiteRecord] = []
75
+ value_slots: list[ValueSlot] = []
76
+ flow_edges: list[FlowEdge] = []
77
+ if root is not None:
78
+ _collect_functions(root, translation_unit, functions)
79
+ callsites, value_slots, flow_edges = collect_program_facts(root, translation_unit, functions)
80
+
81
+ if proc.returncode == 0 and root is not None:
82
+ status = ParseStatus.OK
83
+ elif root is not None and functions:
84
+ status = ParseStatus.ERROR_RECOVERED
85
+ elif root is not None:
86
+ status = ParseStatus.PARTIAL
87
+ else:
88
+ status = ParseStatus.OPAQUE_REGION
89
+
90
+ return FileParseArtifact(
91
+ translation_unit=translation_unit,
92
+ status=status,
93
+ functions=functions,
94
+ callsites=callsites,
95
+ value_slots=value_slots,
96
+ flow_edges=flow_edges,
97
+ diagnostics=diagnostics,
98
+ opaque_regions=opaque_regions,
99
+ guarded_regions=guarded_regions,
100
+ parser_backend=self.name,
101
+ )
102
+
103
+
104
+ def _load_ast_prefix(stdout: str) -> dict[str, Any] | None:
105
+ if not stdout.strip():
106
+ return None
107
+ try:
108
+ value = json.loads(stdout)
109
+ except json.JSONDecodeError:
110
+ return None
111
+ return value if isinstance(value, dict) else None
112
+
113
+
114
+
115
+ def _opaque_regions_from_clang_diagnostics(stderr: str) -> list[SourceRange]:
116
+ """Convert hard compiler diagnostics into explicit uncertain source ranges.
117
+
118
+ Clang often still emits a useful partial AST after an error. The range is
119
+ intentionally narrow (diagnostic line/column) so callers can keep all other
120
+ recovered facts while still showing that this location is not trustworthy.
121
+ """
122
+ rx = re.compile(r"^(.*?):(\d+):(\d+):\s*(fatal error|error):", re.MULTILINE)
123
+ seen: set[tuple[int, int]] = set()
124
+ out: list[SourceRange] = []
125
+ for m in rx.finditer(stderr):
126
+ line, col = int(m.group(2)), int(m.group(3))
127
+ key = (line, col)
128
+ if key in seen:
129
+ continue
130
+ seen.add(key)
131
+ out.append(SourceRange(line, col, line, col))
132
+ return out
133
+
134
+ def _parse_clang_diagnostics(stderr: str) -> list[Diagnostic]:
135
+ result: list[Diagnostic] = []
136
+ rx = re.compile(r"\b(fatal error|error|warning|note):\s*(.*)$")
137
+ for raw in stderr.splitlines():
138
+ m = rx.search(raw)
139
+ if not m:
140
+ continue
141
+ sev = m.group(1).replace("fatal error", "fatal")
142
+ result.append(Diagnostic(severity=sev, message=m.group(2).strip(), raw=raw))
143
+ return result
144
+
145
+
146
+ def _location_entry(location: dict[str, Any] | None) -> dict[str, Any]:
147
+ """Return the effective source location from Clang JSON location variants."""
148
+ if not isinstance(location, dict):
149
+ return {}
150
+ expansion = location.get("expansionLoc")
151
+ if isinstance(expansion, dict):
152
+ return expansion
153
+ spelling = location.get("spellingLoc")
154
+ if isinstance(spelling, dict):
155
+ return spelling
156
+ return location
157
+
158
+
159
+ def _same_path(a: str, b: Path, working_directory: Path) -> bool:
160
+ try:
161
+ pa = Path(a)
162
+ if not pa.is_absolute():
163
+ pa = working_directory / pa
164
+ return pa.resolve() == b.resolve()
165
+ except (OSError, RuntimeError, ValueError):
166
+ return Path(a).name.lower() == b.name.lower()
167
+
168
+
169
+ def _node_belongs_to_translation_unit(node: dict[str, Any], tu: TranslationUnit) -> bool:
170
+ """Exclude definitions physically coming from included headers.
171
+
172
+ Clang may omit ``file`` for consecutive locations in the primary source, so
173
+ absence of ``file`` is accepted only when there is no ``includedFrom`` chain.
174
+ Macro locations prefer expansionLoc, which represents the user's call site.
175
+ """
176
+ loc = _location_entry(node.get("loc"))
177
+ rng = node.get("range") or {}
178
+ begin = _location_entry(rng.get("begin"))
179
+ candidates = [x for x in (loc, begin) if isinstance(x, dict) and x]
180
+ saw_primary_without_file = False
181
+ for entry in candidates:
182
+ file_name = entry.get("file")
183
+ if file_name:
184
+ if _same_path(str(file_name), tu.source.path, tu.working_directory):
185
+ return True
186
+ return False
187
+ if entry.get("includedFrom"):
188
+ return False
189
+ saw_primary_without_file = True
190
+ return saw_primary_without_file
191
+
192
+
193
+ def _collect_functions(node: dict[str, Any], tu: TranslationUnit, out: list[FunctionRecord]) -> None:
194
+ if node.get("kind") == "FunctionDecl" and _has_body(node) and _node_belongs_to_translation_unit(node, tu):
195
+ loc = node.get("loc") or {}
196
+ rng = node.get("range") or {}
197
+ begin = rng.get("begin") or loc
198
+ end = rng.get("end") or loc
199
+ start_line = _int_or_none(begin.get("line")) or _int_or_none(loc.get("line"))
200
+ start_col = _int_or_none(begin.get("col")) or _int_or_none(loc.get("col"))
201
+ name = str(node.get("name") or "<anonymous>")
202
+ storage = node.get("storageClass")
203
+ type_info = node.get("type") or {}
204
+ params = tuple(
205
+ str(c.get("name"))
206
+ for c in node.get("inner") or ()
207
+ if isinstance(c, dict) and c.get("kind") == "ParmVarDecl" and c.get("name")
208
+ )
209
+ out.append(FunctionRecord(
210
+ name=name,
211
+ qualified_id=_function_id(tu, name, start_line, start_col, storage, type_info.get("qualType")),
212
+ source_path=tu.source.project_relative_path,
213
+ source_range=SourceRange(start_line, start_col, _int_or_none(end.get("line")), _int_or_none(end.get("col"))),
214
+ storage_class=str(storage) if storage else None,
215
+ type_qualifier=str(type_info.get("qualType")) if type_info.get("qualType") else None,
216
+ parameters=params,
217
+ ))
218
+ for child in node.get("inner") or ():
219
+ if isinstance(child, dict):
220
+ _collect_functions(child, tu, out)
221
+
222
+
223
+ def collect_program_facts(root: dict[str, Any], tu: TranslationUnit, functions: list[FunctionRecord]) -> tuple[list[CallSiteRecord], list[ValueSlot], list[FlowEdge]]:
224
+ source_bytes = tu.source.path.read_bytes()
225
+ guard_by_line = _preprocessor_guard_map(source_bytes)
226
+ calls: list[CallSiteRecord] = []
227
+ slots: dict[str, ValueSlot] = {}
228
+ edges: list[FlowEdge] = []
229
+ funcs_by_name = {f.name: f for f in functions}
230
+
231
+ def add_slot(slot: ValueSlot | None) -> str | None:
232
+ if slot is None:
233
+ return None
234
+ slots.setdefault(slot.key, slot)
235
+ return slot.key
236
+
237
+ def add_function_source(name: str) -> str:
238
+ key = f"function::{name}"
239
+ slots.setdefault(key, ValueSlot(key, ValueSlotKind.FUNCTION, name))
240
+ return key
241
+
242
+ def add_sources_to_target(expr: dict[str, Any] | None, target: ValueSlot | None, kind: FlowEdgeKind, current: FunctionRecord | None) -> None:
243
+ target_key = add_slot(target)
244
+ if not target_key or not expr:
245
+ return
246
+ fn_names = _expr_function_names(expr)
247
+ for fn in sorted(fn_names):
248
+ edges.append(FlowEdge(add_function_source(fn), target_key, kind, tu.source.project_relative_path, _range(expr)))
249
+ src_slot = _expr_slot(expr, current, tu)
250
+ src_key = add_slot(src_slot)
251
+ if src_key and src_key != target_key:
252
+ edges.append(FlowEdge(src_key, target_key, kind, tu.source.project_relative_path, _range(expr)))
253
+
254
+ # Global array initializers first.
255
+ for node in _walk_nodes(root):
256
+ if node.get("kind") != "VarDecl" or not _is_global_var(node, root):
257
+ continue
258
+ name = node.get("name")
259
+ if not name:
260
+ continue
261
+ init = next((c for c in node.get("inner") or () if isinstance(c, dict) and c.get("kind") == "InitListExpr"), None)
262
+ if not init:
263
+ continue
264
+ for idx, elem in enumerate(init.get("inner") or ()):
265
+ if not isinstance(elem, dict):
266
+ continue
267
+ exact = ValueSlot(f"global::{name}[{idx}]", ValueSlotKind.GLOBAL_ARRAY_ELEMENT, str(name), index=idx)
268
+ summary = ValueSlot(f"global::{name}[*]", ValueSlotKind.GLOBAL_ARRAY_SUMMARY, str(name))
269
+ add_sources_to_target(elem, exact, FlowEdgeKind.INIT, None)
270
+ add_sources_to_target(elem, summary, FlowEdgeKind.INIT, None)
271
+
272
+ def walk(node: dict[str, Any], current: FunctionRecord | None, control_context: tuple[str, ...] = ()) -> None:
273
+ next_current = current
274
+ next_context = control_context
275
+ kind = node.get("kind")
276
+ control = None if kind == "IfStmt" else _control_context_label(node, source_bytes)
277
+ if control is not None:
278
+ next_context = (*control_context, control)
279
+ if node.get("kind") == "FunctionDecl" and _has_body(node):
280
+ if not _node_belongs_to_translation_unit(node, tu):
281
+ return
282
+ name = str(node.get("name") or "<anonymous>")
283
+ loc = _location_entry(node.get("loc"))
284
+ line = _int_or_none(loc.get("line"))
285
+ next_current = next((f for f in functions if f.name == name and (line is None or f.source_range.start_line == line)), current)
286
+ if next_current:
287
+ for param in node.get("inner") or ():
288
+ if isinstance(param, dict) and param.get("kind") == "ParmVarDecl" and param.get("name"):
289
+ add_slot(_decl_slot(param, next_current, tu))
290
+
291
+ if next_current is not None:
292
+ if node.get("kind") == "VarDecl" and node.get("name"):
293
+ target = _decl_slot(node, next_current, tu)
294
+ add_slot(target)
295
+ init = next((c for c in node.get("inner") or () if isinstance(c, dict)), None)
296
+ if init:
297
+ add_sources_to_target(init, target, FlowEdgeKind.INIT, next_current)
298
+
299
+ if node.get("kind") == "BinaryOperator" and node.get("opcode") == "=":
300
+ inner = [c for c in node.get("inner") or () if isinstance(c, dict)]
301
+ if len(inner) >= 2:
302
+ target = _expr_slot(inner[0], next_current, tu)
303
+ add_sources_to_target(inner[1], target, FlowEdgeKind.ASSIGN, next_current)
304
+
305
+ if node.get("kind") == "CallExpr":
306
+ inner = [c for c in node.get("inner") or () if isinstance(c, dict)]
307
+ callee_expr = inner[0] if inner else None
308
+ direct = _direct_callee_name(node)
309
+ callee_slot = None if direct else _expr_slot(callee_expr, next_current, tu)
310
+ callee_slot_key = add_slot(callee_slot)
311
+ arg_slots: list[str | None] = []
312
+ for arg in inner[1:]:
313
+ arg_slots.append(add_slot(_argument_object_slot(arg, next_current, tu)))
314
+ calls.append(CallSiteRecord(
315
+ caller_id=next_current.qualified_id,
316
+ caller_name=next_current.name,
317
+ callee_name=direct,
318
+ raw_text=_raw_text(node, source_bytes),
319
+ source_path=tu.source.project_relative_path,
320
+ source_range=_range(node, source_bytes),
321
+ callee_slot_key=callee_slot_key,
322
+ argument_slot_keys=tuple(arg_slots),
323
+ control_context=next_context,
324
+ macro_expanded=_is_macro_expanded(node),
325
+ spelling_range=_range_with_loc_kind(node, "spellingLoc"),
326
+ expansion_range=_range_with_loc_kind(node, "expansionLoc"),
327
+ guard_expression=guard_by_line.get((_range(node, source_bytes).start_line or 0)),
328
+ ))
329
+
330
+ children = [c for c in node.get("inner") or () if isinstance(c, dict)]
331
+ if kind == "IfStmt":
332
+ raw_if = _raw_text(node, source_bytes).replace("\n", " ").strip()
333
+ if len(raw_if) > 80:
334
+ raw_if = raw_if[:77] + "..."
335
+ base = "if:" + raw_if
336
+ for i, child in enumerate(children):
337
+ if i == 1:
338
+ child_ctx = (*control_context, base + "/then")
339
+ elif i >= 2:
340
+ child_ctx = (*control_context, base + "/else")
341
+ else:
342
+ child_ctx = control_context
343
+ walk(child, next_current, child_ctx)
344
+ return
345
+ if kind == "CaseStmt":
346
+ labels, body = _case_chain(node, source_bytes)
347
+ label = "|".join(labels) if labels else "?"
348
+ if body is not None:
349
+ walk(body, next_current, (*next_context, f"CASE:{label}"))
350
+ return
351
+ if kind == "DefaultStmt":
352
+ for child in children:
353
+ walk(child, next_current, (*next_context, "DEFAULT"))
354
+ return
355
+ for child in children:
356
+ walk(child, next_current, next_context)
357
+
358
+ walk(root, None, ())
359
+ return calls, list(slots.values()), edges
360
+
361
+
362
+
363
+
364
+ def _case_chain(node: dict[str, Any], source_bytes: bytes) -> tuple[list[str], dict[str, Any] | None]:
365
+ """Return consecutive case labels and the first non-CaseStmt body.
366
+
367
+ Clang represents fall-through labels such as ``case 2: case 3: body`` as
368
+ nested CaseStmt nodes. Treating the nesting as conjunction would be wrong:
369
+ the body is reachable from either label. We therefore collapse the label
370
+ chain into one alternative region (``CASE:2|3``).
371
+ """
372
+ labels: list[str] = []
373
+ current: dict[str, Any] | None = node
374
+ while isinstance(current, dict) and current.get("kind") == "CaseStmt":
375
+ children = [c for c in current.get("inner") or () if isinstance(c, dict)]
376
+ if not children:
377
+ return labels, None
378
+ label_node = children[0]
379
+ label = _raw_text(label_node, source_bytes).strip() or "?"
380
+ labels.append(label)
381
+ body = children[1] if len(children) >= 2 else None
382
+ if isinstance(body, dict) and body.get("kind") == "CaseStmt":
383
+ current = body
384
+ continue
385
+ return labels, body
386
+ return labels, current
387
+
388
+ def _control_context_label(node: dict[str, Any], source_bytes: bytes) -> str | None:
389
+ kind = node.get("kind")
390
+ if kind not in {"IfStmt", "SwitchStmt", "ForStmt", "WhileStmt", "DoStmt"}:
391
+ return None
392
+ raw = _raw_text(node, source_bytes).replace("\n", " ").strip()
393
+ if kind == "IfStmt":
394
+ return f"IF:{_header_expression(raw, 'if')}"
395
+ if kind == "SwitchStmt":
396
+ return f"SWITCH:{_header_expression(raw, 'switch')}"
397
+ if kind == "ForStmt":
398
+ header = _header_expression(raw, "for")
399
+ compact = "".join(header.split())
400
+ return "LOOP:" + (("[infinite] " if compact == "for(;;)" else "") + header)
401
+ if kind == "WhileStmt":
402
+ header = _header_expression(raw, "while")
403
+ compact = "".join(header.split())
404
+ return "LOOP:" + (("[infinite] " if compact in {"while(1)", "while(true)"} else "") + header)
405
+ if kind == "DoStmt":
406
+ compact = "".join(raw.split())
407
+ infinite = compact.endswith("while(1)") or compact.endswith("while(1);") or compact.endswith("while(true)") or compact.endswith("while(true);")
408
+ return "LOOP:" + (("[infinite] " if infinite else "") + ("do/while(1)" if infinite else "do/while"))
409
+ return None
410
+
411
+
412
+ def _header_expression(raw: str, keyword: str) -> str:
413
+ pos = raw.find("(")
414
+ if pos < 0:
415
+ return keyword
416
+ depth = 0
417
+ for i in range(pos, len(raw)):
418
+ ch = raw[i]
419
+ if ch == "(": depth += 1
420
+ elif ch == ")":
421
+ depth -= 1
422
+ if depth == 0: return raw[: i + 1].strip()
423
+ return raw[:120].strip()
424
+
425
+
426
+ def _walk_nodes(node: dict[str, Any]):
427
+ yield node
428
+ for child in node.get("inner") or ():
429
+ if isinstance(child, dict):
430
+ yield from _walk_nodes(child)
431
+
432
+
433
+ def _is_global_var(node: dict[str, Any], root: dict[str, Any]) -> bool:
434
+ # Clang top-level VarDecls appear directly below TranslationUnitDecl.
435
+ return any(child is node for child in root.get("inner") or ())
436
+
437
+
438
+ def _expr_function_names(node: dict[str, Any] | None) -> set[str]:
439
+ out: set[str] = set()
440
+ if not isinstance(node, dict):
441
+ return out
442
+ if node.get("kind") == "DeclRefExpr":
443
+ ref = node.get("referencedDecl") or {}
444
+ if ref.get("kind") == "FunctionDecl" and ref.get("name"):
445
+ out.add(str(ref["name"]))
446
+ for child in node.get("inner") or ():
447
+ if isinstance(child, dict):
448
+ out |= _expr_function_names(child)
449
+ return out
450
+
451
+
452
+ def _expr_slot(node: dict[str, Any] | None, current: FunctionRecord | None, tu: TranslationUnit) -> ValueSlot | None:
453
+ if not isinstance(node, dict):
454
+ return None
455
+ kind = node.get("kind")
456
+ if kind in {"ImplicitCastExpr", "ParenExpr", "CStyleCastExpr", "UnaryOperator"}:
457
+ inner = [c for c in node.get("inner") or () if isinstance(c, dict)]
458
+ if inner:
459
+ return _expr_slot(inner[0], current, tu)
460
+ if kind == "DeclRefExpr":
461
+ ref = node.get("referencedDecl") or {}
462
+ if ref.get("kind") in {"VarDecl", "ParmVarDecl"} and ref.get("name"):
463
+ return _decl_ref_slot(ref, current, tu)
464
+ if kind == "MemberExpr" and node.get("name"):
465
+ inner = [c for c in node.get("inner") or () if isinstance(c, dict)]
466
+ base_node = inner[0] if inner else None
467
+ base = _expr_slot(base_node, current, tu) if base_node else None
468
+ if base:
469
+ field = str(node["name"])
470
+ if _is_union_typed(base_node):
471
+ return ValueSlot(
472
+ f"{base.key}.<union>", ValueSlotKind.UNION_SUMMARY,
473
+ f"{base.name}.<union>", base.function_id, field_name="<union>",
474
+ )
475
+ return ValueSlot(f"{base.key}.{field}", ValueSlotKind.STRUCT_FIELD, f"{base.name}.{field}", base.function_id, field_name=field)
476
+ if kind == "CallExpr" and current is not None:
477
+ allocator = _direct_callee_name(node)
478
+ if allocator in {"malloc", "calloc", "realloc", "aligned_alloc"}:
479
+ rng = _range(node)
480
+ where = f"{tu.source.project_relative_path}:{rng.start_line or '?'}:{rng.start_column or '?'}"
481
+ key = f"heap::{current.qualified_id}::{where}:{allocator}"
482
+ return ValueSlot(key, ValueSlotKind.HEAP_ALLOCATION, f"{allocator}@{where}", current.qualified_id)
483
+ if kind == "ArraySubscriptExpr":
484
+ inner = [c for c in node.get("inner") or () if isinstance(c, dict)]
485
+ if len(inner) >= 2:
486
+ base_name = _declref_name(inner[0])
487
+ if base_name:
488
+ idx = _integer_value(inner[1])
489
+ if idx is not None:
490
+ return ValueSlot(f"global::{base_name}[{idx}]", ValueSlotKind.GLOBAL_ARRAY_ELEMENT, base_name, index=idx)
491
+ return ValueSlot(f"global::{base_name}[*]", ValueSlotKind.GLOBAL_ARRAY_SUMMARY, base_name)
492
+ return None
493
+
494
+
495
+ def _is_union_typed(node: dict[str, Any] | None) -> bool:
496
+ if not isinstance(node, dict):
497
+ return False
498
+ type_info = node.get("type") or {}
499
+ qual = str(type_info.get("qualType") or type_info.get("desugaredQualType") or "")
500
+ if "union " in qual:
501
+ return True
502
+ # Cast/paren nodes may carry a pointer type while the inner DeclRef has the
503
+ # actual union spelling, so inspect their first child conservatively.
504
+ for child in node.get("inner") or ():
505
+ if isinstance(child, dict) and _is_union_typed(child):
506
+ return True
507
+ return False
508
+
509
+
510
+ def _argument_object_slot(node: dict[str, Any], current: FunctionRecord, tu: TranslationUnit) -> ValueSlot | None:
511
+ # &h and h are both treated as the same abstract object for the PoC alias binding.
512
+ if node.get("kind") == "UnaryOperator" and node.get("opcode") == "&":
513
+ inner = [c for c in node.get("inner") or () if isinstance(c, dict)]
514
+ return _expr_slot(inner[0], current, tu) if inner else None
515
+ return _expr_slot(node, current, tu)
516
+
517
+
518
+ def _decl_slot(node: dict[str, Any], current: FunctionRecord, tu: TranslationUnit) -> ValueSlot | None:
519
+ name = node.get("name")
520
+ if not name:
521
+ return None
522
+ if node.get("kind") == "ParmVarDecl":
523
+ key = f"{current.qualified_id}::param::{name}"
524
+ return ValueSlot(key, ValueSlotKind.PARAMETER, str(name), current.qualified_id)
525
+ key = f"{current.qualified_id}::local::{name}"
526
+ return ValueSlot(key, ValueSlotKind.LOCAL, str(name), current.qualified_id)
527
+
528
+
529
+ def _decl_ref_slot(ref: dict[str, Any], current: FunctionRecord | None, tu: TranslationUnit) -> ValueSlot | None:
530
+ if current is None or not ref.get("name"):
531
+ return None
532
+ name = str(ref["name"])
533
+ if ref.get("kind") == "ParmVarDecl":
534
+ return ValueSlot(f"{current.qualified_id}::param::{name}", ValueSlotKind.PARAMETER, name, current.qualified_id)
535
+ return ValueSlot(f"{current.qualified_id}::local::{name}", ValueSlotKind.LOCAL, name, current.qualified_id)
536
+
537
+
538
+ def _declref_name(node: dict[str, Any] | None) -> str | None:
539
+ if not isinstance(node, dict):
540
+ return None
541
+ if node.get("kind") == "DeclRefExpr":
542
+ ref = node.get("referencedDecl") or {}
543
+ if ref.get("name"):
544
+ return str(ref["name"])
545
+ for child in node.get("inner") or ():
546
+ if isinstance(child, dict):
547
+ found = _declref_name(child)
548
+ if found:
549
+ return found
550
+ return None
551
+
552
+
553
+ def _integer_value(node: dict[str, Any] | None) -> int | None:
554
+ if not isinstance(node, dict):
555
+ return None
556
+ if node.get("kind") == "IntegerLiteral":
557
+ value = node.get("value")
558
+ try:
559
+ return int(value)
560
+ except (TypeError, ValueError):
561
+ return None
562
+ for child in node.get("inner") or ():
563
+ if isinstance(child, dict):
564
+ v = _integer_value(child)
565
+ if v is not None:
566
+ return v
567
+ return None
568
+
569
+
570
+
571
+ def _is_macro_expanded(node: dict[str, Any]) -> bool:
572
+ rng = node.get("range") or {}
573
+ for part in (rng.get("begin") or {}, rng.get("end") or {}):
574
+ if isinstance(part, dict) and ("spellingLoc" in part or "expansionLoc" in part):
575
+ return True
576
+ return False
577
+
578
+
579
+ def _range_with_loc_kind(node: dict[str, Any], key: str) -> SourceRange | None:
580
+ rng = node.get("range") or {}
581
+ begin_raw = rng.get("begin") or {}
582
+ end_raw = rng.get("end") or {}
583
+ begin = begin_raw.get(key) if isinstance(begin_raw, dict) else None
584
+ end = end_raw.get(key) if isinstance(end_raw, dict) else None
585
+ if not isinstance(begin, dict) and not isinstance(end, dict):
586
+ return None
587
+ begin = begin if isinstance(begin, dict) else {}
588
+ end = end if isinstance(end, dict) else begin
589
+ start_line = _int_or_none(begin.get("line")) or _int_or_none(end.get("line"))
590
+ end_line = _int_or_none(end.get("line")) or start_line
591
+ return SourceRange(start_line, _int_or_none(begin.get("col")), end_line, _int_or_none(end.get("col")))
592
+
593
+ def _direct_callee_name(call_node: dict[str, Any]) -> str | None:
594
+ def find(node: Any) -> str | None:
595
+ if not isinstance(node, dict):
596
+ return None
597
+ if node.get("kind") == "DeclRefExpr":
598
+ ref = node.get("referencedDecl") or {}
599
+ if ref.get("kind") == "FunctionDecl" and ref.get("name"):
600
+ return str(ref["name"])
601
+ for child in node.get("inner") or ():
602
+ found = find(child)
603
+ if found:
604
+ return found
605
+ return None
606
+ inner = call_node.get("inner") or []
607
+ return find(inner[0]) if inner else None
608
+
609
+
610
+ def _raw_text(node: dict[str, Any], source_bytes: bytes) -> str:
611
+ rng = node.get("range") or {}
612
+ begin = rng.get("begin") or {}
613
+ end = rng.get("end") or {}
614
+ start = begin.get("offset") if isinstance(begin, dict) else None
615
+ stop = end.get("offset") if isinstance(end, dict) else None
616
+ tok_len = end.get("tokLen") if isinstance(end, dict) else None
617
+ tok_len = tok_len or 1
618
+ if isinstance(start, int) and isinstance(stop, int):
619
+ return source_bytes[start:stop + int(tok_len)].decode("utf-8", errors="replace")
620
+
621
+ # Macro-expanded AST ranges often store expansionLoc/spellingLoc instead of
622
+ # direct offsets. Prefer the expansion location and reconstruct only the
623
+ # invocation token sequence from the original source.
624
+ expansion = begin.get("expansionLoc") if isinstance(begin, dict) else None
625
+ if not isinstance(expansion, dict):
626
+ expansion = end.get("expansionLoc") if isinstance(end, dict) else None
627
+ if isinstance(expansion, dict) and isinstance(expansion.get("offset"), int):
628
+ return _macro_invocation_text(source_bytes, int(expansion["offset"]))
629
+ return "<call>"
630
+
631
+
632
+ def _macro_invocation_text(source_bytes: bytes, start: int) -> str:
633
+ text = source_bytes.decode("utf-8", errors="replace")
634
+ start = max(0, min(start, len(text)))
635
+ i = start
636
+ while i < len(text) and (text[i].isalnum() or text[i] == "_"):
637
+ i += 1
638
+ while i < len(text) and text[i].isspace() and text[i] not in "\r\n":
639
+ i += 1
640
+ if i >= len(text) or text[i] != "(":
641
+ return text[start:i].strip() or "<call>"
642
+ depth = 0
643
+ quote: str | None = None
644
+ escaped = False
645
+ j = i
646
+ while j < len(text):
647
+ ch = text[j]
648
+ if quote is not None:
649
+ if escaped:
650
+ escaped = False
651
+ elif ch == "\\":
652
+ escaped = True
653
+ elif ch == quote:
654
+ quote = None
655
+ else:
656
+ if ch in {"\"", "'"}:
657
+ quote = ch
658
+ elif ch == "(":
659
+ depth += 1
660
+ elif ch == ")":
661
+ depth -= 1
662
+ if depth == 0:
663
+ return text[start:j + 1].strip()
664
+ elif ch in "\r\n;" and depth <= 0:
665
+ break
666
+ j += 1
667
+ return text[start:j].strip() or "<call>"
668
+
669
+
670
+ def _range(node: dict[str, Any], source_bytes: bytes | None = None) -> SourceRange:
671
+ rng = node.get("range") or {}
672
+ begin = rng.get("begin") or node.get("loc") or {}
673
+ end = rng.get("end") or node.get("loc") or {}
674
+ if isinstance(begin, dict) and isinstance(begin.get("expansionLoc"), dict):
675
+ begin = begin["expansionLoc"]
676
+ elif isinstance(begin, dict) and isinstance(begin.get("spellingLoc"), dict):
677
+ begin = begin["spellingLoc"]
678
+ if isinstance(end, dict) and isinstance(end.get("expansionLoc"), dict):
679
+ end = end["expansionLoc"]
680
+ elif isinstance(end, dict) and isinstance(end.get("spellingLoc"), dict):
681
+ end = end["spellingLoc"]
682
+ start_line = _int_or_none(begin.get("line")) or _int_or_none(end.get("line"))
683
+ start_col = _int_or_none(begin.get("col"))
684
+ end_line = _int_or_none(end.get("line")) or start_line
685
+ end_col = _int_or_none(end.get("col"))
686
+ if source_bytes is not None and start_line is None and isinstance(begin.get("offset"), int):
687
+ start_line, start_col = _line_col_from_offset(source_bytes, int(begin["offset"]))
688
+ if source_bytes is not None and end_line is None and isinstance(end.get("offset"), int):
689
+ end_line, end_col = _line_col_from_offset(source_bytes, int(end["offset"]))
690
+ return SourceRange(start_line, start_col, end_line, end_col)
691
+
692
+
693
+ def _line_col_from_offset(source_bytes: bytes, offset: int) -> tuple[int, int]:
694
+ offset = max(0, min(offset, len(source_bytes)))
695
+ prefix = source_bytes[:offset]
696
+ line = prefix.count(b"\n") + 1
697
+ last_nl = prefix.rfind(b"\n")
698
+ col = offset + 1 if last_nl < 0 else offset - last_nl
699
+ return line, col
700
+
701
+
702
+ def _preprocessor_guard_map(source_bytes: bytes) -> dict[int, str]:
703
+ """Map active source lines to symbolic preprocessor guards.
704
+
705
+ This scanner does not try to evaluate C preprocessor expressions. It keeps
706
+ them as symbolic evidence so the active AST retains the reason a callsite
707
+ exists in the selected build.
708
+ """
709
+ text = source_bytes.decode("utf-8", errors="replace")
710
+ stack: list[dict[str, object]] = []
711
+ result: dict[int, str] = {}
712
+
713
+ def current_guard() -> str | None:
714
+ parts = [str(frame["current"]) for frame in stack if frame.get("current")]
715
+ return " && ".join(f"({p})" for p in parts) if parts else None
716
+
717
+ for lineno, raw in enumerate(text.splitlines(), 1):
718
+ stripped = raw.strip()
719
+ if stripped.startswith("#ifdef "):
720
+ expr = f"defined({stripped[7:].strip()})"
721
+ stack.append({"branches": [expr], "current": expr})
722
+ continue
723
+ if stripped.startswith("#ifndef "):
724
+ expr = f"!defined({stripped[8:].strip()})"
725
+ stack.append({"branches": [expr], "current": expr})
726
+ continue
727
+ if stripped.startswith("#if "):
728
+ expr = stripped[4:].strip() or "<empty>"
729
+ stack.append({"branches": [expr], "current": expr})
730
+ continue
731
+ if stripped.startswith("#elif ") and stack:
732
+ expr = stripped[6:].strip() or "<empty>"
733
+ branches = list(stack[-1].get("branches") or [])
734
+ prior = " || ".join(f"({x})" for x in branches)
735
+ stack[-1]["current"] = f"!({prior}) && ({expr})"
736
+ branches.append(expr)
737
+ stack[-1]["branches"] = branches
738
+ continue
739
+ if stripped.startswith("#else") and stack:
740
+ branches = list(stack[-1].get("branches") or [])
741
+ prior = " || ".join(f"({x})" for x in branches) or "<none>"
742
+ stack[-1]["current"] = f"!({prior})"
743
+ continue
744
+ if stripped.startswith("#endif") and stack:
745
+ stack.pop()
746
+ continue
747
+ guard = current_guard()
748
+ if guard is not None:
749
+ result[lineno] = guard
750
+ return result
751
+
752
+
753
+ def _preprocessor_guard_regions(source_bytes: bytes, source_path: str) -> list[GuardedSourceRegion]:
754
+ """Return contiguous guarded source regions for *all* preprocessor branches.
755
+
756
+ This is intentionally independent from Clang's active AST. Even source lines
757
+ excluded by the selected build remain represented as symbolic evidence, so an
758
+ inactive branch is distinguishable from code that never existed.
759
+ """
760
+ by_line = _preprocessor_guard_map(source_bytes)
761
+ if not by_line:
762
+ return []
763
+ regions: list[GuardedSourceRegion] = []
764
+ start: int | None = None
765
+ prev: int | None = None
766
+ expr: str | None = None
767
+ for line in sorted(by_line):
768
+ current = by_line[line]
769
+ if start is None:
770
+ start = prev = line
771
+ expr = current
772
+ continue
773
+ if current == expr and line == (prev or line) + 1:
774
+ prev = line
775
+ continue
776
+ regions.append(GuardedSourceRegion(source_path, SourceRange(start, 1, prev, None), str(expr)))
777
+ start = prev = line
778
+ expr = current
779
+ if start is not None:
780
+ regions.append(GuardedSourceRegion(source_path, SourceRange(start, 1, prev, None), str(expr)))
781
+ return regions
782
+
783
+
784
+ def _has_body(node: dict[str, Any]) -> bool:
785
+ return any(isinstance(c, dict) and c.get("kind") == "CompoundStmt" for c in node.get("inner") or ())
786
+
787
+
788
+ def _function_id(tu: TranslationUnit, name: str, line: int | None, col: int | None, storage: Any, qual_type: Any) -> str:
789
+ line = line if line is not None else "?"
790
+ col = col if col is not None else "?"
791
+ storage_text = storage or "external"
792
+ type_text = qual_type or "?"
793
+ return f"{tu.source.project_relative_path}:{line}:{col}:{storage_text}:{name}:{type_text}"
794
+
795
+
796
+ def _int_or_none(value: Any) -> int | None:
797
+ return value if isinstance(value, int) else None