codeanalyzer-python 1.5.2__py3-none-any.whl → 1.5.4__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.
codeanalyzer/__main__.py CHANGED
@@ -39,7 +39,7 @@ def _pin_hash_seed() -> None:
39
39
  from codeanalyzer.core import Codeanalyzer
40
40
  from codeanalyzer.utils import _set_log_level, logger
41
41
  from codeanalyzer.schema import model_dump, model_dump_json, strip_internal_only
42
- from codeanalyzer.options import AnalysisOptions, EmitTarget
42
+ from codeanalyzer.options import ANALYSIS_JSON, AnalysisOptions, EmitTarget
43
43
 
44
44
 
45
45
  def _version_callback(value: bool) -> None:
@@ -402,7 +402,7 @@ def main(
402
402
 
403
403
  def _write_output(artifacts, output_dir: Path):
404
404
  """Write analysis.json (the single wire format since #118)."""
405
- output_file = output_dir / "analysis.json"
405
+ output_file = output_dir / ANALYSIS_JSON
406
406
  # Use Pydantic's model_dump_json() for compact output
407
407
  # Strip internal-only fields here rather than with a field-level Pydantic
408
408
  # `exclude`: the analysis cache shares the serializer and must keep them.
@@ -3,10 +3,11 @@ from __future__ import annotations
3
3
  import fnmatch
4
4
  import hashlib
5
5
  from pathlib import Path
6
- from typing import Dict, List, Tuple
6
+ from typing import Dict, Iterable, List, Tuple
7
7
 
8
8
  from codeanalyzer.schema.ids import artifact_id
9
9
  from codeanalyzer.schema.py_schema import PyArtifact
10
+ from codeanalyzer.utils import logger
10
11
 
11
12
  # (glob pattern against the repo-relative POSIX path, format, roles).
12
13
  # First match wins; patterns are checked in order.
@@ -67,6 +68,43 @@ _IGNORED_DIRS = {
67
68
  }
68
69
 
69
70
 
71
+ def _resolve_exclusions(project_dir: Path, exclude_paths: Iterable[Path]) -> List[Path]:
72
+ """Resolve the paths a run writes to, dropping any that would take the whole
73
+ project with them (#207).
74
+
75
+ A *directory* exclusion at or above the project root would empty the
76
+ inventory, which is worse than the bug it guards against, so it is refused.
77
+ That case is still covered, because the caller also passes the individual
78
+ output *files* -- excluding ``<project>/analysis.json`` costs one file
79
+ instead of the entire tree.
80
+ """
81
+ root = project_dir.resolve()
82
+ kept = []
83
+ for given in exclude_paths:
84
+ resolved = given.resolve()
85
+ if root.is_relative_to(resolved):
86
+ logger.warning(
87
+ f"Not excluding {resolved} from artifact discovery: it holds the "
88
+ f"project itself. Only this run's own output files under it are "
89
+ f"skipped; anything else written there is ingested as an artifact."
90
+ )
91
+ continue
92
+ kept.append(resolved)
93
+ return kept
94
+
95
+
96
+ def _is_excluded(path: Path, excluded: List[Path]) -> bool:
97
+ """True when ``path`` is, or sits inside, one of ``excluded`` (a file entry
98
+ matches only itself). Checked on the path as walked *and* on its resolved
99
+ form, so an output directory reached through a symlink is caught from either
100
+ side."""
101
+ if not excluded:
102
+ return False
103
+ if any(path.is_relative_to(directory) for directory in excluded):
104
+ return True
105
+ return any(path.resolve().is_relative_to(directory) for directory in excluded)
106
+
107
+
70
108
  def _classify(rel_posix: str) -> Tuple[str, List[str]] | None:
71
109
  name = rel_posix.rsplit("/", 1)[-1]
72
110
  for pattern, fmt, roles in RULES:
@@ -81,6 +119,7 @@ def discover_artifacts(
81
119
  app_name: str,
82
120
  *,
83
121
  capture_text: bool = True,
122
+ exclude_paths: Iterable[Path] = (),
84
123
  ) -> Dict[str, PyArtifact]:
85
124
  """Walk the project and return every file as an artifact, sorted by path.
86
125
 
@@ -93,15 +132,28 @@ def discover_artifacts(
93
132
  deliberate exception -- it IS rule-matched (a dependency-manifest), so it
94
133
  is captured like any other manifest despite the `.py` suffix.
95
134
 
135
+ ``exclude_paths`` names what this run writes -- the ``--output`` and cache
136
+ directories, and the output files inside them (#207). Without them a run
137
+ whose output lands inside the project ingests the previous run's whole
138
+ ``analysis.json``, and each run embeds the one before it until the process is
139
+ killed decoding its own output. Matching is on resolved paths, so a
140
+ relative, `..`-laden or symlinked target excludes the same tree, and a target
141
+ outside the project excludes nothing. A directory that holds the project
142
+ itself is refused (it would empty the inventory); the file entries still
143
+ cover that case.
144
+
96
145
  ``source`` is the WHOLE file or nothing -- never a prefix (#172). A
97
146
  decodable file is captured in full; ``capture_text=False`` empties
98
147
  ``source`` everywhere (inventory otherwise identical), and an undecodable
99
148
  file gets ``""`` as ``binary``. ``sha256``/``size_bytes`` always reflect
100
149
  the full file regardless."""
101
150
  out: Dict[str, PyArtifact] = {}
151
+ excluded = _resolve_exclusions(project_dir, exclude_paths)
102
152
  for path in sorted(project_dir.rglob("*")):
103
153
  if not path.is_file():
104
154
  continue
155
+ if _is_excluded(path, excluded):
156
+ continue
105
157
  rel = path.relative_to(project_dir)
106
158
  if any(part in _IGNORED_DIRS for part in rel.parts):
107
159
  continue
codeanalyzer/core.py CHANGED
@@ -35,7 +35,7 @@ from codeanalyzer.syntactic_analysis.exceptions import SymbolTableBuilderRayErro
35
35
  from codeanalyzer.syntactic_analysis.import_resolver import resolve_imports
36
36
  from codeanalyzer.syntactic_analysis.symbol_table_builder import SymbolTableBuilder
37
37
  from codeanalyzer.utils import ProgressBar
38
- from codeanalyzer.options import AnalysisOptions
38
+ from codeanalyzer.options import ANALYSIS_JSON, GRAPH_CYPHER, AnalysisOptions, EmitTarget
39
39
  from codeanalyzer.provenance import analyzer_info, repository_info
40
40
 
41
41
  def _artifact_full_text(project_dir: Path, path: str, art) -> str:
@@ -572,6 +572,24 @@ class Codeanalyzer:
572
572
  )
