|
| 1 | +################################################################################ |
| 2 | +# Copyright IBM Corporation 2025 |
| 3 | +# |
| 4 | +# Licensed under the Apache License, Version 2.0 (the "License"); |
| 5 | +# you may not use this file except in compliance with the License. |
| 6 | +# You may obtain a copy of the License at |
| 7 | +# |
| 8 | +# http://www.apache.org/licenses/LICENSE-2.0 |
| 9 | +# |
| 10 | +# Unless required by applicable law or agreed to in writing, software |
| 11 | +# distributed under the License is distributed on an "AS IS" BASIS, |
| 12 | +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 13 | +# See the License for the specific language governing permissions and |
| 14 | +# limitations under the License. |
| 15 | +################################################################################ |
| 16 | + |
| 17 | +"""The level-3 orchestrator: symbol table + call graph → program graphs. |
| 18 | +
|
| 19 | +``build_program_graphs`` is the single entry point ``Codeanalyzer.analyze`` |
| 20 | +calls at ``-a 3``. It re-parses each module file with the stdlib ``ast`` (the |
| 21 | +same parser the symbol table used), maps every ``PyCallable`` to its def node |
| 22 | +by ``(file, start_line)`` — which is what guarantees graph nodes join back to |
| 23 | +symbol-table signatures — then runs the construction ladder: |
| 24 | +
|
| 25 | + per callable: CFG → dominance → facts (module-qualified globals) |
| 26 | + whole program: SCC condensation → summary fixpoint → SDG assembly |
| 27 | +
|
| 28 | +The call graph and Jedi-resolved callsites are frozen oracles: targets are |
| 29 | +looked up, never re-inferred. Callables whose AST cannot be recovered (file |
| 30 | +changed on disk, decorators moving line numbers, generated code) are skipped |
| 31 | +with a warning — their callers still treat them as external pass-through, so |
| 32 | +the result degrades gracefully instead of crashing (contract rule). |
| 33 | +""" |
| 34 | + |
| 35 | +from __future__ import annotations |
| 36 | + |
| 37 | +import ast |
| 38 | +from pathlib import Path |
| 39 | +from typing import Dict, List, Optional, Set, Tuple |
| 40 | + |
| 41 | +from codeanalyzer.dataflow.access_paths import _PathExtractor, _calls_in |
| 42 | +from codeanalyzer.dataflow.alias import TypeBasedAliasOracle |
| 43 | +from codeanalyzer.dataflow.pdg import build_pdg |
| 44 | +from codeanalyzer.dataflow.sdg import ProgramGraphsIR, assemble_sdg |
| 45 | +from codeanalyzer.dataflow.summaries import CallSite, FunctionInfo, compute_summaries |
| 46 | +from codeanalyzer.schema.py_schema import PyApplication, PyCallable, PyClass, PyModule |
| 47 | +from codeanalyzer.utils import logger |
| 48 | + |
| 49 | +DEFAULT_K_LIMIT = 3 |
| 50 | + |
| 51 | + |
| 52 | +def _walk_callables( |
| 53 | + module: PyModule, |
| 54 | +) -> List[Tuple[PyCallable, Tuple[PyCallable, ...]]]: |
| 55 | + """Every callable in the module with its chain of enclosing callables.""" |
| 56 | + out: List[Tuple[PyCallable, Tuple[PyCallable, ...]]] = [] |
| 57 | + |
| 58 | + def from_callable(c: PyCallable, chain: Tuple[PyCallable, ...]) -> None: |
| 59 | + out.append((c, chain)) |
| 60 | + for inner in (c.inner_callables or {}).values(): |
| 61 | + from_callable(inner, chain + (c,)) |
| 62 | + for cls in (c.inner_classes or {}).values(): |
| 63 | + from_class(cls, chain + (c,)) |
| 64 | + |
| 65 | + def from_class(cls: PyClass, chain: Tuple[PyCallable, ...]) -> None: |
| 66 | + for m in (cls.methods or {}).values(): |
| 67 | + from_callable(m, chain) |
| 68 | + for inner in (cls.inner_classes or {}).values(): |
| 69 | + from_class(inner, chain) |
| 70 | + |
| 71 | + for fn in (module.functions or {}).values(): |
| 72 | + from_callable(fn, ()) |
| 73 | + for cls in (module.classes or {}).values(): |
| 74 | + from_class(cls, ()) |
| 75 | + return out |
| 76 | + |
| 77 | + |
| 78 | +def _locals_of(func: ast.AST) -> Set[str]: |
| 79 | + from codeanalyzer.dataflow.access_paths import _assigned_names, _param_names |
| 80 | + |
| 81 | + return set(_param_names(func)) | _assigned_names(func) |
| 82 | + |
| 83 | + |
| 84 | +def _base_types(c: PyCallable) -> Dict[str, Optional[str]]: |
| 85 | + types: Dict[str, Optional[str]] = {} |
| 86 | + for p in c.parameters or []: |
| 87 | + types[p.name] = p.type |
| 88 | + for v in c.local_variables or []: |
| 89 | + types.setdefault(v.name, v.type) |
| 90 | + return types |
| 91 | + |
| 92 | + |
| 93 | +def _class_index(app: PyApplication) -> Dict[str, PyClass]: |
| 94 | + from codeanalyzer.semantic_analysis.call_graph import iter_classes_in_symbol_table |
| 95 | + |
| 96 | + return {c.signature: c for c in iter_classes_in_symbol_table(app.symbol_table)} |
| 97 | + |
| 98 | + |
| 99 | +def _callable_index(app: PyApplication) -> Dict[str, PyCallable]: |
| 100 | + from codeanalyzer.semantic_analysis.call_graph import iter_callables_in_symbol_table |
| 101 | + |
| 102 | + return {c.signature: c for c in iter_callables_in_symbol_table(app.symbol_table)} |
| 103 | + |
| 104 | + |
| 105 | +def _match_args( |
| 106 | + call: ast.Call, |
| 107 | + callee: PyCallable, |
| 108 | + extractor: _PathExtractor, |
| 109 | + receiver_path: Optional[str], |
| 110 | +) -> Tuple[Tuple[str, Optional[str]], ...]: |
| 111 | + """Positional/keyword-match actual access paths to callee param names. |
| 112 | + The receiver (or constructed object) binds the leading self/cls param.""" |
| 113 | + params = [p.name for p in (callee.parameters or [])] |
| 114 | + pairs: List[Tuple[str, Optional[str]]] = [] |
| 115 | + positional = list(params) |
| 116 | + if params and params[0] in ("self", "cls"): |
| 117 | + if receiver_path is not None: |
| 118 | + pairs.append((params[0], receiver_path)) |
| 119 | + positional = params[1:] |
| 120 | + for name, arg in zip(positional, call.args): |
| 121 | + if isinstance(arg, ast.Starred): |
| 122 | + break |
| 123 | + pairs.append((name, extractor.path_of(arg))) |
| 124 | + for kw in call.keywords: |
| 125 | + if kw.arg and kw.arg in params: |
| 126 | + pairs.append((kw.arg, extractor.path_of(kw.value))) |
| 127 | + return tuple(pairs) |
| 128 | + |
| 129 | + |
| 130 | +def build_program_graphs( |
| 131 | + app: PyApplication, |
| 132 | + k: int = DEFAULT_K_LIMIT, |
| 133 | +) -> ProgramGraphsIR: |
| 134 | + """Build CFG/PDG per callable and the whole-program SDG.""" |
| 135 | + class_idx = _class_index(app) |
| 136 | + callable_idx = _callable_index(app) |
| 137 | + |
| 138 | + infos: Dict[str, FunctionInfo] = {} |
| 139 | + func_asts: Dict[str, ast.AST] = {} |
| 140 | + |
| 141 | + for file_key, module in sorted(app.symbol_table.items()): |
| 142 | + path = Path(module.file_path) |
| 143 | + try: |
| 144 | + tree = ast.parse(path.read_text()) |
| 145 | + except (OSError, SyntaxError) as exc: |
| 146 | + logger.warning(f"level 3: skipping {path} (unparseable: {exc})") |
| 147 | + continue |
| 148 | + |
| 149 | + def_index: Dict[int, ast.AST] = {} |
| 150 | + for node in ast.walk(tree): |
| 151 | + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): |
| 152 | + def_index[node.lineno] = node |
| 153 | + |
| 154 | + for pycallable, chain in _walk_callables(module): |
| 155 | + func = def_index.get(pycallable.start_line) |
| 156 | + if func is None or func.name != pycallable.name: |
| 157 | + logger.warning( |
| 158 | + f"level 3: no AST match for {pycallable.signature} " |
| 159 | + f"({path}:{pycallable.start_line}); treated as external" |
| 160 | + ) |
| 161 | + continue |
| 162 | + |
| 163 | + enclosing_locals: Set[str] = set() |
| 164 | + for enclosing in chain: |
| 165 | + enclosing_ast = def_index.get(enclosing.start_line) |
| 166 | + if enclosing_ast is not None: |
| 167 | + enclosing_locals |= _locals_of(enclosing_ast) |
| 168 | + |
| 169 | + oracle = TypeBasedAliasOracle(_base_types(pycallable)) |
| 170 | + pdg = build_pdg( |
| 171 | + func, |
| 172 | + enclosing_locals=enclosing_locals, |
| 173 | + oracle=oracle, |
| 174 | + k=k, |
| 175 | + global_qualifier=module.module_name, |
| 176 | + ) |
| 177 | + infos[pycallable.signature] = FunctionInfo( |
| 178 | + signature=pycallable.signature, pdg=pdg, oracle=oracle |
| 179 | + ) |
| 180 | + func_asts[pycallable.signature] = func |
| 181 | + |
| 182 | + # Callsites and nested defs, now that every signature is known. |
| 183 | + for sig, info in infos.items(): |
| 184 | + pycallable = callable_idx[sig] |
| 185 | + func = func_asts[sig] |
| 186 | + extractor = _PathExtractor(info.pdg.scope, k) |
| 187 | + |
| 188 | + calls_by_pos: Dict[Tuple[int, int], Tuple[int, ast.Call]] = {} |
| 189 | + calls_by_line: Dict[int, Tuple[int, ast.Call]] = {} |
| 190 | + for node in info.pdg.cfg.nodes: |
| 191 | + if node.ast_node is None: |
| 192 | + continue |
| 193 | + for call in _calls_in(node.ast_node): |
| 194 | + pos = (call.lineno, call.col_offset) |
| 195 | + calls_by_pos.setdefault(pos, (node.id, call)) |
| 196 | + calls_by_line.setdefault(call.lineno, (node.id, call)) |
| 197 | + |
| 198 | + for site in pycallable.call_sites or []: |
| 199 | + target = site.callee_signature |
| 200 | + if not target: |
| 201 | + continue |
| 202 | + if target in class_idx and target not in infos: |
| 203 | + target = f"{target}.__init__" # constructor → its initializer |
| 204 | + if target not in infos: |
| 205 | + continue # external or unrecovered: pass-through posture |
| 206 | + |
| 207 | + located = calls_by_pos.get((site.start_line, site.start_column)) |
| 208 | + if located is None: |
| 209 | + located = calls_by_line.get(site.start_line) |
| 210 | + if located is None: |
| 211 | + continue |
| 212 | + node_id, call = located |
| 213 | + |
| 214 | + receiver_path: Optional[str] = None |
| 215 | + if isinstance(call.func, ast.Attribute): |
| 216 | + receiver_path = extractor.path_of(call.func.value) |
| 217 | + elif site.is_constructor_call: |
| 218 | + # p = Box(...) binds the constructed object (self) to p. |
| 219 | + owner = info.pdg.cfg.node_by_id(node_id).ast_node |
| 220 | + if ( |
| 221 | + isinstance(owner, ast.Assign) |
| 222 | + and len(owner.targets) == 1 |
| 223 | + and isinstance(owner.targets[0], (ast.Name, ast.Attribute)) |
| 224 | + ): |
| 225 | + receiver_path = extractor.path_of(owner.targets[0]) |
| 226 | + |
| 227 | + info.call_sites.append( |
| 228 | + CallSite( |
| 229 | + node_id=node_id, |
| 230 | + targets=(target,), |
| 231 | + arg_paths=_match_args(call, callable_idx[target], extractor, receiver_path), |
| 232 | + line=site.start_line, |
| 233 | + ) |
| 234 | + ) |
| 235 | + |
| 236 | + for node in info.pdg.cfg.nodes: |
| 237 | + if isinstance(node.ast_node, (ast.FunctionDef, ast.AsyncFunctionDef)): |
| 238 | + nested_sig = f"{sig}.{node.ast_node.name}" |
| 239 | + if nested_sig in infos: |
| 240 | + info.nested_defs.append((node.id, nested_sig)) |
| 241 | + |
| 242 | + call_edges = [ |
| 243 | + (e.source, e.target) |
| 244 | + for e in app.call_graph |
| 245 | + if e.source in infos and e.target in infos |
| 246 | + ] |
| 247 | + # Callsite resolutions are part of the same oracle (they may include |
| 248 | + # constructor retargets the edge list lacks). |
| 249 | + for sig, info in infos.items(): |
| 250 | + for cs in info.call_sites: |
| 251 | + for t in cs.targets: |
| 252 | + call_edges.append((sig, t)) |
| 253 | + |
| 254 | + summaries = compute_summaries(infos, sorted(set(call_edges))) |
| 255 | + return assemble_sdg(infos, summaries, k) |
0 commit comments