codeanalyzer-python 1.1.1__py3-none-any.whl → 1.3.0__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 +119 -118
- codeanalyzer/artifacts/__init__.py +20 -0
- codeanalyzer/artifacts/config_keys.py +588 -0
- codeanalyzer/artifacts/config_use.py +597 -0
- codeanalyzer/artifacts/config_use_rules.yml +58 -0
- codeanalyzer/artifacts/dependencies.py +237 -0
- codeanalyzer/artifacts/discovery.py +167 -0
- codeanalyzer/artifacts/parsers.py +248 -0
- codeanalyzer/core.py +112 -45
- codeanalyzer/dataflow/access_paths.py +26 -4
- codeanalyzer/dataflow/builder.py +22 -1
- codeanalyzer/dataflow/identity.py +1 -1
- codeanalyzer/dataflow/pdg.py +7 -2
- codeanalyzer/dataflow/scc.py +1 -1
- codeanalyzer/entrypoints/__init__.py +3 -0
- codeanalyzer/entrypoints/detect.py +124 -0
- codeanalyzer/entrypoints/matching.py +182 -0
- codeanalyzer/entrypoints/pipeline.py +131 -0
- codeanalyzer/entrypoints/rules.py +159 -0
- codeanalyzer/entrypoints/rules.yml +88 -0
- codeanalyzer/neo4j/bolt.py +1 -1
- codeanalyzer/neo4j/project.py +277 -60
- codeanalyzer/neo4j/schema.py +92 -34
- codeanalyzer/options/__init__.py +2 -2
- codeanalyzer/options/options.py +7 -26
- codeanalyzer/schema/__init__.py +48 -0
- codeanalyzer/schema/ids.py +21 -0
- codeanalyzer/schema/l1_body.py +11 -1
- codeanalyzer/schema/l2_callees.py +29 -13
- codeanalyzer/schema/py_schema.py +213 -103
- codeanalyzer/semantic_analysis/call_graph.py +20 -4
- codeanalyzer/semantic_analysis/defuse_linker.py +1499 -0
- codeanalyzer/syntactic_analysis/symbol_table_builder.py +99 -3
- {codeanalyzer_python-1.1.1.dist-info → codeanalyzer_python-1.3.0.dist-info}/METADATA +143 -164
- {codeanalyzer_python-1.1.1.dist-info → codeanalyzer_python-1.3.0.dist-info}/RECORD +39 -31
- {codeanalyzer_python-1.1.1.dist-info → codeanalyzer_python-1.3.0.dist-info}/WHEEL +1 -1
- codeanalyzer/config/__init__.py +0 -3
- codeanalyzer/config/config.py +0 -8
- codeanalyzer/semantic_analysis/pycg/__init__.py +0 -20
- codeanalyzer/semantic_analysis/pycg/pycg_analysis.py +0 -1115
- codeanalyzer/semantic_analysis/pycg/pycg_exceptions.py +0 -23
- codeanalyzer/semantic_analysis/pycg/shard_planner.py +0 -401
- {codeanalyzer_python-1.1.1.dist-info → codeanalyzer_python-1.3.0.dist-info}/entry_points.txt +0 -0
- {codeanalyzer_python-1.1.1.dist-info → codeanalyzer_python-1.3.0.dist-info}/licenses/LICENSE +0 -0
- {codeanalyzer_python-1.1.1.dist-info → codeanalyzer_python-1.3.0.dist-info}/licenses/NOTICE +0 -0
codeanalyzer/options/__init__.py
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
from .options import AnalysisOptions, EmitTarget
|
|
1
|
+
from .options import AnalysisOptions, EmitTarget
|
|
2
2
|
|
|
3
|
-
__all__ = ["AnalysisOptions", "EmitTarget"
|
|
3
|
+
__all__ = ["AnalysisOptions", "EmitTarget"]
|
codeanalyzer/options/options.py
CHANGED
|
@@ -1,14 +1,9 @@
|
|
|
1
1
|
from dataclasses import dataclass
|
|
2
2
|
from pathlib import Path
|
|
3
|
-
from typing import Optional
|
|
3
|
+
from typing import Optional, Tuple
|
|
4
4
|
from enum import Enum
|
|
5
5
|
|
|
6
6
|
|
|
7
|
-
class OutputFormat(str, Enum):
|
|
8
|
-
JSON = "json"
|
|
9
|
-
MSGPACK = "msgpack"
|
|
10
|
-
|
|
11
|
-
|
|
12
7
|
class EmitTarget(str, Enum):
|
|
13
8
|
"""Output target selected by ``--emit``.
|
|
14
9
|
|
|
@@ -23,25 +18,10 @@ class EmitTarget(str, Enum):
|
|
|
23
18
|
SCHEMA = "schema"
|
|
24
19
|
|
|
25
20
|
|
|
26
|
-
class ShardStrategy(str, Enum):
|
|
27
|
-
"""How ``--pycg-shard`` groups files into shards (level 2 only).
|
|
28
|
-
|
|
29
|
-
- ``jedi`` : partition the Jedi module-dependency graph (strongly-
|
|
30
|
-
connected-component condensation + Louvain) so tightly-
|
|
31
|
-
coupled modules co-compute and few call edges are severed
|
|
32
|
-
between shards. Import cycles are never split.
|
|
33
|
-
- ``package`` : legacy one-shard-per-package-directory grouping.
|
|
34
|
-
"""
|
|
35
|
-
|
|
36
|
-
JEDI = "jedi"
|
|
37
|
-
PACKAGE = "package"
|
|
38
|
-
|
|
39
|
-
|
|
40
21
|
@dataclass
|
|
41
22
|
class AnalysisOptions:
|
|
42
23
|
input: Path
|
|
43
24
|
output: Optional[Path] = None
|
|
44
|
-
format: OutputFormat = OutputFormat.JSON
|
|
45
25
|
emit: EmitTarget = EmitTarget.JSON
|
|
46
26
|
app_name: Optional[str] = None
|
|
47
27
|
neo4j_uri: Optional[str] = None
|
|
@@ -57,12 +37,13 @@ class AnalysisOptions:
|
|
|
57
37
|
rebuild_analysis: bool = False
|
|
58
38
|
skip_tests: bool = True
|
|
59
39
|
no_venv: bool = False
|
|
40
|
+
resolve_installed: bool = False
|
|
60
41
|
file_name: Optional[Path] = None
|
|
61
42
|
cache_dir: Optional[Path] = None
|
|
62
43
|
clear_cache: bool = False
|
|
63
44
|
verbosity: int = 0
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
45
|
+
entrypoint_rules: Tuple[Path, ...] = ()
|
|
46
|
+
# Artifact text-capture controls (#157 follow-up): whether to capture
|
|
47
|
+
# `source` at all, and the per-file byte cap before it truncates.
|
|
48
|
+
artifact_text: bool = True
|
|
49
|
+
artifact_text_max_bytes: int = 262144
|
codeanalyzer/schema/__init__.py
CHANGED
|
@@ -62,6 +62,31 @@ if not PYDANTIC_V2:
|
|
|
62
62
|
)
|
|
63
63
|
Analysis.update_forward_refs(PyApplication=PyApplication)
|
|
64
64
|
|
|
65
|
+
# Fields the analyzer keeps in memory (and in the cache) but never emits.
|
|
66
|
+
# `call_sites` is the internal record `body{}` call nodes are derived from (#120);
|
|
67
|
+
# emitting both shipped the same fact twice under two identity schemes.
|
|
68
|
+
INTERNAL_ONLY_FIELDS = frozenset({"call_sites"})
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def strip_internal_only(data):
|
|
72
|
+
"""Recursively drop `INTERNAL_ONLY_FIELDS` from a dumped payload.
|
|
73
|
+
|
|
74
|
+
Applied at emit time rather than as a field-level Pydantic `exclude`, because
|
|
75
|
+
the analysis cache uses the same serializer -- excluding at the field would
|
|
76
|
+
drop these from the cache as well, and the next warm-cache run would rebuild
|
|
77
|
+
from a payload with no call sites.
|
|
78
|
+
"""
|
|
79
|
+
if isinstance(data, dict):
|
|
80
|
+
return {
|
|
81
|
+
k: strip_internal_only(v)
|
|
82
|
+
for k, v in data.items()
|
|
83
|
+
if k not in INTERNAL_ONLY_FIELDS
|
|
84
|
+
}
|
|
85
|
+
if isinstance(data, list):
|
|
86
|
+
return [strip_internal_only(v) for v in data]
|
|
87
|
+
return data
|
|
88
|
+
|
|
89
|
+
|
|
65
90
|
# Compatibility helpers for Pydantic v1/v2
|
|
66
91
|
def model_dump_json(model, **kwargs):
|
|
67
92
|
"""Compatibility helper for JSON serialization."""
|
|
@@ -79,6 +104,27 @@ def model_dump_json(model, **kwargs):
|
|
|
79
104
|
v1_kwargs['separators'] = kwargs['separators']
|
|
80
105
|
return model.json(**v1_kwargs)
|
|
81
106
|
|
|
107
|
+
def model_dump(model, **kwargs):
|
|
108
|
+
"""Compatibility helper for dict serialization (v2 model_dump / v1 dict).
|
|
109
|
+
|
|
110
|
+
``mode="json"`` (v2) maps to a json round-trip on v1 so both versions
|
|
111
|
+
yield JSON-safe primitives.
|
|
112
|
+
"""
|
|
113
|
+
if PYDANTIC_V2:
|
|
114
|
+
return model.model_dump(**kwargs)
|
|
115
|
+
import json as _json
|
|
116
|
+
mode = kwargs.pop("mode", None)
|
|
117
|
+
v1_kwargs = {k: v for k, v in kwargs.items() if k in ("exclude_none", "exclude")}
|
|
118
|
+
if mode == "json":
|
|
119
|
+
return _json.loads(model.json(**v1_kwargs))
|
|
120
|
+
return model.dict(**v1_kwargs)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def model_copy(model):
|
|
124
|
+
"""Compatibility helper for copying a model (v2 model_copy / v1 copy)."""
|
|
125
|
+
return model.model_copy() if PYDANTIC_V2 else model.copy()
|
|
126
|
+
|
|
127
|
+
|
|
82
128
|
def model_validate_json(model_class, json_data):
|
|
83
129
|
"""Compatibility helper for JSON deserialization."""
|
|
84
130
|
if PYDANTIC_V2:
|
|
@@ -89,5 +135,7 @@ def model_validate_json(model_class, json_data):
|
|
|
89
135
|
__all__.extend([
|
|
90
136
|
"PYDANTIC_V2",
|
|
91
137
|
"model_dump_json",
|
|
138
|
+
"strip_internal_only",
|
|
139
|
+
"INTERNAL_ONLY_FIELDS",
|
|
92
140
|
"model_validate_json"
|
|
93
141
|
])
|
codeanalyzer/schema/ids.py
CHANGED
|
@@ -21,3 +21,24 @@ def callable_sig_segment(name: str, param_names: List[str]) -> str:
|
|
|
21
21
|
|
|
22
22
|
def ordinal_id(callable_id: str, tag: str) -> str:
|
|
23
23
|
return f"{callable_id}@{tag}"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def artifact_id(app_name: str, rel_path: str) -> str:
|
|
27
|
+
"""Language-neutral artifact id: ``can://artifact/<app>/<rel-path>``.
|
|
28
|
+
|
|
29
|
+
The first segment is a namespace (a language for code nodes, the literal
|
|
30
|
+
``artifact`` for files), so sibling analyzers over the same repo emit the
|
|
31
|
+
same id for the same file."""
|
|
32
|
+
return f"can://artifact/{app_name}/{rel_path}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def config_key_id(artifact_id: str, dotted_key: str) -> str:
|
|
36
|
+
"""A ``PyConfigKey`` extracted from an artifact: ``<artifact-id>@key/<dotted.key>``.
|
|
37
|
+
``dotted_key`` uses numeric segments for array indices (e.g.
|
|
38
|
+
``services.web.ports.0``); ids are opaque, do not re-split them."""
|
|
39
|
+
return f"{artifact_id}@key/{dotted_key}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def purl_pypi(name: str) -> str:
|
|
43
|
+
"""Package URL for a (PEP 503 normalized) PyPI distribution name."""
|
|
44
|
+
return f"pkg:pypi/{name}"
|
codeanalyzer/schema/l1_body.py
CHANGED
|
@@ -9,7 +9,17 @@ def _do_callable(source: str, c: PyCallable) -> None:
|
|
|
9
9
|
span = Span(start=(cs.start_line, cs.start_column),
|
|
10
10
|
end=(cs.end_line, cs.end_column),
|
|
11
11
|
bytes=byte_offsets(source, cs.start_line, cs.start_column, cs.end_line, cs.end_column)) if source else None
|
|
12
|
-
c.body[key] = BodyNode(
|
|
12
|
+
c.body[key] = BodyNode(
|
|
13
|
+
kind="call",
|
|
14
|
+
span=span,
|
|
15
|
+
callee=None,
|
|
16
|
+
method_name=cs.method_name,
|
|
17
|
+
receiver_expr=cs.receiver_expr,
|
|
18
|
+
receiver_type=cs.receiver_type,
|
|
19
|
+
return_type=cs.return_type,
|
|
20
|
+
is_constructor_call=cs.is_constructor_call,
|
|
21
|
+
arguments=list(cs.arguments or []),
|
|
22
|
+
)
|
|
13
23
|
for ic in (c.callables or {}).values():
|
|
14
24
|
_do_callable(source, ic)
|
|
15
25
|
for icl in (c.types or {}).values():
|
|
@@ -1,36 +1,52 @@
|
|
|
1
1
|
"""L2 refinement: fill each L1 `call` body node's `callee` (null→id) from the
|
|
2
2
|
call site's resolved signature — the one sanctioned value change. A declared
|
|
3
3
|
target becomes its can:// id; an external/library target keeps its dotted
|
|
4
|
-
signature; an unresolved call site leaves `callee` absent.
|
|
4
|
+
signature; an unresolved call site leaves `callee` absent.
|
|
5
|
+
|
|
6
|
+
Two resolution sources feed the backfill: Jedi's `callee_signature` on the
|
|
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
|
|
9
|
+
into `callee_signature` — the symbol table round-trips through the analysis
|
|
10
|
+
cache, and a persisted resolution would resurface on a warm run as a Jedi
|
|
11
|
+
edge, silently changing provenance."""
|
|
5
12
|
from __future__ import annotations
|
|
6
13
|
from codeanalyzer.schema.py_schema import PyApplication, PyClass, PyCallable
|
|
7
14
|
|
|
8
15
|
|
|
9
|
-
def _do_callable(c: PyCallable, sig_to_id: dict) -> None:
|
|
16
|
+
def _do_callable(c: PyCallable, sig_to_id: dict, resolutions: dict) -> None:
|
|
10
17
|
for cs in c.call_sites or []:
|
|
11
|
-
if cs.callee_signature is None:
|
|
12
|
-
continue
|
|
13
18
|
key = f"{cs.start_line}:{cs.start_column}"
|
|
19
|
+
jedi_sig = cs.callee_signature
|
|
20
|
+
if jedi_sig and jedi_sig.startswith("typing."):
|
|
21
|
+
# A decorator-typed callable resolved to its annotation, not a
|
|
22
|
+
# target; the linker's resolution (if any) is the real callee.
|
|
23
|
+
jedi_sig = None
|
|
24
|
+
sig = jedi_sig or resolutions.get((c.signature, key))
|
|
25
|
+
if not sig:
|
|
26
|
+
continue
|
|
14
27
|
node = c.body.get(key)
|
|
15
28
|
if node is None or node.kind != "call":
|
|
16
29
|
continue
|
|
17
|
-
node.callee = sig_to_id.get(
|
|
30
|
+
node.callee = sig_to_id.get(sig, sig)
|
|
18
31
|
for ic in (c.callables or {}).values():
|
|
19
|
-
_do_callable(ic, sig_to_id)
|
|
32
|
+
_do_callable(ic, sig_to_id, resolutions)
|
|
20
33
|
for icl in (c.types or {}).values():
|
|
21
|
-
_do_class(icl, sig_to_id)
|
|
34
|
+
_do_class(icl, sig_to_id, resolutions)
|
|
22
35
|
|
|
23
36
|
|
|
24
|
-
def _do_class(cl: PyClass, sig_to_id: dict) -> None:
|
|
37
|
+
def _do_class(cl: PyClass, sig_to_id: dict, resolutions: dict) -> None:
|
|
25
38
|
for m in (cl.callables or {}).values():
|
|
26
|
-
_do_callable(m, sig_to_id)
|
|
39
|
+
_do_callable(m, sig_to_id, resolutions)
|
|
27
40
|
for ic in (cl.types or {}).values():
|
|
28
|
-
_do_class(ic, sig_to_id)
|
|
41
|
+
_do_class(ic, sig_to_id, resolutions)
|
|
29
42
|
|
|
30
43
|
|
|
31
|
-
def backfill_callees(
|
|
44
|
+
def backfill_callees(
|
|
45
|
+
app: PyApplication, sig_to_id: dict, resolutions: dict | None = None
|
|
46
|
+
) -> None:
|
|
47
|
+
resolutions = resolutions or {}
|
|
32
48
|
for mod in app.symbol_table.values():
|
|
33
49
|
for fn in (mod.functions or {}).values():
|
|
34
|
-
_do_callable(fn, sig_to_id)
|
|
50
|
+
_do_callable(fn, sig_to_id, resolutions)
|
|
35
51
|
for cl in (mod.types or {}).values():
|
|
36
|
-
_do_class(cl, sig_to_id)
|
|
52
|
+
_do_class(cl, sig_to_id, resolutions)
|