573
573
  return externals
574
574
 
575
+ def _own_output_paths(self) -> List[Path]:
576
+ """Where this run writes: the output and cache directories, plus the
577
+ output files themselves (#207).
578
+
579
+ Artifact discovery skips these, so a run whose ``-o``/``-c`` lands inside
580
+ ``-i`` does not ingest its own previous output (each run embedding the
581
+ last until the process is killed decoding it). The file entries carry the
582
+ degenerate case where the output directory *is* the project root, which
583
+ cannot be skipped wholesale without emptying the inventory.
584
+ """
585
+ paths: List[Path] = [self.cache_dir]
586
+ if self.options.output is not None:
587
+ paths += [self.options.output, self.options.output / ANALYSIS_JSON]
588
+ if self.options.emit is EmitTarget.NEO4J:
589
+ # No -o means the cypher snapshot lands in the working directory.
590
+ paths.append((self.options.output or Path.cwd()) / GRAPH_CYPHER)
591
+ return paths
592
+
575
593
  def analyze(self) -> Analysis:
576
594
  """Analyze the project and return the v2 ``Analysis`` envelope.
577
595
 
@@ -673,6 +691,7 @@ class Codeanalyzer:
673
691
  app.artifacts = discover_artifacts(
674
692
  self.project_dir, app_name,
675
693
  capture_text=self.options.artifact_text,
694
+ exclude_paths=self._own_output_paths(),
676
695
  )
677
696
  app.dependencies, app.unresolved_imports = build_dependency_view(
678
697
  app.artifacts,
@@ -35,6 +35,7 @@ the result degrades gracefully instead of crashing (contract rule).
35
35
  from __future__ import annotations
36
36
 
37
37
  import ast
38
+ from collections import defaultdict
38
39
  from pathlib import Path
39
40
  from typing import Callable, Dict, List, Optional, Set, Tuple
40
41
 
@@ -43,7 +44,7 @@ from codeanalyzer.dataflow.alias import TypeBasedAliasOracle
43
44
  from codeanalyzer.dataflow.pdg import build_pdg
44
45
  from codeanalyzer.dataflow.sdg import ProgramGraphsIR, assemble_sdg
45
46
  from codeanalyzer.dataflow.summaries import CallSite, FunctionInfo, compute_summaries
46
- from codeanalyzer.schema.ids import stamp_body_ids
47
+ from codeanalyzer.schema.ids import call_body_keys, stamp_body_ids
47
48
  from codeanalyzer.schema.py_schema import PyApplication, PyCallable, PyClass, PyModule
48
49
  from codeanalyzer.utils import logger
49
50
 
@@ -291,6 +292,11 @@ def emit_l3_body(
291
292
  continue
292
293
  pycallable.body[local] = BodyNode(kind=node.kind, span=span)
293
294
 
295
+ keys_at: Dict[str, List[str]] = defaultdict(list)
296
+ for key, n in pycallable.body.items():
297
+ if n.kind == "call":
298
+ keys_at[key.split("/", 1)[0]].append(key)
299
+
294
300
  # #115: anchor nested call vertices to their statement. A bare-call
295
301
  # statement shares its key with its CFG node (handled above); a call
296
302
  # nested inside a larger statement (`y = f(x)`) has its own key and
@@ -301,12 +307,16 @@ def emit_l3_body(
301
307
  continue
302
308
  stmt_local = im.local(node.id)
303
309
  for call in _calls_in(node.ast_node):
304
- call_key = f"{call.lineno}:{call.col_offset}"
305
- child = pycallable.body.get(call_key)
306
- if child is None or call_key == stmt_local:
307
- continue
308
- if child.kind == "call":
309
- child.parent = stmt_local
310
+ # Every call node at this position, not just one: nested calls
311
+ # that share a start column are keyed `line:col`, `line:col/2`,
312
+ # ... and each of them needs the anchor (#215).
313
+ base = f"{call.lineno}:{call.col_offset}"
314
+ for call_key in keys_at.get(base, ()):
315
+ child = pycallable.body.get(call_key)
316
+ if child is None or call_key == stmt_local:
317
+ continue
318
+ if child.kind == "call":
319
+ child.parent = stmt_local
310
320
 
311
321
  if want_cfg:
312
322
  pycallable.cfg = [
@@ -378,7 +388,7 @@ def build_program_graphs(
378
388
  calls_by_pos.setdefault(pos, (node.id, call))
379
389
  calls_by_line.setdefault(call.lineno, (node.id, call))
380
390
 
381
- for site in pycallable.call_sites or []:
391
+ for site_key, site in call_body_keys(pycallable.call_sites):
382
392
  # Prefer the callsite's body-backfilled callee over Jedi's own
383
393
  # callee_signature side channel: under cross-test parso/Jedi cache
384
394
  # pressure that inference can silently degrade (full-suite-only
@@ -388,7 +398,7 @@ def build_program_graphs(
388
398
  # Only a resolved INTERNAL target counts (id_to_sig misses on an
389
399
  # external/unresolved callee); falls through to callee_signature
390
400
  # exactly as before whenever the body doesn't have an answer.
391
- body_node = pycallable.body.get(f"{site.start_line}:{site.start_column}")
401
+ body_node = pycallable.body.get(site_key)
392
402
  target = (id_to_sig.get(body_node.callee) if body_node else None) or site.callee_signature
393
403
  if not target:
394
404
  continue
@@ -142,6 +142,26 @@ class _FunctionAssembler:
142
142
 
143
143
  # ---------------------------------------------------------------- formals
144
144
 
145
+ def formal_for(self, var: str) -> Optional[int]:
146
+ """The ``formal_in`` vertex that defines ``var`` in this callable.
147
+
148
+ A parameter, a capture and a read global are all defined at a
149
+ ``formal_in`` port, not at the synthetic CFG ENTRY node. Reaching-def
150
+ analysis reports ENTRY as the source for all three, so every place that
151
+ consumes a raw def source has to remap it here, or the SDG loses the
152
+ hop from the parameter to whatever uses it. Returns ``None`` when
153
+ ``var`` is not one of this callable's formals, so a caller can keep the
154
+ original source.
155
+ """
156
+ b = base_of(var)
157
+ if b in self.formal_in:
158
+ return self.formal_in[b]
159
+ if CAPTURE_PREFIX + b in self.formal_in:
160
+ return self.formal_in[CAPTURE_PREFIX + b]
161
+ if "::" in b and GLOBAL_PREFIX + b in self.formal_in:
162
+ return self.formal_in[GLOBAL_PREFIX + b]
163
+ return None
164
+
145
165
  def build_formals(self) -> None:
146
166
  scope, summary = self.scope, self.summary
147
167
  params = list(scope.params)
@@ -168,14 +188,8 @@ class _FunctionAssembler:
168
188
  for e in self.ddg:
169
189
  if e.source != entry:
170
190
  continue
171
- b = base_of(e.var)
172
- if b in self.formal_in:
173
- fid = self.formal_in[b]
174
- elif CAPTURE_PREFIX + b in self.formal_in:
175
- fid = self.formal_in[CAPTURE_PREFIX + b]
176
- elif "::" in b and GLOBAL_PREFIX + b in self.formal_in:
177
- fid = self.formal_in[GLOBAL_PREFIX + b]
178
- else:
191
+ fid = self.formal_for(e.var)
192
+ if fid is None:
179
193
  continue
180
194
  self.extra.append(PDGEdge(source=fid, target=e.target, type="DDG", var=e.var))
181
195
 
@@ -268,6 +282,8 @@ class _FunctionAssembler:
268
282
  for src, var in self._defs_reaching_call_matching(
269
283
  cs.node_id, path
270
284
  ):
285
+ if src == self.cfg.entry_id:
286
+ src = self.formal_for(var) or src
271
287
  self.extra.append(
272
288
  PDGEdge(source=src, target=aid, type="DDG", var=var)
273
289
  )
@@ -296,6 +312,8 @@ class _FunctionAssembler:
296
312
  for src, var in self._defs_reaching_call_matching(
297
313
  cs.node_id, g
298
314
  ):
315
+ if src == self.cfg.entry_id:
316
+ src = self.formal_for(var) or src
299
317
  self.extra.append(
300
318
  PDGEdge(source=src, target=aid, type="DDG", var=var)
301
319
  )
@@ -104,8 +104,12 @@ def _methods_of(dec, spec: Optional[Dict[str, Any]], qualified: Optional[str] =
104
104
  return []
105
105
  source = spec.get("from")
106
106
  if source == "match_suffix":
107
+ # Only a real verb: `heuristic.http-verb` also matches `.websocket`, and a
108
+ # rule may accept any suffix, but `http_methods` is what a consumer filters
109
+ # on to enumerate methods -- a value that is not one is worse there than an
110
+ # empty list (#213). The dispatch path below already filters the same way.
107
111
  verb = (qualified or dec.qualified_name or "").rsplit(".", 1)[-1]
108
- return [verb.upper()]
112
+ return [verb.upper()] if verb.lower() in _HTTP_VERBS else []
109
113
  if source == "keyword":
110
114
  raw = (dec.keyword_arguments or {}).get(spec.get("name", ""))
111
115
  value = _literal(raw)
@@ -31,7 +31,7 @@ from codeanalyzer.neo4j.bolt import BoltConfig, bolt_writer
31
31
  from codeanalyzer.neo4j.schema import build_schema_document
32
32
  from codeanalyzer.neo4j.cypher import render_cypher
33
33
  from codeanalyzer.neo4j.project import project
34
- from codeanalyzer.options import AnalysisOptions
34
+ from codeanalyzer.options import GRAPH_CYPHER, AnalysisOptions
35
35
  from codeanalyzer.schema import Analysis
36
36
  from codeanalyzer.schema.assign_ids import assign_ids
37
37
  from codeanalyzer.utils import logger
@@ -75,6 +75,6 @@ def emit_neo4j(analysis: Analysis, options: AnalysisOptions) -> None:
75
75
 
76
76
  out_dir = options.output if options.output is not None else Path.cwd()
77
77
  out_dir.mkdir(parents=True, exist_ok=True)
78
- target = out_dir / "graph.cypher"
78
+ target = out_dir / GRAPH_CYPHER
79
79
  target.write_text(render_cypher(rows, app_name))
80
80
  logger.info(f"Neo4j graph written to {target}")
@@ -49,7 +49,9 @@ from codeanalyzer.schema import (
49
49
  PyVariableDeclaration,
50
50
  )
51
51
  from codeanalyzer.schema import model_dump
52
- from codeanalyzer.schema.ids import application_id, external_id, global_ordinal, purl_pypi
52
+ from codeanalyzer.schema.ids import (
53
+ application_id, call_body_keys, external_id, global_ordinal, purl_pypi,
54
+ )
53
55
  from codeanalyzer.schema.py_schema import PyDecorator, byte_offsets
54
56
 
55
57
 
@@ -183,12 +185,14 @@ def _project_program_graphs(
183
185
  continue # unstamped callable — assign_ids must run first
184
186
  owner = _sym(c.id) # the :PyCallable node, keyed by its can:// id
185
187
  # ``callee_signature`` lives on ``PyCallable.call_sites``, not on the body
186
- # node, so the graph joins the two on the call site's position (#203).
188
+ # node, so the graph joins the two on the call site's BODY KEY (#203,
189
+ # #215) -- re-derived from the same sequence L1 keyed ``body`` with, so
190
+ # two calls that start at one position keep their own signatures.
187
191
  # ``argument_types`` is deliberately not joined: it is the legacy field #86
188
192
  # split into ``PyCallArgument``, already carried as ``arguments_json``.
189
- sig_by_pos = {
190
- (cs.start_line, cs.start_column): cs.callee_signature
191
- for cs in (c.call_sites or [])
193
+ sig_by_key = {
194
+ key: cs.callee_signature
195
+ for key, cs in call_body_keys(c.call_sites)
192
196
  if cs.callee_signature
193
197
  }
194
198
  for local_key, node in (c.body or {}).items():
@@ -204,10 +208,7 @@ def _project_program_graphs(
204
208
  {
205
209
  "kind": node.kind,
206
210
  **_span_props(span),
207
- "callee_signature": (
208
- sig_by_pos.get((span.start[0], span.start[1]))
209
- if span else None
210
- ),
211
+ "callee_signature": sig_by_key.get(local_key),
211
212
  "var": node.of,
212
213
  "call_node": node.parent,
213
214
  # Call-site detail (#120). The JSON emits one node per
@@ -1,3 +1,3 @@
1
- from .options import AnalysisOptions, EmitTarget
1
+ from .options import ANALYSIS_JSON, GRAPH_CYPHER, AnalysisOptions, EmitTarget
2
2
 
3
- __all__ = ["AnalysisOptions", "EmitTarget"]
3
+ __all__ = ["AnalysisOptions", "EmitTarget", "ANALYSIS_JSON", "GRAPH_CYPHER"]
@@ -4,6 +4,13 @@ from typing import Optional, Tuple
4
4
  from enum import Enum
5
5
 
6
6
 
7
+ # The files a run writes into its output directory. Named once so artifact
8
+ # discovery can recognize -- and skip -- the run's own output when it lands
9
+ # inside the analyzed project (#207).
10
+ ANALYSIS_JSON = "analysis.json"
11
+ GRAPH_CYPHER = "graph.cypher"
12
+
13
+
7
14
  class EmitTarget(str, Enum):
8
15
  """Output target selected by ``--emit``.
