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/schema/py_schema.py
CHANGED
|
@@ -22,85 +22,8 @@ for static analysis purposes.
|
|
|
22
22
|
from __future__ import annotations
|
|
23
23
|
from pathlib import Path
|
|
24
24
|
from typing import Any, Dict, List, Optional, Tuple
|
|
25
|
-
import gzip
|
|
26
|
-
|
|
27
25
|
from pydantic import BaseModel
|
|
28
26
|
from typing_extensions import Literal
|
|
29
|
-
import msgpack
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def msgpk(cls):
|
|
33
|
-
"""
|
|
34
|
-
Decorator that adds MessagePack serialization methods to Pydantic models.
|
|
35
|
-
|
|
36
|
-
Adds methods:
|
|
37
|
-
- to_msgpack_bytes() -> bytes: Serialize to compact binary format
|
|
38
|
-
- from_msgpack_bytes(data: bytes) -> cls: Deserialize from binary format
|
|
39
|
-
- to_msgpack_dict() -> dict: Convert to msgpack-compatible dict
|
|
40
|
-
- from_msgpack_dict(data: dict) -> cls: Create instance from msgpack dict
|
|
41
|
-
"""
|
|
42
|
-
|
|
43
|
-
def _prepare_for_serialization(obj: Any) -> Any:
|
|
44
|
-
"""Convert objects to serialization-friendly format."""
|
|
45
|
-
if isinstance(obj, Path):
|
|
46
|
-
return str(obj)
|
|
47
|
-
elif isinstance(obj, dict):
|
|
48
|
-
return {
|
|
49
|
-
_prepare_for_serialization(k): _prepare_for_serialization(v)
|
|
50
|
-
for k, v in obj.items()
|
|
51
|
-
}
|
|
52
|
-
elif isinstance(obj, list):
|
|
53
|
-
return [_prepare_for_serialization(item) for item in obj]
|
|
54
|
-
elif isinstance(obj, tuple):
|
|
55
|
-
return tuple(_prepare_for_serialization(item) for item in obj)
|
|
56
|
-
elif isinstance(obj, set):
|
|
57
|
-
return [_prepare_for_serialization(item) for item in obj]
|
|
58
|
-
elif hasattr(obj, "model_dump"): # Pydantic model
|
|
59
|
-
return _prepare_for_serialization(obj.model_dump())
|
|
60
|
-
else:
|
|
61
|
-
return obj
|
|
62
|
-
|
|
63
|
-
def to_msgpack_bytes(self) -> bytes:
|
|
64
|
-
"""Serialize the model to compact binary format using MessagePack + gzip."""
|
|
65
|
-
data = _prepare_for_serialization(self.model_dump())
|
|
66
|
-
msgpack_data = msgpack.packb(data, use_bin_type=True)
|
|
67
|
-
return gzip.compress(msgpack_data)
|
|
68
|
-
|
|
69
|
-
@classmethod
|
|
70
|
-
def from_msgpack_bytes(cls_obj, data: bytes):
|
|
71
|
-
"""Deserialize from MessagePack + gzip binary format."""
|
|
72
|
-
decompressed_data = gzip.decompress(data)
|
|
73
|
-
obj_dict = msgpack.unpackb(decompressed_data, raw=False)
|
|
74
|
-
return cls_obj.model_validate(obj_dict)
|
|
75
|
-
|
|
76
|
-
def to_msgpack_dict(self) -> dict:
|
|
77
|
-
"""Convert to msgpack-compatible dictionary format."""
|
|
78
|
-
return _prepare_for_serialization(self.model_dump())
|
|
79
|
-
|
|
80
|
-
@classmethod
|
|
81
|
-
def from_msgpack_dict(cls_obj, data: dict):
|
|
82
|
-
"""Create instance from msgpack-compatible dictionary."""
|
|
83
|
-
return cls_obj.model_validate(data)
|
|
84
|
-
|
|
85
|
-
def get_msgpack_size(self) -> int:
|
|
86
|
-
"""Get the size of the msgpack serialization in bytes."""
|
|
87
|
-
return len(self.to_msgpack_bytes())
|
|
88
|
-
|
|
89
|
-
def get_compression_ratio(self) -> float:
|
|
90
|
-
"""Get compression ratio compared to JSON."""
|
|
91
|
-
json_size = len(self.model_dump_json().encode("utf-8"))
|
|
92
|
-
msgpack_gzip_size = self.get_msgpack_size()
|
|
93
|
-
return msgpack_gzip_size / json_size if json_size > 0 else 1.0
|
|
94
|
-
|
|
95
|
-
# Add methods to the class
|
|
96
|
-
cls.to_msgpack_bytes = to_msgpack_bytes
|
|
97
|
-
cls.from_msgpack_bytes = from_msgpack_bytes
|
|
98
|
-
cls.to_msgpack_dict = to_msgpack_dict
|
|
99
|
-
cls.from_msgpack_dict = from_msgpack_dict
|
|
100
|
-
cls.get_msgpack_size = get_msgpack_size
|
|
101
|
-
cls.get_compression_ratio = get_compression_ratio
|
|
102
|
-
|
|
103
|
-
return cls
|
|
104
27
|
|
|
105
28
|
|
|
106
29
|
def builder(cls):
|
|
@@ -192,7 +115,6 @@ def byte_offsets(source: str, start_line: int, start_col: int,
|
|
|
192
115
|
|
|
193
116
|
|
|
194
117
|
@builder
|
|
195
|
-
@msgpk
|
|
196
118
|
class Span(BaseModel):
|
|
197
119
|
"""Where a node lives in source. `start`/`end` are [line, col] (1-based line,
|
|
198
120
|
0-based col, ast semantics); `bytes` are utf-8 offsets into module.source."""
|
|
@@ -202,7 +124,6 @@ class Span(BaseModel):
|
|
|
202
124
|
|
|
203
125
|
|
|
204
126
|
@builder
|
|
205
|
-
@msgpk
|
|
206
127
|
class BodyNode(BaseModel):
|
|
207
128
|
"""A node in a callable's `body`: an AST region (statement/call/branch/…) or
|
|
208
129
|
a synthetic analysis vertex (entry/exit/formal_in/out/actual_in/out)."""
|
|
@@ -211,40 +132,46 @@ class BodyNode(BaseModel):
|
|
|
211
132
|
callee: Optional[str] = None # only on `call` nodes; the sanctioned null→id slot
|
|
212
133
|
of: Optional[str] = None # param vertices: the variable/return they carry
|
|
213
134
|
parent: Optional[str] = None # actuals: owning callsite ordinal id
|
|
135
|
+
# Call-site detail (#120). Previously reachable only through the parallel
|
|
136
|
+
# `PyCallable.call_sites` list, which emitted the same fact a second time under
|
|
137
|
+
# an unrelated id scheme. `method_name` and `is_constructor_call` are carried
|
|
138
|
+
# rather than derived from `callee`: measured across `requests` and `flask`,
|
|
139
|
+
# 20-28% of call sites never resolve a callee, so deriving them would lose them
|
|
140
|
+
# on one call in four.
|
|
141
|
+
method_name: Optional[str] = None
|
|
142
|
+
receiver_expr: Optional[str] = None
|
|
143
|
+
receiver_type: Optional[str] = None
|
|
144
|
+
return_type: Optional[str] = None
|
|
145
|
+
is_constructor_call: Optional[bool] = None
|
|
146
|
+
arguments: List["PyCallArgument"] = []
|
|
214
147
|
|
|
215
148
|
|
|
216
149
|
@builder
|
|
217
|
-
@msgpk
|
|
218
150
|
class CfgEdge(BaseModel):
|
|
219
151
|
src: str; dst: str; kind: str = "fallthrough"
|
|
220
152
|
|
|
221
153
|
|
|
222
154
|
@builder
|
|
223
|
-
@msgpk
|
|
224
155
|
class CdgEdge(BaseModel):
|
|
225
156
|
src: str; dst: str
|
|
226
157
|
|
|
227
158
|
|
|
228
159
|
@builder
|
|
229
|
-
@msgpk
|
|
230
160
|
class DdgEdge(BaseModel):
|
|
231
161
|
src: str; dst: str; var: Optional[str] = None; prov: List[str] = []
|
|
232
162
|
|
|
233
163
|
|
|
234
164
|
@builder
|
|
235
|
-
@msgpk
|
|
236
165
|
class SummaryEdge(BaseModel):
|
|
237
166
|
src: str; dst: str
|
|
238
167
|
|
|
239
168
|
|
|
240
169
|
@builder
|
|
241
|
-
@msgpk
|
|
242
170
|
class ParamEdge(BaseModel):
|
|
243
171
|
src: str; dst: str
|
|
244
172
|
|
|
245
173
|
|
|
246
174
|
@builder
|
|
247
|
-
@msgpk
|
|
248
175
|
class PyImport(BaseModel):
|
|
249
176
|
"""Represents a Python import statement."""
|
|
250
177
|
|
|
@@ -259,7 +186,6 @@ class PyImport(BaseModel):
|
|
|
259
186
|
|
|
260
187
|
|
|
261
188
|
@builder
|
|
262
|
-
@msgpk
|
|
263
189
|
class PyComment(BaseModel):
|
|
264
190
|
"""Represents a Python comment."""
|
|
265
191
|
|
|
@@ -272,7 +198,6 @@ class PyComment(BaseModel):
|
|
|
272
198
|
|
|
273
199
|
|
|
274
200
|
@builder
|
|
275
|
-
@msgpk
|
|
276
201
|
class PySymbol(BaseModel):
|
|
277
202
|
"""Represents a symbol used or declared in Python code."""
|
|
278
203
|
|
|
@@ -287,7 +212,6 @@ class PySymbol(BaseModel):
|
|
|
287
212
|
|
|
288
213
|
|
|
289
214
|
@builder
|
|
290
|
-
@msgpk
|
|
291
215
|
class PyVariableDeclaration(BaseModel):
|
|
292
216
|
"""Represents a Python variable declaration."""
|
|
293
217
|
|
|
@@ -306,13 +230,66 @@ class PyVariableDeclaration(BaseModel):
|
|
|
306
230
|
|
|
307
231
|
|
|
308
232
|
@builder
|
|
309
|
-
|
|
233
|
+
class PyDecorator(BaseModel):
|
|
234
|
+
"""One decorator application, structured rather than a source string (#128).
|
|
235
|
+
|
|
236
|
+
``name`` is the spelling as written (``lru_cache``, ``builtins.staticmethod``);
|
|
237
|
+
``qualified_name`` is Jedi's resolution of it (``functools.lru_cache``) and is
|
|
238
|
+
absent when it cannot be resolved. ``expression`` keeps the full unparsed source
|
|
239
|
+
so nothing is lost for decorators too complex to decompose.
|
|
240
|
+
"""
|
|
241
|
+
|
|
242
|
+
name: str
|
|
243
|
+
qualified_name: Optional[str] = None
|
|
244
|
+
positional_arguments: List[str] = []
|
|
245
|
+
keyword_arguments: Dict[str, str] = {}
|
|
246
|
+
expression: str = ""
|
|
247
|
+
span: Optional[Span] = None
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@builder
|
|
251
|
+
class PyEntrypoint(BaseModel):
|
|
252
|
+
"""One way a callable or class is invoked from outside the application (#27).
|
|
253
|
+
|
|
254
|
+
A node may hold several: two ``@app.route`` decorators, or a function that
|
|
255
|
+
is both a Celery task and a CLI command. ``confidence`` lets a consumer
|
|
256
|
+
threshold on evidence quality rather than inheriting this analyzer's
|
|
257
|
+
judgement.
|
|
258
|
+
"""
|
|
259
|
+
|
|
260
|
+
framework: str
|
|
261
|
+
confidence: str = "certain" # "declared" | "certain" | "heuristic"
|
|
262
|
+
rule: str = "" # rules.yml `id:`, or an engine name
|
|
263
|
+
ruleset: str = "shipped" # "shipped" | "user:<path>"
|
|
264
|
+
evidence: Optional[str] = None
|
|
265
|
+
route: Optional[str] = None
|
|
266
|
+
http_methods: List[str] = []
|
|
267
|
+
via: Optional[str] = None # can:// id of the routed node dispatching here
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@builder
|
|
271
|
+
class PyEntrypointReport(BaseModel):
|
|
272
|
+
"""Coverage and failure record for the entrypoint pass (#27).
|
|
273
|
+
|
|
274
|
+
The pass under-approximates by design, so silence is its failure mode.
|
|
275
|
+
This is what makes a gap visible instead of indistinguishable from
|
|
276
|
+
"this project has no entrypoints".
|
|
277
|
+
"""
|
|
278
|
+
|
|
279
|
+
frameworks_detected: List[str] = []
|
|
280
|
+
rulesets: List[str] = []
|
|
281
|
+
unresolved: Dict[str, int] = {}
|
|
282
|
+
errors: List[str] = []
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
@builder
|
|
310
286
|
class PyCallableParameter(BaseModel):
|
|
311
287
|
"""Represents a parameter of a Python callable (function/method)."""
|
|
312
288
|
|
|
313
289
|
name: str
|
|
314
290
|
type: Optional[str] = None
|
|
315
291
|
default_value: Optional[str] = None
|
|
292
|
+
decorators: List[PyDecorator] = []
|
|
316
293
|
start_line: int = -1
|
|
317
294
|
end_line: int = -1
|
|
318
295
|
start_column: int = -1
|
|
@@ -320,7 +297,6 @@ class PyCallableParameter(BaseModel):
|
|
|
320
297
|
|
|
321
298
|
|
|
322
299
|
@builder
|
|
323
|
-
@msgpk
|
|
324
300
|
class PyCallArgument(BaseModel):
|
|
325
301
|
"""One call-site argument: AST category + inferred type, kept separate.
|
|
326
302
|
|
|
@@ -330,10 +306,27 @@ class PyCallArgument(BaseModel):
|
|
|
330
306
|
|
|
331
307
|
ast_kind: str
|
|
332
308
|
inferred_type: Optional[str] = None
|
|
309
|
+
# Literal capture (#162): populated only for an `ast.Constant` argument
|
|
310
|
+
# whose value is str/int/float/bool/None, JSON-encoded (`json.dumps`) --
|
|
311
|
+
# decode with `json.loads` to recover the Python constant. `None` for
|
|
312
|
+
# every non-constant argument (and for a Constant of another type, e.g.
|
|
313
|
+
# bytes/complex/Ellipsis).
|
|
314
|
+
value: Optional[str] = None
|
|
315
|
+
# Bare-identifier capture (#162): the `id` of an `ast.Name` argument
|
|
316
|
+
# (e.g. `f(KEY)` -> `"KEY"`) -- ships alongside `value` as the dataflow
|
|
317
|
+
# tier's join point back to the variable's own definitions. `None` for
|
|
318
|
+
# every other argument shape.
|
|
319
|
+
name: Optional[str] = None
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
# BodyNode.arguments forward-references PyCallArgument (defined later);
|
|
323
|
+
# pydantic v1 resolves string annotations only when told to, while v2
|
|
324
|
+
# rebuilds automatically (and its update_forward_refs shim rejects localns).
|
|
325
|
+
if not hasattr(BodyNode, "model_rebuild"): # pydantic v1
|
|
326
|
+
BodyNode.update_forward_refs(PyCallArgument=PyCallArgument)
|
|
333
327
|
|
|
334
328
|
|
|
335
329
|
@builder
|
|
336
|
-
@msgpk
|
|
337
330
|
class PyCallsite(BaseModel):
|
|
338
331
|
"""Represents a Python call site (function or method invocation) with contextual metadata."""
|
|
339
332
|
|
|
@@ -352,7 +345,6 @@ class PyCallsite(BaseModel):
|
|
|
352
345
|
|
|
353
346
|
|
|
354
347
|
@builder
|
|
355
|
-
@msgpk
|
|
356
348
|
class PyCallable(BaseModel):
|
|
357
349
|
"""Represents a Python callable (function/method)."""
|
|
358
350
|
|
|
@@ -363,13 +355,27 @@ class PyCallable(BaseModel):
|
|
|
363
355
|
kind: str = "function"
|
|
364
356
|
span: Optional[Span] = None
|
|
365
357
|
comments: List[PyComment] = []
|
|
366
|
-
decorators: List[
|
|
358
|
+
decorators: List[PyDecorator] = []
|
|
359
|
+
# Language-level modifiers on the declaration itself (#130). `async` is the
|
|
360
|
+
# only one Python has today. It lives here rather than in `kind` because it
|
|
361
|
+
# is orthogonal to every kind -- an async method is both -- so encoding it
|
|
362
|
+
# in the discriminant would need async_function, async_method,
|
|
363
|
+
# async_generator and so on, combinatorially.
|
|
364
|
+
modifiers: List[str] = []
|
|
365
|
+
entrypoints: List[PyEntrypoint] = []
|
|
366
|
+
is_entrypoint: bool = False
|
|
367
367
|
parameters: List[PyCallableParameter] = []
|
|
368
368
|
return_type: Optional[str] = None
|
|
369
369
|
start_line: int = -1
|
|
370
370
|
end_line: int = -1
|
|
371
371
|
code_start_line: int = -1
|
|
372
372
|
accessed_symbols: List[PySymbol] = []
|
|
373
|
+
# Internal (#120): the Jedi-produced record that `l1_body` derives `body{}`
|
|
374
|
+
# call nodes from, and that `call_graph.py`, `l2_callees.py` and the dataflow
|
|
375
|
+
# builder all read. It is stripped at EMIT time (see `wire_json`), not with a
|
|
376
|
+
# field-level `exclude`: the analysis cache round-trips through the same
|
|
377
|
+
# serializer, so excluding it would drop it from the cache too and a warm-cache
|
|
378
|
+
# run would rebuild with no call sites at all.
|
|
373
379
|
call_sites: List[PyCallsite] = []
|
|
374
380
|
callables: Dict[str, "PyCallable"] = {} # nested callables (closures)
|
|
375
381
|
types: Dict[str, "PyClass"] = {} # nested (local) classes
|
|
@@ -389,7 +395,6 @@ class PyCallable(BaseModel):
|
|
|
389
395
|
|
|
390
396
|
|
|
391
397
|
@builder
|
|
392
|
-
@msgpk
|
|
393
398
|
class PyClassAttribute(BaseModel):
|
|
394
399
|
"""Represents a Python class attribute."""
|
|
395
400
|
|
|
@@ -397,12 +402,12 @@ class PyClassAttribute(BaseModel):
|
|
|
397
402
|
type: Optional[str] = None
|
|
398
403
|
initializer: Optional[str] = None
|
|
399
404
|
comments: List[PyComment] = []
|
|
405
|
+
decorators: List[PyDecorator] = []
|
|
400
406
|
start_line: int = -1
|
|
401
407
|
end_line: int = -1
|
|
402
408
|
|
|
403
409
|
|
|
404
410
|
@builder
|
|
405
|
-
@msgpk
|
|
406
411
|
class PyClass(BaseModel):
|
|
407
412
|
"""Represents a Python class."""
|
|
408
413
|
|
|
@@ -413,6 +418,9 @@ class PyClass(BaseModel):
|
|
|
413
418
|
span: Optional[Span] = None
|
|
414
419
|
comments: List[PyComment] = []
|
|
415
420
|
base_classes: List[str] = []
|
|
421
|
+
decorators: List[PyDecorator] = []
|
|
422
|
+
entrypoints: List[PyEntrypoint] = []
|
|
423
|
+
is_entrypoint: bool = False
|
|
416
424
|
callables: Dict[str, PyCallable] = {} # methods, keystone containment name
|
|
417
425
|
attributes: Dict[str, PyClassAttribute] = {}
|
|
418
426
|
types: Dict[str, "PyClass"] = {} # inner classes, keystone containment name
|
|
@@ -425,7 +433,6 @@ class PyClass(BaseModel):
|
|
|
425
433
|
|
|
426
434
|
|
|
427
435
|
@builder
|
|
428
|
-
@msgpk
|
|
429
436
|
class PyModule(BaseModel):
|
|
430
437
|
"""Represents a Python module."""
|
|
431
438
|
|
|
@@ -446,7 +453,6 @@ class PyModule(BaseModel):
|
|
|
446
453
|
|
|
447
454
|
|
|
448
455
|
@builder
|
|
449
|
-
@msgpk
|
|
450
456
|
class PyCallEdge(BaseModel):
|
|
451
457
|
"""Identity-only call-graph edge with weight (keystone shape: the list name
|
|
452
458
|
IS the edge type, so there is no ``type`` field).
|
|
@@ -460,11 +466,10 @@ class PyCallEdge(BaseModel):
|
|
|
460
466
|
src: str # caller callable id
|
|
461
467
|
dst: str # callee callable (or external) id
|
|
462
468
|
weight: int = 1
|
|
463
|
-
prov: List[Literal["jedi", "
|
|
469
|
+
prov: List[Literal["jedi", "defuse"]] = []
|
|
464
470
|
|
|
465
471
|
|
|
466
472
|
@builder
|
|
467
|
-
@msgpk
|
|
468
473
|
class PyExternalSymbol(BaseModel):
|
|
469
474
|
"""A call-graph target outside the analyzed project -- an imported library or
|
|
470
475
|
builtin member. An edge-endpoint id home, not a tree node: keyed in
|
|
@@ -477,7 +482,104 @@ class PyExternalSymbol(BaseModel):
|
|
|
477
482
|
|
|
478
483
|
|
|
479
484
|
@builder
|
|
480
|
-
|
|
485
|
+
class PyConfigKey(BaseModel):
|
|
486
|
+
"""A configuration key flattened out of a config-bearing ``PyArtifact``
|
|
487
|
+
(#152). Graph vocabulary stays neutral (label ``ConfigKey``, edge
|
|
488
|
+
``DEFINES_CONFIG``) -- the ``Py`` prefix here is only the ``PyArtifact``
|
|
489
|
+
naming precedent, not a Python-specific claim. L1 data, identical at
|
|
490
|
+
every analysis level; nested under the owning artifact, containment
|
|
491
|
+
mirrors ``DEFINES_CONFIG``."""
|
|
492
|
+
|
|
493
|
+
id: str = "" # <artifact-id>@key/<dotted.key>
|
|
494
|
+
key: str # dotted path; numeric segments for arrays, e.g. "services.web.ports.0"
|
|
495
|
+
namespace: str # env|yaml|json|toml|ini|properties|dockerfile
|
|
496
|
+
value: Optional[str] = None # populated only when options.artifact_text is on
|
|
497
|
+
span: Optional[Span] = None # into the artifact's source; best-effort for yaml/json/toml
|
|
498
|
+
references: List[str] = [] # raw recognized tokens, order of appearance, deduplicated
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
@builder
|
|
502
|
+
class PyArtifact(BaseModel):
|
|
503
|
+
"""Any non-`.py` project file (config, manifest, CI, container spec, or
|
|
504
|
+
plain data/binary) -- never dropped from the walk. Captured broadly (node
|
|
505
|
+
+ verbatim ``source``); *meaning* is extracted narrowly -- only
|
|
506
|
+
``dependency-manifest`` roles feed ``dependencies`` today. ``id`` is
|
|
507
|
+
language-neutral (``can://artifact/<app>/<path>``)."""
|
|
508
|
+
|
|
509
|
+
id: str = ""
|
|
510
|
+
kind: str = "artifact"
|
|
511
|
+
path: str # repo-relative POSIX path (also the map key)
|
|
512
|
+
format: str # toml|yaml|json|ini|properties|requirements|dockerfile|text|binary
|
|
513
|
+
roles: List[str] = []
|
|
514
|
+
size_bytes: int = 0
|
|
515
|
+
sha256: str = "" # always the full file's hash, even when source is truncated/empty
|
|
516
|
+
source: str = "" # verbatim by default; "" for binary or when capture is disabled
|
|
517
|
+
text_truncated: bool = False # True when `source` is a prefix, not the full file
|
|
518
|
+
extraction: str = "none" # none|partial|full
|
|
519
|
+
config_keys: List[PyConfigKey] = [] # flattened config keys (#152); [] when not namespace-eligible
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
@builder
|
|
523
|
+
class PyConfigUseEdge(BaseModel):
|
|
524
|
+
"""One resolved config read (#162): a detector-matched call's key
|
|
525
|
+
argument closed on exactly one string literal that matches a declared
|
|
526
|
+
``PyConfigKey``. ``src`` is the call's GLOBAL ordinal id
|
|
527
|
+
(``<callable-id>@<local-id>``); ``dst`` is the matched ``PyConfigKey.id``
|
|
528
|
+
-- application scope, mirroring ``param_in`` (endpoints span callables/
|
|
529
|
+
artifacts). Superset-monotonic across levels, same additive contract as
|
|
530
|
+
the DDG's ``prov`` widening: literal (``-a 2``+) subset of +dataflow
|
|
531
|
+
(``-a 3``/``-a 4``)."""
|
|
532
|
+
|
|
533
|
+
src: str
|
|
534
|
+
dst: str
|
|
535
|
+
prov: List[Literal["literal", "dataflow"]] = []
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
@builder
|
|
539
|
+
class PyConfigRead(BaseModel):
|
|
540
|
+
"""A detector-matched call whose key did not close on exactly one string
|
|
541
|
+
literal -- first-class so a config read nobody can trace is as visible
|
|
542
|
+
as one that resolves (#162). ``key`` is the decoded literal text only
|
|
543
|
+
when it IS a literal but matches no declared ``PyConfigKey``
|
|
544
|
+
(``reason="undefined-key"``); ``None`` for a key that never closed on a
|
|
545
|
+
literal at all (``reason="non-literal"``). ``prov`` lists every tier
|
|
546
|
+
that was attempted before giving up."""
|
|
547
|
+
|
|
548
|
+
site: str # GLOBAL ordinal id
|
|
549
|
+
callee: str # external id (can://.../@external/<module>/<name>)
|
|
550
|
+
key: Optional[str] = None
|
|
551
|
+
reason: Literal["non-literal", "undefined-key"]
|
|
552
|
+
prov: List[Literal["literal", "dataflow"]] = []
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
@builder
|
|
556
|
+
class PyDependency(BaseModel):
|
|
557
|
+
"""One declared third-party dependency, evidence-tagged via ``prov``."""
|
|
558
|
+
|
|
559
|
+
name: str # PEP 503 normalized
|
|
560
|
+
ecosystem: str = "pypi" # SDK symmetry with purl (#152 rider); the only ecosystem this analyzer emits
|
|
561
|
+
spec: str = ""
|
|
562
|
+
kind: str = "runtime" # runtime|dev|optional|build
|
|
563
|
+
extras: List[str] = []
|
|
564
|
+
declared_in: str = "" # PyArtifact id
|
|
565
|
+
# False for lockfile-only (transitive) dependencies -- pinned in a lock
|
|
566
|
+
# with no manifest declaration (#152 reconciliation).
|
|
567
|
+
direct: bool = True
|
|
568
|
+
locked_version: Optional[str] = None
|
|
569
|
+
provides_imports: List[str] = []
|
|
570
|
+
prov: List[str] = [] # declared|lockfile|installed-metadata|heuristic
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
@builder
|
|
574
|
+
class PyImportBinding(BaseModel):
|
|
575
|
+
"""A top-level import no declared dependency accounts for."""
|
|
576
|
+
|
|
577
|
+
module: str
|
|
578
|
+
bound_to: Optional[str] = None # best-effort distribution name
|
|
579
|
+
prov: List[str] = []
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
@builder
|
|
481
583
|
class PyRepositoryInfo(BaseModel):
|
|
482
584
|
"""Where the analyzed source came from: git provenance captured at analysis time."""
|
|
483
585
|
|
|
@@ -487,7 +589,6 @@ class PyRepositoryInfo(BaseModel):
|
|
|
487
589
|
|
|
488
590
|
|
|
489
591
|
@builder
|
|
490
|
-
@msgpk
|
|
491
592
|
class PyAnalyzerInfo(BaseModel):
|
|
492
593
|
"""Which analyzer produced this snapshot, and how it was configured.
|
|
493
594
|
Lives on the ``Analysis`` envelope (keystone ``analyzer{name,version}``;
|
|
@@ -499,7 +600,6 @@ class PyAnalyzerInfo(BaseModel):
|
|
|
499
600
|
|
|
500
601
|
|
|
501
602
|
@builder
|
|
502
|
-
@msgpk
|
|
503
603
|
class PyApplication(BaseModel):
|
|
504
604
|
"""Represents a Python application."""
|
|
505
605
|
|
|
@@ -511,15 +611,25 @@ class PyApplication(BaseModel):
|
|
|
511
611
|
# builtin members), keyed by signature. Populated by the analyzer so every
|
|
512
612
|
# backend (JSON and Neo4j) shares one authoritative external-symbol set.
|
|
513
613
|
external_symbols: Dict[str, PyExternalSymbol] = {}
|
|
614
|
+
# Non-code artifacts, declared dependencies, and undeclared imports
|
|
615
|
+
# (spec 2026-08-27). L1 data: identical at every analysis level.
|
|
616
|
+
artifacts: Dict[str, PyArtifact] = {}
|
|
617
|
+
dependencies: List[PyDependency] = []
|
|
618
|
+
unresolved_imports: List[PyImportBinding] = []
|
|
619
|
+
# Coverage/failure record for the entrypoint pass; see PyEntrypointReport (#27).
|
|
620
|
+
entrypoint_report: PyEntrypointReport = PyEntrypointReport()
|
|
514
621
|
# Git provenance of the analyzed checkout, captured at analysis time.
|
|
515
622
|
repository: Optional[PyRepositoryInfo] = None
|
|
516
623
|
# Interprocedural parameter-passing edges (formal↔actual); populated at L4.
|
|
517
624
|
param_in: List[ParamEdge] = []
|
|
518
625
|
param_out: List[ParamEdge] = []
|
|
626
|
+
# config_use (#162): PY_USES_CONFIG edges + first-class unresolved reads.
|
|
627
|
+
# Literal tier from L2; dataflow tiers widen the set at L3/L4 (additive).
|
|
628
|
+
config_uses: List[PyConfigUseEdge] = []
|
|
629
|
+
config_reads_unresolved: List[PyConfigRead] = []
|
|
519
630
|
|
|
520
631
|
|
|
521
632
|
@builder
|
|
522
|
-
@msgpk
|
|
523
633
|
class Analysis(BaseModel):
|
|
524
634
|
"""v2 payload root: envelope + the application tree node. ``k_limit`` is an
|
|
525
635
|
L3+ envelope key (None below the dataflow levels; exclude_none drops it)."""
|
|
@@ -28,6 +28,8 @@ from typing import Dict, Iterator, List, Tuple
|
|
|
28
28
|
|
|
29
29
|
import networkx as nx
|
|
30
30
|
|
|
31
|
+
from codeanalyzer.semantic_analysis.defuse_linker import _module_qual
|
|
32
|
+
from codeanalyzer.schema import model_copy
|
|
31
33
|
from codeanalyzer.schema.py_schema import (
|
|
32
34
|
PyApplication,
|
|
33
35
|
PyCallable,
|
|
@@ -170,7 +172,7 @@ def jedi_call_graph_edges(
|
|
|
170
172
|
|
|
171
173
|
Edges are coalesced on ``(source, target)``: ``weight`` is the count of
|
|
172
174
|
matching sites. Provenance is always ``["jedi"]``; combine with
|
|
173
|
-
|
|
175
|
+
defuse-linker edges via ``merge_edges``.
|
|
174
176
|
"""
|
|
175
177
|
counts: Counter = Counter()
|
|
176
178
|
for caller in iter_callables_in_symbol_table(symbol_table):
|
|
@@ -253,10 +255,24 @@ def filter_external_edges(
|
|
|
253
255
|
retained; only lib→lib edges are dropped. The app symbol set is built by
|
|
254
256
|
walking every callable in the symbol table recursively (including nested
|
|
255
257
|
functions and closures via ``callables``) plus every class, so
|
|
256
|
-
|
|
258
|
+
resolver-discovered closure nodes are correctly recognised as app symbols.
|
|
259
|
+
|
|
260
|
+
Module names count as app symbols too (#131). A resolver attributes a call in
|
|
261
|
+
module scope to the MODULE -- ``app -> functools.reduce`` for a module-level
|
|
262
|
+
``functools.reduce(...)``, or for a decorator applied to a top-level
|
|
263
|
+
definition, since a decorator executes in its enclosing scope. Without the
|
|
264
|
+
module names here, both endpoints looked third-party and every module-scope
|
|
265
|
+
call to a library target was discarded as lib→lib.
|
|
257
266
|
"""
|
|
258
267
|
app_symbols: set = {c.signature for c in iter_callables_in_symbol_table(symbol_table)}
|
|
259
268
|
app_symbols.update(cls.signature for cls in iter_classes_in_symbol_table(symbol_table))
|
|
269
|
+
app_symbols.update(
|
|
270
|
+
mod.module_name for mod in symbol_table.values() if mod.module_name
|
|
271
|
+
)
|
|
272
|
+
# Dotted module quals too: the defuse linker attributes module- and
|
|
273
|
+
# class-scope calls to "pkg.module" (collision-free across packages),
|
|
274
|
+
# which the bare `module_name` stems above never match.
|
|
275
|
+
app_symbols.update(_module_qual(key) for key in symbol_table)
|
|
260
276
|
|
|
261
277
|
return [
|
|
262
278
|
e for e in edges
|
|
@@ -269,7 +285,7 @@ def merge_edges(*edge_lists: list) -> list:
|
|
|
269
285
|
|
|
270
286
|
Edges with the same ``(source, target)`` are coalesced: weights sum,
|
|
271
287
|
provenance is the sorted union. Useful for combining edges produced
|
|
272
|
-
by different backends (e.g. Jedi +
|
|
288
|
+
by different backends (e.g. Jedi + the defuse linker).
|
|
273
289
|
"""
|
|
274
290
|
by_key: Dict[Tuple[str, str], PyCallEdge] = {}
|
|
275
291
|
for edges in edge_lists:
|
|
@@ -280,5 +296,5 @@ def merge_edges(*edge_lists: list) -> list:
|
|
|
280
296
|
cur.weight += e.weight
|
|
281
297
|
cur.prov = sorted(set(cur.prov) | set(e.prov))
|
|
282
298
|
else:
|
|
283
|
-
by_key[k] =
|
|
299
|
+
by_key[k] = model_copy(e)
|
|
284
300
|
return list(by_key.values())
|