codeanalyzer-python 1.5.1__py3-none-any.whl → 1.5.3__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 +2 -2
- codeanalyzer/artifacts/discovery.py +53 -1
- codeanalyzer/core.py +20 -1
- codeanalyzer/dataflow/builder.py +19 -9
- codeanalyzer/neo4j/emit.py +2 -2
- codeanalyzer/neo4j/project.py +79 -17
- codeanalyzer/neo4j/schema.py +52 -3
- codeanalyzer/options/__init__.py +2 -2
- codeanalyzer/options/options.py +7 -0
- codeanalyzer/schema/ids.py +37 -1
- codeanalyzer/schema/l1_body.py +2 -3
- codeanalyzer/schema/l2_callees.py +4 -3
- codeanalyzer/semantic_analysis/defuse_linker.py +7 -4
- codeanalyzer/syntactic_analysis/symbol_table_builder.py +22 -8
- {codeanalyzer_python-1.5.1.dist-info → codeanalyzer_python-1.5.3.dist-info}/METADATA +1 -1
- {codeanalyzer_python-1.5.1.dist-info → codeanalyzer_python-1.5.3.dist-info}/RECORD +20 -20
- {codeanalyzer_python-1.5.1.dist-info → codeanalyzer_python-1.5.3.dist-info}/WHEEL +0 -0
- {codeanalyzer_python-1.5.1.dist-info → codeanalyzer_python-1.5.3.dist-info}/entry_points.txt +0 -0
- {codeanalyzer_python-1.5.1.dist-info → codeanalyzer_python-1.5.3.dist-info}/licenses/LICENSE +0 -0
- {codeanalyzer_python-1.5.1.dist-info → codeanalyzer_python-1.5.3.dist-info}/licenses/NOTICE +0 -0
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 /
|
|
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,
|
codeanalyzer/dataflow/builder.py
CHANGED
|
@@ -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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
child
|
|
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
|
|
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(
|
|
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
|
codeanalyzer/neo4j/emit.py
CHANGED
|
@@ -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 /
|
|
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}")
|
codeanalyzer/neo4j/project.py
CHANGED
|
@@ -49,8 +49,10 @@ from codeanalyzer.schema import (
|
|
|
49
49
|
PyVariableDeclaration,
|
|
50
50
|
)
|
|
51
51
|
from codeanalyzer.schema import model_dump
|
|
52
|
-
from codeanalyzer.schema.ids import
|
|
53
|
-
|
|
52
|
+
from codeanalyzer.schema.ids import (
|
|
53
|
+
application_id, call_body_keys, external_id, global_ordinal, purl_pypi,
|
|
54
|
+
)
|
|
55
|
+
from codeanalyzer.schema.py_schema import PyDecorator, byte_offsets
|
|
54
56
|
|
|
55
57
|
|
|
56
58
|
def project(app: PyApplication, app_name: str, sig_to_id: dict,
|
|
@@ -182,6 +184,17 @@ def _project_program_graphs(
|
|
|
182
184
|
if not c.id:
|
|
183
185
|
continue # unstamped callable — assign_ids must run first
|
|
184
186
|
owner = _sym(c.id) # the :PyCallable node, keyed by its can:// id
|
|
187
|
+
# ``callee_signature`` lives on ``PyCallable.call_sites``, not on the body
|
|
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.
|
|
191
|
+
# ``argument_types`` is deliberately not joined: it is the legacy field #86
|
|
192
|
+
# split into ``PyCallArgument``, already carried as ``arguments_json``.
|
|
193
|
+
sig_by_key = {
|
|
194
|
+
key: cs.callee_signature
|
|
195
|
+
for key, cs in call_body_keys(c.call_sites)
|
|
196
|
+
if cs.callee_signature
|
|
197
|
+
}
|
|
185
198
|
for local_key, node in (c.body or {}).items():
|
|
186
199
|
span = node.span
|
|
187
200
|
# L4 param vertices carry the variable they model (``of``) and
|
|
@@ -194,8 +207,8 @@ def _project_program_graphs(
|
|
|
194
207
|
prune(
|
|
195
208
|
{
|
|
196
209
|
"kind": node.kind,
|
|
197
|
-
|
|
198
|
-
"
|
|
210
|
+
**_span_props(span),
|
|
211
|
+
"callee_signature": sig_by_key.get(local_key),
|
|
199
212
|
"var": node.of,
|
|
200
213
|
"call_node": node.parent,
|
|
201
214
|
# Call-site detail (#120). The JSON emits one node per
|
|
@@ -346,8 +359,7 @@ def _project_artifacts(b: RowBuilder, app: PyApplication, app_name: str, app_ref
|
|
|
346
359
|
"namespace": ck.namespace,
|
|
347
360
|
"value": ck.value,
|
|
348
361
|
"references": list(ck.references or []),
|
|
349
|
-
|
|
350
|
-
"end_line": ck.span.end[0] if ck.span else None,
|
|
362
|
+
**_span_props(ck.span),
|
|
351
363
|
}
|
|
352
364
|
),
|
|
353
365
|
)
|
|
@@ -569,7 +581,7 @@ def _project_module_body(
|
|
|
569
581
|
_project_class(b, file_key, mod_ref, "PY_DECLARES", cl, externals, sig_to_id,
|
|
570
582
|
mod.source, base_ref)
|
|
571
583
|
for v in mod.variables or []:
|
|
572
|
-
_project_variable(b, file_key, mod_ref, v)
|
|
584
|
+
_project_variable(b, file_key, mod_ref, v, mod.source or "")
|
|
573
585
|
_project_imports(b, mod_ref, mod, module_id_by_key)
|
|
574
586
|
|
|
575
587
|
|
|
@@ -597,9 +609,15 @@ def _project_imports(b: RowBuilder, mod_ref: NodeRef, mod: PyModule,
|
|
|
597
609
|
continue
|
|
598
610
|
key = im.resolved_module or im.module
|
|
599
611
|
a = agg.setdefault(
|
|
600
|
-
key,
|
|
612
|
+
key,
|
|
613
|
+
{"spellings": set(), "names": set(), "aliases": set(), "positions": {},
|
|
614
|
+
"resolved": im.resolved_module},
|
|
601
615
|
)
|
|
602
616
|
a["spellings"].add(im.module)
|
|
617
|
+
# Keyed by spelling, never by index: ``spellings`` is emitted sorted, so a
|
|
618
|
+
# parallel position array has already lost its alignment (#203).
|
|
619
|
+
a["positions"][im.module] = [im.start_line, im.start_column,
|
|
620
|
+
im.end_line, im.end_column]
|
|
603
621
|
if im.name:
|
|
604
622
|
a["names"].add(im.name)
|
|
605
623
|
if im.alias:
|
|
@@ -623,6 +641,7 @@ def _project_imports(b: RowBuilder, mod_ref: NodeRef, mod: PyModule,
|
|
|
623
641
|
"spellings": sorted(a["spellings"]),
|
|
624
642
|
"imported_names": sorted(a["names"]) or None,
|
|
625
643
|
"aliases": sorted(a["aliases"]) or None,
|
|
644
|
+
"positions_json": json.dumps(a["positions"], sort_keys=True),
|
|
626
645
|
}
|
|
627
646
|
),
|
|
628
647
|
)
|
|
@@ -675,7 +694,7 @@ def _project_callable(
|
|
|
675
694
|
_project_decorator(b, ref, d)
|
|
676
695
|
|
|
677
696
|
for v in c.local_variables or []:
|
|
678
|
-
_project_variable(b, file_key, ref, v)
|
|
697
|
+
_project_variable(b, file_key, ref, v, source)
|
|
679
698
|
for ic in (c.callables or {}).values():
|
|
680
699
|
_project_callable(b, file_key, ref, "PY_DECLARES", ic, externals, sig_to_id,
|
|
681
700
|
source, base_ref)
|
|
@@ -700,12 +719,14 @@ def _project_variable(
|
|
|
700
719
|
file_key: str,
|
|
701
720
|
owner: NodeRef,
|
|
702
721
|
v: PyVariableDeclaration,
|
|
722
|
+
source: str,
|
|
703
723
|
) -> None:
|
|
704
724
|
# ``<owner can:// id>/<name>@<line>`` (#173) — the owner is the module or the
|
|
705
725
|
# callable, so a module-level variable sits under ``<module-id>/`` like every
|
|
706
726
|
# other declaration and the module's prefix purge reaches it.
|
|
707
727
|
var_id = f"{owner.value}/{v.name}@{v.start_line}"
|
|
708
|
-
ref = b.node(["PyVariable"], "id", var_id,
|
|
728
|
+
ref = b.node(["PyVariable"], "id", var_id,
|
|
729
|
+
_variable_props(v, var_id, file_key, source))
|
|
709
730
|
b.edge("PY_DECLARES_VAR", owner, ref)
|
|
710
731
|
|
|
711
732
|
|
|
@@ -737,6 +758,7 @@ def _project_decorator(b: RowBuilder, on: NodeRef, decorator: PyDecorator) -> No
|
|
|
737
758
|
"keyword_arguments_json": json.dumps(
|
|
738
759
|
dict(decorator.keyword_arguments or {}), sort_keys=True
|
|
739
760
|
),
|
|
761
|
+
**_span_props(decorator.span),
|
|
740
762
|
},
|
|
741
763
|
)
|
|
742
764
|
|
|
@@ -752,6 +774,9 @@ def _module_props(mod: PyModule, file_key: str) -> Props:
|
|
|
752
774
|
"id": mod.id,
|
|
753
775
|
"file_key": file_key,
|
|
754
776
|
"module_name": mod.module_name,
|
|
777
|
+
# Always present, never pruned: "" for an empty file, so a consumer can
|
|
778
|
+
# never confuse "not carried" with "empty" (#202).
|
|
779
|
+
"source": mod.source or "",
|
|
755
780
|
"content_hash": mod.content_hash,
|
|
756
781
|
"last_modified": mod.last_modified,
|
|
757
782
|
"file_size": mod.file_size,
|
|
@@ -760,6 +785,36 @@ def _module_props(mod: PyModule, file_key: str) -> Props:
|
|
|
760
785
|
)
|
|
761
786
|
|
|
762
787
|
|
|
788
|
+
def _span_props(span) -> Props:
|
|
789
|
+
"""The six flattened span properties from a v2 ``Span`` (#202). Lines and columns
|
|
790
|
+
come straight off the model; ``bytes`` is already utf-8 and is what makes the span
|
|
791
|
+
sliceable out of ``:PyModule.source``."""
|
|
792
|
+
if span is None:
|
|
793
|
+
return {}
|
|
794
|
+
return {
|
|
795
|
+
"start_line": span.start[0], "start_column": span.start[1],
|
|
796
|
+
"end_line": span.end[0], "end_column": span.end[1],
|
|
797
|
+
"start_byte": span.bytes[0], "end_byte": span.bytes[1],
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
def _flat_span_props(source: str, start_line: int, start_column: int,
|
|
802
|
+
end_line: int, end_column: int) -> Props:
|
|
803
|
+
"""The same six for a model carrying flat ast positions and no ``Span``
|
|
804
|
+
(:PyAttribute, :PyVariable). The byte pair is computed here rather than left
|
|
805
|
+
absent, so ``_SPAN`` means one thing on every label that spreads it (#202)."""
|
|
806
|
+
if start_line < 0 or end_line < 0 or not source:
|
|
807
|
+
return {}
|
|
808
|
+
start_column = max(start_column, 0)
|
|
809
|
+
end_column = max(end_column, 0)
|
|
810
|
+
lo, hi = byte_offsets(source, start_line, start_column, end_line, end_column)
|
|
811
|
+
return {
|
|
812
|
+
"start_line": start_line, "start_column": start_column,
|
|
813
|
+
"end_line": end_line, "end_column": end_column,
|
|
814
|
+
"start_byte": lo, "end_byte": hi,
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
|
|
763
818
|
def _span_code(source: str, span) -> str | None:
|
|
764
819
|
"""A declaration's text: the owning module's ``source`` sliced by the node's
|
|
765
820
|
utf-8 byte span. Schema v2 stores source once per module, so the graph's
|
|
@@ -781,8 +836,8 @@ def _class_props(cl: PyClass, file_key: str, source: str) -> Props:
|
|
|
781
836
|
"base_classes": list(cl.base_classes or []),
|
|
782
837
|
"decorators": [d.qualified_name or d.name for d in (cl.decorators or [])],
|
|
783
838
|
"docstring": _docstring_of(cl.comments),
|
|
784
|
-
"start_line": cl.start_line,
|
|
785
|
-
|
|
839
|
+
**(_span_props(cl.span) or {"start_line": cl.start_line,
|
|
840
|
+
"end_line": cl.end_line}),
|
|
786
841
|
"_module": file_key,
|
|
787
842
|
"is_entrypoint": bool(cl.entrypoints),
|
|
788
843
|
"entrypoint_frameworks": sorted({e.framework for e in (cl.entrypoints or [])}),
|
|
@@ -801,8 +856,8 @@ def _callable_props(c: PyCallable, file_key: str, source: str) -> Props:
|
|
|
801
856
|
"cyclomatic_complexity": c.cyclomatic_complexity,
|
|
802
857
|
"code": _span_code(source, c.span),
|
|
803
858
|
"code_start_line": c.code_start_line,
|
|
804
|
-
"start_line": c.start_line,
|
|
805
|
-
|
|
859
|
+
**(_span_props(c.span) or {"start_line": c.start_line,
|
|
860
|
+
"end_line": c.end_line}),
|
|
806
861
|
"docstring": _docstring_of(c.comments),
|
|
807
862
|
"decorators": [d.qualified_name or d.name for d in (c.decorators or [])],
|
|
808
863
|
"modifiers": list(c.modifiers or []),
|
|
@@ -823,6 +878,8 @@ def _attribute_props(a: PyClassAttribute, attr_id: str, file_key: str) -> Props:
|
|
|
823
878
|
"type": a.type,
|
|
824
879
|
"initializer": a.initializer,
|
|
825
880
|
"docstring": _docstring_of(a.comments),
|
|
881
|
+
# Line-only: PyClassAttribute has no columns, so there is nothing to
|
|
882
|
+
# derive bytes from. See the note on :PyAttribute in schema.py.
|
|
826
883
|
"start_line": a.start_line,
|
|
827
884
|
"end_line": a.end_line,
|
|
828
885
|
"_module": file_key,
|
|
@@ -830,16 +887,21 @@ def _attribute_props(a: PyClassAttribute, attr_id: str, file_key: str) -> Props:
|
|
|
830
887
|
)
|
|
831
888
|
|
|
832
889
|
|
|
833
|
-
def _variable_props(v: PyVariableDeclaration, var_id: str, file_key: str
|
|
890
|
+
def _variable_props(v: PyVariableDeclaration, var_id: str, file_key: str,
|
|
891
|
+
source: str) -> Props:
|
|
834
892
|
return prune(
|
|
835
893
|
{
|
|
836
894
|
"id": var_id,
|
|
837
895
|
"name": v.name,
|
|
838
896
|
"type": v.type,
|
|
839
897
|
"initializer": v.initializer,
|
|
898
|
+
# ``value`` is Optional[Any] and a Neo4j property is a scalar or an array
|
|
899
|
+
# of scalars, so a dict/list value needs a serialization rather than a raw
|
|
900
|
+
# put -- always encoded, one shape for every value (#203).
|
|
901
|
+
"value_json": _stringify_if(v.value) if v.value is not None else None,
|
|
840
902
|
"scope": v.scope,
|
|
841
|
-
|
|
842
|
-
|
|
903
|
+
**_flat_span_props(source, v.start_line, v.start_column,
|
|
904
|
+
v.end_line, v.end_column),
|
|
843
905
|
"_module": file_key,
|
|
844
906
|
}
|
|
845
907
|
)
|
codeanalyzer/neo4j/schema.py
CHANGED
|
@@ -28,6 +28,13 @@ SCHEMA_VERSION is the contract version: bump MAJOR on a breaking change (renamed
|
|
|
28
28
|
relationship or key), MINOR on an additive change (new label/rel/property). It is stamped onto
|
|
29
29
|
the :PyApplication node of every emitted graph so any consumer can detect a producer/consumer
|
|
30
30
|
mismatch at runtime.
|
|
31
|
+
|
|
32
|
+
**The additive-MINOR rule is suspended for the 2.0.0 line.** Per the 2026-09-07 ruling (all three
|
|
33
|
+
analyzers), payload ``schema_version`` and this version both hold at ``2.0.0`` until the 2.0.0 line
|
|
34
|
+
leaves release-candidate, so the additive properties of #202/#203 ship without a bump and are
|
|
35
|
+
detectable only by presence. Consumers gate on the **analyzer version** instead -- the python-sdk
|
|
36
|
+
Neo4j backends carry an analyzer floor and refuse anything below it at attach. The rule above
|
|
37
|
+
resumes at the coordinated re-baseline.
|
|
31
38
|
"""
|
|
32
39
|
|
|
33
40
|
from __future__ import annotations
|
|
@@ -61,7 +68,18 @@ class RelType:
|
|
|
61
68
|
# anchor for the prefix-scoped destructive statements (see ``rows.CAN_NODE``).
|
|
62
69
|
MARKER_LABELS: List[str] = ["PyCanNode"]
|
|
63
70
|
|
|
64
|
-
|
|
71
|
+
# The flattened span. ``start_column``/``end_column``/``start_byte``/``end_byte``
|
|
72
|
+
# are adopted **verbatim** from codeanalyzer-java#255, which coined them: a term
|
|
73
|
+
# coined twice is permanently wrong under the cross-language parity clause. The
|
|
74
|
+
# byte pair makes every span sliceable out of :PyModule.source -- for the labels
|
|
75
|
+
# whose JSON model carries flat ast positions and no ``Span`` (:PyAttribute,
|
|
76
|
+
# :PyVariable) the projector computes it, so this dict means one thing on every
|
|
77
|
+
# label that spreads it (#202).
|
|
78
|
+
_SPAN = {
|
|
79
|
+
"start_line": "integer", "end_line": "integer",
|
|
80
|
+
"start_column": "integer", "end_column": "integer",
|
|
81
|
+
"start_byte": "integer", "end_byte": "integer",
|
|
82
|
+
}
|
|
65
83
|
|
|
66
84
|
|
|
67
85
|
NODE_LABELS: List[NodeLabel] = [
|
|
@@ -90,6 +108,10 @@ NODE_LABELS: List[NodeLabel] = [
|
|
|
90
108
|
"id": "string",
|
|
91
109
|
"file_key": "string",
|
|
92
110
|
"module_name": "string",
|
|
111
|
+
# The primary text: schema v2 stores source once per module and every
|
|
112
|
+
# narrower node's text is a byte slice of it. Always present -- an empty
|
|
113
|
+
# file yields "", so "not carried" is never confusable with "empty" (#202).
|
|
114
|
+
"source": "string",
|
|
93
115
|
"content_hash": "string",
|
|
94
116
|
"last_modified": "float",
|
|
95
117
|
"file_size": "integer",
|
|
@@ -158,7 +180,13 @@ NODE_LABELS: List[NodeLabel] = [
|
|
|
158
180
|
"type": "string",
|
|
159
181
|
"initializer": "string",
|
|
160
182
|
"docstring": "string",
|
|
161
|
-
|
|
183
|
+
# The one _SPAN exception: ``PyClassAttribute`` carries start/end LINE only
|
|
184
|
+
# -- no columns, hence no derivable byte offsets. Emitting a fabricated
|
|
185
|
+
# column 0 would make the span unsliceable while claiming otherwise, so the
|
|
186
|
+
# line pair is declared honestly instead. Filed for the JSON model to gain
|
|
187
|
+
# columns; until then this label is line-granular (#203).
|
|
188
|
+
"start_line": "integer",
|
|
189
|
+
"end_line": "integer",
|
|
162
190
|
},
|
|
163
191
|
),
|
|
164
192
|
NodeLabel(
|
|
@@ -170,6 +198,11 @@ NODE_LABELS: List[NodeLabel] = [
|
|
|
170
198
|
"name": "string",
|
|
171
199
|
"type": "string",
|
|
172
200
|
"initializer": "string",
|
|
201
|
+
# ``PyVariableDeclaration.value`` -- the literal-evaluated result, always
|
|
202
|
+
# JSON-encoded because it is ``Optional[Any]`` and a Neo4j property is a
|
|
203
|
+
# scalar or an array of scalars (the ``arguments_json`` precedent, #203).
|
|
204
|
+
# ``initializer`` stays the raw source text.
|
|
205
|
+
"value_json": "string",
|
|
173
206
|
"scope": "string",
|
|
174
207
|
**_SPAN,
|
|
175
208
|
},
|
|
@@ -197,6 +230,12 @@ NODE_LABELS: List[NodeLabel] = [
|
|
|
197
230
|
"return_type": "string",
|
|
198
231
|
"is_constructor_call": "boolean",
|
|
199
232
|
"arguments_json": "string",
|
|
233
|
+
# What distinguishes overload targets at a resolved call site. Joined
|
|
234
|
+
# from ``PyCallable.call_sites`` at projection time, since ``BodyNode``
|
|
235
|
+
# does not carry it in JSON (#203). ``argument_types`` is deliberately
|
|
236
|
+
# NOT here: it is the legacy field #86 split into ``PyCallArgument``,
|
|
237
|
+
# already carried as ``arguments_json``.
|
|
238
|
+
"callee_signature": "string",
|
|
200
239
|
**_SPAN,
|
|
201
240
|
},
|
|
202
241
|
),
|
|
@@ -247,16 +286,26 @@ REL_TYPES: List[RelType] = [
|
|
|
247
286
|
"PY_IMPORTS",
|
|
248
287
|
["PyModule"],
|
|
249
288
|
["PyModule", "PyPackage"],
|
|
250
|
-
|
|
289
|
+
# ``positions_json`` keys on the spelling, not on an index: this edge
|
|
290
|
+
# pre-aggregates per (module, target) and emits ``spellings`` sorted, so
|
|
291
|
+
# parallel position arrays have already lost their alignment (#203).
|
|
292
|
+
{"spellings": "string[]", "imported_names": "string[]", "aliases": "string[]",
|
|
293
|
+
"positions_json": "string"},
|
|
251
294
|
),
|
|
252
295
|
RelType(
|
|
253
296
|
"PY_DECORATED_BY",
|
|
254
297
|
["PyCallable", "PyClass"],
|
|
255
298
|
["PyDecorator"],
|
|
299
|
+
# The span rides here, not on :PyDecorator: that node is merged on the
|
|
300
|
+
# resolved ``qualified_name`` and carries no ``_module``, so it is never
|
|
301
|
+
# pruned and any per-application fact on it would accumulate across every
|
|
302
|
+
# project in the database -- the same reason ``expression`` and the
|
|
303
|
+
# arguments already ride the relationship (#203).
|
|
256
304
|
{
|
|
257
305
|
"expression": "string",
|
|
258
306
|
"positional_arguments": "string[]",
|
|
259
307
|
"keyword_arguments_json": "string",
|
|
308
|
+
**_SPAN,
|
|
260
309
|
},
|
|
261
310
|
),
|
|
262
311
|
# Level-3 CPG overlay (-a 3 only): the cross-language dataflow vocabulary,
|
codeanalyzer/options/__init__.py
CHANGED
|
@@ -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"]
|
codeanalyzer/options/options.py
CHANGED
|
@@ -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
|
|
codeanalyzer/schema/ids.py
CHANGED
|
@@ -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`,
|
codeanalyzer/schema/l1_body.py
CHANGED
|
@@ -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
|
|
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 +
|
|
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
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
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.
|
|
3
|
+
Version: 1.5.3
|
|
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=
|
|
3
|
-
codeanalyzer/core.py,sha256=
|
|
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=
|
|
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=
|
|
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
|
|
@@ -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=
|
|
51
|
-
codeanalyzer/neo4j/project.py,sha256=
|
|
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
|
-
codeanalyzer/neo4j/schema.py,sha256=
|
|
54
|
-
codeanalyzer/options/__init__.py,sha256=
|
|
55
|
-
codeanalyzer/options/options.py,sha256=
|
|
53
|
+
codeanalyzer/neo4j/schema.py,sha256=0qZ1qJgmutlMHyIjdfD5N9QtA-gwl8Qcgh4NGguQEMs,18089
|
|
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=
|
|
60
|
-
codeanalyzer/schema/l1_body.py,sha256
|
|
61
|
-
codeanalyzer/schema/l2_callees.py,sha256=
|
|
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=
|
|
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=
|
|
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.
|
|
74
|
-
codeanalyzer_python-1.5.
|
|
75
|
-
codeanalyzer_python-1.5.
|
|
76
|
-
codeanalyzer_python-1.5.
|
|
77
|
-
codeanalyzer_python-1.5.
|
|
78
|
-
codeanalyzer_python-1.5.
|
|
73
|
+
codeanalyzer_python-1.5.3.dist-info/METADATA,sha256=mYN0yUedKfOOzWquD1DxyaoE3c9yUpK9Prs9AXqj6d0,44845
|
|
74
|
+
codeanalyzer_python-1.5.3.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
75
|
+
codeanalyzer_python-1.5.3.dist-info/entry_points.txt,sha256=v4Vux0Nnx7sOntVk_CH7W9RX6SkIkvR1FQYq73oVlCQ,105
|
|
76
|
+
codeanalyzer_python-1.5.3.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
|
77
|
+
codeanalyzer_python-1.5.3.dist-info/licenses/NOTICE,sha256=MdVkNYqHJ20on2FmWvgD4WpMsX5mir33xdyPvTXd0A0,1223
|
|
78
|
+
codeanalyzer_python-1.5.3.dist-info/RECORD,,
|
|
File without changes
|
{codeanalyzer_python-1.5.1.dist-info → codeanalyzer_python-1.5.3.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{codeanalyzer_python-1.5.1.dist-info → codeanalyzer_python-1.5.3.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|
|
File without changes
|