9
16
 
@@ -10,7 +10,7 @@ and artifacts alike — which is what the prefix-scoped destructive statements
10
10
  an application named ``python`` mints ``can://python/python/...``, so a test
11
11
  for ``can://python/`` no longer means "a python id"; test the scheme instead."""
12
12
  from __future__ import annotations
13
- from typing import List, Optional
13
+ from typing import Iterable, Iterator, List, Optional, Tuple
14
14
 
15
15
  SCHEME = "can://"
16
16
 
@@ -52,6 +52,42 @@ def external_id(app_id: str, module: Optional[str], name: str) -> str:
52
52
  return f"{base}/{module}/{name}" if module else f"{base}/{name}"
53
53
 
54
54
 
55
+ def call_body_keys(sites: Iterable) -> Iterator[Tuple[str, object]]:
56
+ """The body key of each call site, in recording order: ``line:col``,
57
+ disambiguated ``/2``, ``/3``, ... when nested calls share a start position
58
+ (#215).
59
+
60
+ `getattr(o, n)(x)` begins the outer application and the inner `getattr` at the
61
+ same column, so a bare ``line:col`` key keeps one of the two and the dynamic
62
+ invocation is lost. Call sites are recorded pre-order, so the bare key goes to
63
+ the OUTERMOST call and the nested ones take the suffixes. The spelling is
64
+ codeanalyzer-typescript's (``callBodyKeys``, ``src/schema/l1Body.ts``), adopted
65
+ verbatim; the ``/`` never collides with a param-vertex segment, which always
66
+ begins ``actual_``.
67
+
68
+ The SINGLE definition of the sequence -- L1 builds ``body`` with it, and L2, the
69
+ dataflow builder, the defuse linker and the Neo4j projection re-derive the same
70
+ pairing from it rather than re-deriving a key from a position.
71
+ """
72
+ used = set()
73
+ for cs in sites or []:
74
+ base = f"{cs.start_line}:{cs.start_column}"
75
+ key = base
76
+ k = 2
77
+ while key in used:
78
+ key = f"{base}/{k}"
79
+ k += 1
80
+ used.add(key)
81
+ yield key, cs
82
+
83
+ def call_body_key(callable_, site) -> Optional[str]:
84
+ """``site``'s body key within ``callable_`` — the pairing of
85
+ :func:`call_body_keys`, for a caller that holds one site rather than the list."""
86
+ for key, cs in call_body_keys(callable_.call_sites):
87
+ if cs is site:
88
+ return key
89
+ return None
90
+
55
91
  def global_ordinal(callable_id: str, local_key: str) -> str:
56
92
  """The GLOBAL ordinal id of a body node from its LOCAL key: synthetic keys
57
93
  (`@entry`, `@formal_in:0`) already carry the `@`; positional keys (`15:2`,
@@ -1,12 +1,11 @@
1
1
  """L1 body population: materialize `call` nodes from existing call sites.
2
2
  `callee` is left None here — the sanctioned null→id refinement happens at L2."""
3
3
  from __future__ import annotations
4
- from codeanalyzer.schema.ids import stamp_body_ids
4
+ from codeanalyzer.schema.ids import call_body_keys, stamp_body_ids
5
5
  from codeanalyzer.schema.py_schema import PyApplication, PyClass, PyCallable, BodyNode, Span, byte_offsets
6
6
 
7
7
  def _do_callable(source: str, c: PyCallable) -> None:
8
- for cs in c.call_sites or []:
9
- key = f"{cs.start_line}:{cs.start_column}"
8
+ for key, cs in call_body_keys(c.call_sites):
10
9
  span = Span(start=(cs.start_line, cs.start_column),
11
10
  end=(cs.end_line, cs.end_column),
12
11
  bytes=byte_offsets(source, cs.start_line, cs.start_column, cs.end_line, cs.end_column)) if source else None
@@ -5,17 +5,18 @@ signature; an unresolved call site leaves `callee` absent.
5
5
 
6
6
  Two resolution sources feed the backfill: Jedi's `callee_signature` on the
7
7
  call site itself, and the defuse linker's returned map (keyed by caller
8
- signature + "line:col"). The linker's resolutions are deliberately NOT written
8
+ signature + the call site's body key, which carries a `/N` disambiguator when
9
+ nested calls share a start position -- #215). The linker's resolutions are deliberately NOT written
9
10
  into `callee_signature` — the symbol table round-trips through the analysis
10
11
  cache, and a persisted resolution would resurface on a warm run as a Jedi
11
12
  edge, silently changing provenance."""
12
13
  from __future__ import annotations
14
+ from codeanalyzer.schema.ids import call_body_keys
13
15
  from codeanalyzer.schema.py_schema import PyApplication, PyClass, PyCallable
14
16
 
15
17
 
16
18
  def _do_callable(c: PyCallable, sig_to_id: dict, resolutions: dict) -> None:
17
- for cs in c.call_sites or []:
18
- key = f"{cs.start_line}:{cs.start_column}"
19
+ for key, cs in call_body_keys(c.call_sites):
19
20
  jedi_sig = cs.callee_signature
20
21
  if jedi_sig and jedi_sig.startswith("typing."):
21
22
  # A decorator-typed callable resolved to its annotation, not a
@@ -40,11 +40,14 @@ import ast
40
40
  import builtins as _py_builtins
41
41
  from typing import Dict, List, Optional, Tuple
42
42
 
43
+ from codeanalyzer.schema.ids import call_body_key
43
44
  from codeanalyzer.schema.py_schema import PyCallable, PyCallEdge, PyClass, PyModule
44
45
 
45
46
  __all__ = ["defuse_linker_edges"]
46
47
 
47
- # (caller signature, "line:col" of the call site) -> resolved callee signature
48
+ # (caller signature, the call site's body key) -> resolved callee signature.
49
+ # The key is `call_body_key`, not a bare "line:col": two nested calls can start
50
+ # at one position and only one of them is the resolution being recorded (#215).
48
51
  Resolutions = Dict[Tuple[str, str], str]
49
52
 
50
53
  _MAX_CHAIN = 16 # assignment-chain hops before giving up (cycle safety net)
@@ -1095,7 +1098,7 @@ def defuse_linker_edges(
1095
1098
  oracle.vote(sig, site)
1096
1099
  bump(caller.signature, sig)
1097
1100
  resolutions[
1098
- (caller.signature, f"{site.start_line}:{site.start_column}")
1101
+ (caller.signature, call_body_key(caller, site))
1099
1102
  ] = sig
1100
1103
 
1101
1104
  # Calls Jedi's extractor never recorded as sites at all (with-
@@ -1398,7 +1401,7 @@ def defuse_linker_edges(
1398
1401
  oracle.vote(sig, site)
1399
1402
  bump(caller.signature, sig)
1400
1403
  resolutions[
1401
- (caller.signature, f"{site.start_line}:{site.start_column}")
1404
+ (caller.signature, call_body_key(caller, site))
1402
1405
  ] = sig
1403
1406
  made_progress = True
1404
1407
  else:
@@ -1452,7 +1455,7 @@ def defuse_linker_edges(
1452
1455
  oracle.vote(sig, site)
1453
1456
  bump(caller.signature, sig)
1454
1457
  resolutions[
1455
- (caller.signature, f"{site.start_line}:{site.start_column}")
1458
+ (caller.signature, call_body_key(caller, site))
1456
1459
  ] = sig
1457
1460
  made_progress = True
1458
1461
  remaining = still
@@ -130,8 +130,9 @@ class SymbolTableBuilder:
130
130
  return None, False
131
131
 
132
132
  @staticmethod
133
- def _callee_anchor(node: ast.Call) -> Tuple[int, int]:
134
- """Position of the callee *name* for Jedi inference.
133
+ def _callee_anchor(node: ast.Call) -> Optional[Tuple[int, int]]:
134
+ """Position of the callee *name* for Jedi inference, or ``None`` when the
135
+ callee is not a name at all.
135
136
 
136
137
  An ``ast.Call``'s own ``lineno``/``col_offset`` is the first
137
138
  character of the whole call expression — for an attribute call
@@ -139,11 +140,17 @@ class SymbolTableBuilder:
139
140
  would infer the receiver's type instead of the invoked method
140
141
  (issue #80). Anchor attribute calls inside the attribute name —
141
142
  its last character, so one-character names stay in range; other
142
- callee shapes keep the call-expression start.
143
+ callee shapes keep the call-expression start. A callee that is itself a
144
+ call has no name to anchor on, so it yields ``None`` (#215).
143
145
  """
144
146
  func_expr = node.func
145
147
  if isinstance(func_expr, ast.Attribute):
146
148
  return func_expr.end_lineno, func_expr.end_col_offset - 1
149
+ if isinstance(func_expr, ast.Call):
150
+ # `getattr(o, n)(x)`: the callee IS a call, so the expression start is
151
+ # the INNER call's name and inferring there labels this site a call to
152
+ # `getattr` -- the thing that produced the callee, not the callee (#215).
153
+ return None
147
154
  return node.lineno, node.col_offset
148
155
 
149
156
  @staticmethod
@@ -766,11 +773,18 @@ class SymbolTableBuilder:
766
773
  func_expr = node.func
767
774
 
768
775
  method_name = "<unknown>"
769
- anchor_line, anchor_col = self._callee_anchor(node)
770
- callee_signature, is_constructor = self._infer_callee(
771
- script, anchor_line, anchor_col
772
- )
773
- return_type = self._infer_call_return_type(script, anchor_line, anchor_col)
776
+ anchor = self._callee_anchor(node)
777
+ if anchor is None:
778
+ # A dynamic invocation: the site is recorded, and it resolves to
779
+ # nothing. Guessing a target here is what produced a graph full of
780
+ # calls to `builtins.getattr` (#215).
781
+ callee_signature, is_constructor, return_type = None, False, None
782
+ else:
783
+ anchor_line, anchor_col = anchor
784
+ callee_signature, is_constructor = self._infer_callee(
785
+ script, anchor_line, anchor_col
786
+ )
787
+ return_type = self._infer_call_return_type(script, anchor_line, anchor_col)
774
788
 
775
789
  receiver_expr = None
776
790
  receiver_type = None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: codeanalyzer-python
3
- Version: 1.5.2
3
+ Version: 1.5.4
4
4
  Summary: Static analysis for Python — canonical schema v2 (symbol table, call graph, and native CFG/PDG/SDG dataflow) as analysis.json or a Neo4j property graph.
5
5
  Author-email: Rahul Krishna <i.m.ralk@gmail.com>
6
6
  License-File: LICENSE
@@ -1,6 +1,6 @@
1
1
  codeanalyzer/__init__.py,sha256=BZ3Kuwl-F_F-8H8cepLnVJ4Ku4NNUjjqg0Y6ujPQSsI,108
2
- codeanalyzer/__main__.py,sha256=o5_9ct61l3T7ryIsag8bz304NabVV-el_ooGD8tnbhA,15968
3
- codeanalyzer/core.py,sha256=JfSsQEacBiVn38tQP34QWdJqQx7ecE70EXI8obISZN4,46598
2
+ codeanalyzer/__main__.py,sha256=noiZpfaiyFlnmdzHoWOAImXpxDkiQzTxSO4gmyZVyGI,15981
3
+ codeanalyzer/core.py,sha256=UoK-7xgzJx-f3A_fmmeEZkBshiOWNDwc_RJTPhCTU0Y,47655
4
4
  codeanalyzer/provenance.py,sha256=DT-DqwdVwO7g-GyK3sC0_4vDNCUjZYEY1fyYCt-7VRM,2306
5
5
  codeanalyzer/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
6
  codeanalyzer/artifacts/__init__.py,sha256=317sEeZLS1AYsfYDYIk7tLR3AdDFwWw_cmkeZDabkJo,906
@@ -8,12 +8,12 @@ codeanalyzer/artifacts/config_keys.py,sha256=ovwptAErLYutlCzUwvyim2kH6-6vUKxuRcB
8
8
  codeanalyzer/artifacts/config_use.py,sha256=1-jGacW249dwF7b6DOrTFuJ7sA5Ho9khgrgZqND0nro,27173
9
9
  codeanalyzer/artifacts/config_use_rules.yml,sha256=fNgI6IceOsj78LYmgoDH-vQ4-Mfb2ZFGKIQhLuoL4ik,1483
10
10
  codeanalyzer/artifacts/dependencies.py,sha256=h_-XV5KhrT-1Ytguw0c4fyI9zSFzn9Aj7kR66omvL3M,10853
11
- codeanalyzer/artifacts/discovery.py,sha256=cUVRSdV82G2hVGhkRGV7u24-tEAxUBmH9nkTg24ur0Y,5968
11
+ codeanalyzer/artifacts/discovery.py,sha256=urN4ZvtnXs0w9Kv2-yeOQza1TV1mjLL007zHfbdjNSg,8416
12
12
  codeanalyzer/artifacts/parsers.py,sha256=xOjC53tT0Mv0k7ONGwzl5XUvpBLnZlEcGmyrX5DS45k,9035
13
13
  codeanalyzer/dataflow/__init__.py,sha256=HCNOH24rAGLUMG94N9aC-2SsAWSntk3uys7e39ipU5o,1725
14
14
  codeanalyzer/dataflow/access_paths.py,sha256=wC8Q9qD-RZzkoFWMVvu_6uNNmYP8z48OGp9h9v3F1d4,23623
15
15
  codeanalyzer/dataflow/alias.py,sha256=ZYKY0DiR3GHv6YJr7C001Lode9rZsk6-gxdomUyIvwg,3928
16
- codeanalyzer/dataflow/builder.py,sha256=grzgpl82KzNiwlgdTx60INMxkTZvddT3JzgaBRO4xh4,32839
16
+ codeanalyzer/dataflow/builder.py,sha256=Ku16XuEJj46lnfuigMMyR6yheRW52f5U-ih9msxqSyo,33402
17
17
  codeanalyzer/dataflow/cfg.py,sha256=u5YXJjAaDshdgv-9kWWITbBJFP-MwXKq6hbNh0fhDm8,25307
18
18
  codeanalyzer/dataflow/defuse.py,sha256=LrX1ToOZzR79NlAGg5T647UAFkpiEqEnT7t0K5RXOiI,4377
19
19
  codeanalyzer/dataflow/dominance.py,sha256=X7Ki1QRdVqaseYsJtiUL7m9MbcC2WHEv9b8bczSLFUA,5086
@@ -21,7 +21,7 @@ codeanalyzer/dataflow/identity.py,sha256=6aAz3iPSOpoS7l_moC7XGr2RS_GqJp_FlsWGglM
21
21
  codeanalyzer/dataflow/pdg.py,sha256=1Bm7AnoRqXBryZulII2idPw0feV8eZQ1VqqprQ8PW-g,3731
22
22
  codeanalyzer/dataflow/scalpel_oracle.py,sha256=FxRadKrTWar0VKHyQJM40n3cq25sld3Sj4lMdOm5NFk,11494
23
23
  codeanalyzer/dataflow/scc.py,sha256=Doa_0-5f3agCu_5UmeEDlCUErg34TtniKIv8Uqd7Jiw,3493
24
- codeanalyzer/dataflow/sdg.py,sha256=taDXkIg0BZUtDEJUjRJB_dxrFl6tKNOUdFyj0zCiso0,18533
24
+ codeanalyzer/dataflow/sdg.py,sha256=_dcR6nNc0ZTETaLHbmliEMeTG-AcQU0x-7DiB1cvoPs,19445
25
25
  codeanalyzer/dataflow/slicing.py,sha256=lWZ7jHhlR8m7rECWJQXvwfs5GNZeJY55Cud3Ji5UbNU,3654
26
26
  codeanalyzer/dataflow/summaries.py,sha256=TLtc5h4bLBC4lViuMhMlEp5rp_nBpDPOl4_wfBuRUY4,9210
27
27
  codeanalyzer/dataflow/syntactic.py,sha256=AbHyXjKX_1xkGgKH48BpXYCWBauActGUwSMF_OD-uys,1124
@@ -38,7 +38,7 @@ codeanalyzer/dataflow/scalpel/core/func_call_visitor.py,sha256=ps0snjTchBXilhor3
38
38
  codeanalyzer/dataflow/scalpel/core/vars_visitor.py,sha256=gE5fNyJS6jslD6vsMRbFLoy3n1xTwH-b54mBcNYO72M,5660
39
39
  codeanalyzer/entrypoints/__init__.py,sha256=VaMd4mSEPLuPlRxxAve_nTJ9ZH62stXZGTPNwU_BQ50,99
40
40
  codeanalyzer/entrypoints/detect.py,sha256=fsWRQz1njvB9d3LIJEHxsNFE1GrB7q52KCvozw_UIUQ,4594
41
- codeanalyzer/entrypoints/matching.py,sha256=rQeY_w2dyG36DBUjle7GLXxqDKsoMhJYPjN4s11OGtM,7948
41
+ codeanalyzer/entrypoints/matching.py,sha256=fppta04o1_HWBEHe6boV_7T964cdLuZlOoQHAW4yt0Q,8321
42
42
  codeanalyzer/entrypoints/pipeline.py,sha256=vVSdttMfSAWour2fsC3LomMtwFLzRqmJHixJS82VY1I,7828
43
43
  codeanalyzer/entrypoints/rules.py,sha256=yG772JHmNc2L10jIpxfg1xTh-QouJa6lNU9ubo3EZYg,6003
44
44
  codeanalyzer/entrypoints/rules.yml,sha256=sguICRfDDNGjJPfZz-JSwkI_hzbTJaau1liHV_y4rTA,4637
@@ -47,32 +47,32 @@ codeanalyzer/jedi/jedi.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
47
47
  codeanalyzer/neo4j/__init__.py,sha256=8Ei6uThTOqmul6GhnKoGOjLXJZbyDwF5Jo-8I8NxnAk,1574
48
48
  codeanalyzer/neo4j/bolt.py,sha256=8l70iGDjY3Kdm-W1sb3b3O1uqLjDrVPYTpXhnkXdZOY,13694
49
49
  codeanalyzer/neo4j/cypher.py,sha256=-wledvXVrcLJRtSC5MCn1N3tqgC06rNfPBn0w6POqHo,6239
50
- codeanalyzer/neo4j/emit.py,sha256=OBCO77TDTaNpqk9Di3JIQzBouLpHPdgoXuPSs44iJuU,3587
51
- codeanalyzer/neo4j/project.py,sha256=gUAmIXLt0agWB1q1OS1Nzvlo1M6rqzzQymoy7lWxTps,42348
50
+ codeanalyzer/neo4j/emit.py,sha256=7QYosqiadH2GIsjaUSmSlJ5g8yLjAAcMplsmFaJLnC4,3599
51
+ codeanalyzer/neo4j/project.py,sha256=o6jdjwvHRxpNPNn-DXpOzebFq-fXgxoXTrKZ4_2ZVbQ,42383
52
52
  codeanalyzer/neo4j/rows.py,sha256=n_J3NVIusLPsHMjPCWaYvlL5sFm6V-kl_Z6B_BTH8Fk,10140
53
53
  codeanalyzer/neo4j/schema.py,sha256=0qZ1qJgmutlMHyIjdfD5N9QtA-gwl8Qcgh4NGguQEMs,18089
54
- codeanalyzer/options/__init__.py,sha256=6NewN0a-1HAEgKcxQjYyVn2WEDLrhax8h3WLgZddeqI,94
55
- codeanalyzer/options/options.py,sha256=2jDCcs74iSEOhz90LoRGts6PC8yV9IDPVxnLPNmonOA,1609
54
+ codeanalyzer/options/__init__.py,sha256=HvKzVXBI91lBM941JSFN7npG4S2fHlVEKo9VC2lRso8,156
55
+ codeanalyzer/options/options.py,sha256=DAo1i08D0K16jCivTrdOcBgKRPO50SngFZTPAL_kpmY,1862
56
56
  codeanalyzer/schema/__init__.py,sha256=hIyz02abUJNPW8yUgguaeKflZjTmOZWbDiKICNBD7yk,4141
57
57
  codeanalyzer/schema/assign_ids.py,sha256=pQHluLaO5a6hFREp3vjVRsUeAfTYSJ95RXS9TZvnoys,1590
58
58
  codeanalyzer/schema/call_graph_ids.py,sha256=mWAhJDBsW8i-siJiINOHCXEm4qM6F_5se9-HuHWIFqU,568
59
- codeanalyzer/schema/ids.py,sha256=YuioNK3Y-M2NcCBiGH4AB4rDezlqu6ycNO0R3O-Jxho,4388
60
- codeanalyzer/schema/l1_body.py,sha256=_sca0mTkMRb-5lasep87faGjDxZJd9jGB0hmBx3uCgk,1757
61
- codeanalyzer/schema/l2_callees.py,sha256=CTcBeGoO04DstDC6h-1sHMbbIrHDEOMxP01U9VLBcfc,2327
59
+ codeanalyzer/schema/ids.py,sha256=eShvV3R6oQt1lCQ2DFAhWekSWom63wQJDfeWXJ7KovQ,5986
60
+ codeanalyzer/schema/l1_body.py,sha256=-rBXZH1nhaCyQ2DVN-iIswuuSQ7Jsa8voSAaljv_c80,1737
61
+ codeanalyzer/schema/l2_callees.py,sha256=UGubBSLA_-GCK9Kak2rqkmoFlGifiImVnPDrypjHfrA,2441
62
62
  codeanalyzer/schema/py_schema.py,sha256=4xJto32nWqHx0dkge4qHsV2JZSjbyv4ojnqpyBm78sQ,24588
63
63
  codeanalyzer/semantic_analysis/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
64
64
  codeanalyzer/semantic_analysis/call_graph.py,sha256=6YEB_wTn5-oQYLrbIhJYE0BsHl4fpWPoy5Hwd9mTnGc,11918
65
- codeanalyzer/semantic_analysis/defuse_linker.py,sha256=bSn1rCun28OQqSd2NOc9aVBSm47Gt9Iul4r_gwfqO1w,64930
65
+ codeanalyzer/semantic_analysis/defuse_linker.py,sha256=3n1ew94eyBsSI98cFqP0uMHFUv7ob7QR9DL2hTKMHsQ,65099
66
66
  codeanalyzer/syntactic_analysis/__init__.py,sha256=EUQkJEh6wHjWx2qTTKbTbUgwSbfKeNieKHNy7RknVXA,476
67
67
  codeanalyzer/syntactic_analysis/exceptions.py,sha256=whs_n0vIu655Jkk1a7iOoXY6iIca4pZqJnU40V9Ejaw,537
68
68
  codeanalyzer/syntactic_analysis/import_resolver.py,sha256=Q8noZwSdDNt4P4NMqDTB9FaS-M0_d4ASK9GnJop9MDI,2751
69
- codeanalyzer/syntactic_analysis/symbol_table_builder.py,sha256=eTknBDlUuuQd3JEwbRtJt5pGVU89kOnzfmT4D5J_GKQ,47917
69
+ codeanalyzer/syntactic_analysis/symbol_table_builder.py,sha256=tKed-VCSd_albMeZfUuPRCcKz_WEBic38DU21gM8o7g,48797
70
70
  codeanalyzer/utils/__init__.py,sha256=hC6VWdR5rerSqBxzu9KQHTASWqwrrYJv-CMDwrTlzkc,137
71
71
  codeanalyzer/utils/logging.py,sha256=Fw0tattPAOMs3o0JMjjXhRVLIF64f-SCcygUXF9jqeg,904
72
72
  codeanalyzer/utils/progress_bar.py,sha256=C9JtzVdd10lIxTv-KA6PebqjKWueC_vMGwVzAtHuHIw,2818
73
- codeanalyzer_python-1.5.2.dist-info/METADATA,sha256=hivDuG8vX-aDD0NW-giMx8A4XXclXcFCtnFR5dYJF2g,44845
74
- codeanalyzer_python-1.5.2.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
75
- codeanalyzer_python-1.5.2.dist-info/entry_points.txt,sha256=v4Vux0Nnx7sOntVk_CH7W9RX6SkIkvR1FQYq73oVlCQ,105
76
- codeanalyzer_python-1.5.2.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
77
- codeanalyzer_python-1.5.2.dist-info/licenses/NOTICE,sha256=MdVkNYqHJ20on2FmWvgD4WpMsX5mir33xdyPvTXd0A0,1223
78
- codeanalyzer_python-1.5.2.dist-info/RECORD,,
73
+ codeanalyzer_python-1.5.4.dist-info/METADATA,sha256=pky-NFu9SoPYS5JNaPnJF4J-9yTTp02bDGA0qxkorpA,44845
74
+ codeanalyzer_python-1.5.4.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
75
+ codeanalyzer_python-1.5.4.dist-info/entry_points.txt,sha256=v4Vux0Nnx7sOntVk_CH7W9RX6SkIkvR1FQYq73oVlCQ,105
76
+ codeanalyzer_python-1.5.4.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
77
+ codeanalyzer_python-1.5.4.dist-info/licenses/NOTICE,sha256=MdVkNYqHJ20on2FmWvgD4WpMsX5mir33xdyPvTXd0A0,1223
78
+ codeanalyzer_python-1.5.4.dist-info/RECORD,,