codeanalyzer-python 1.1.0__py3-none-any.whl → 1.2.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.
Files changed (37) hide show
  1. codeanalyzer/__main__.py +95 -123
  2. codeanalyzer/core.py +21 -45
  3. codeanalyzer/dataflow/access_paths.py +26 -4
  4. codeanalyzer/dataflow/builder.py +65 -1
  5. codeanalyzer/dataflow/identity.py +1 -1
  6. codeanalyzer/dataflow/pdg.py +7 -2
  7. codeanalyzer/dataflow/scc.py +1 -1
  8. codeanalyzer/entrypoints/__init__.py +3 -0
  9. codeanalyzer/entrypoints/detect.py +124 -0
  10. codeanalyzer/entrypoints/matching.py +182 -0
  11. codeanalyzer/entrypoints/pipeline.py +131 -0
  12. codeanalyzer/entrypoints/rules.py +159 -0
  13. codeanalyzer/entrypoints/rules.yml +88 -0
  14. codeanalyzer/neo4j/bolt.py +1 -1
  15. codeanalyzer/neo4j/project.py +85 -60
  16. codeanalyzer/neo4j/schema.py +35 -34
  17. codeanalyzer/options/__init__.py +2 -2
  18. codeanalyzer/options/options.py +2 -26
  19. codeanalyzer/schema/__init__.py +48 -0
  20. codeanalyzer/schema/l1_body.py +11 -1
  21. codeanalyzer/schema/l2_callees.py +29 -13
  22. codeanalyzer/schema/py_schema.py +95 -103
  23. codeanalyzer/semantic_analysis/call_graph.py +20 -4
  24. codeanalyzer/semantic_analysis/defuse_linker.py +1499 -0
  25. codeanalyzer/syntactic_analysis/symbol_table_builder.py +88 -3
  26. {codeanalyzer_python-1.1.0.dist-info → codeanalyzer_python-1.2.0.dist-info}/METADATA +36 -161
  27. {codeanalyzer_python-1.1.0.dist-info → codeanalyzer_python-1.2.0.dist-info}/RECORD +31 -30
  28. {codeanalyzer_python-1.1.0.dist-info → codeanalyzer_python-1.2.0.dist-info}/WHEEL +1 -1
  29. codeanalyzer/config/__init__.py +0 -3
  30. codeanalyzer/config/config.py +0 -8
  31. codeanalyzer/semantic_analysis/pycg/__init__.py +0 -20
  32. codeanalyzer/semantic_analysis/pycg/pycg_analysis.py +0 -1115
  33. codeanalyzer/semantic_analysis/pycg/pycg_exceptions.py +0 -23
  34. codeanalyzer/semantic_analysis/pycg/shard_planner.py +0 -401
  35. {codeanalyzer_python-1.1.0.dist-info → codeanalyzer_python-1.2.0.dist-info}/entry_points.txt +0 -0
  36. {codeanalyzer_python-1.1.0.dist-info → codeanalyzer_python-1.2.0.dist-info}/licenses/LICENSE +0 -0
  37. {codeanalyzer_python-1.1.0.dist-info → codeanalyzer_python-1.2.0.dist-info}/licenses/NOTICE +0 -0
@@ -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
- @msgpk
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
 
@@ -332,8 +308,14 @@ class PyCallArgument(BaseModel):
332
308
  inferred_type: Optional[str] = None
333
309
 
334
310
 
311
+ # BodyNode.arguments forward-references PyCallArgument (defined later);
312
+ # pydantic v1 resolves string annotations only when told to, while v2
313
+ # rebuilds automatically (and its update_forward_refs shim rejects localns).
314
+ if not hasattr(BodyNode, "model_rebuild"): # pydantic v1
315
+ BodyNode.update_forward_refs(PyCallArgument=PyCallArgument)
316
+
317
+
335
318
  @builder
336
- @msgpk
337
319
  class PyCallsite(BaseModel):
338
320
  """Represents a Python call site (function or method invocation) with contextual metadata."""
339
321
 
@@ -352,7 +334,6 @@ class PyCallsite(BaseModel):
352
334
 
353
335
 
354
336
  @builder
355
- @msgpk
356
337
  class PyCallable(BaseModel):
357
338
  """Represents a Python callable (function/method)."""
358
339
 
@@ -363,13 +344,27 @@ class PyCallable(BaseModel):
363
344
  kind: str = "function"
364
345
  span: Optional[Span] = None
365
346
  comments: List[PyComment] = []
366
- decorators: List[str] = []
347
+ decorators: List[PyDecorator] = []
348
+ # Language-level modifiers on the declaration itself (#130). `async` is the
349
+ # only one Python has today. It lives here rather than in `kind` because it
350
+ # is orthogonal to every kind -- an async method is both -- so encoding it
351
+ # in the discriminant would need async_function, async_method,
352
+ # async_generator and so on, combinatorially.
353
+ modifiers: List[str] = []
354
+ entrypoints: List[PyEntrypoint] = []
355
+ is_entrypoint: bool = False
367
356
  parameters: List[PyCallableParameter] = []
368
357
  return_type: Optional[str] = None
369
358
  start_line: int = -1
370
359
  end_line: int = -1
371
360
  code_start_line: int = -1
372
361
  accessed_symbols: List[PySymbol] = []
362
+ # Internal (#120): the Jedi-produced record that `l1_body` derives `body{}`
363
+ # call nodes from, and that `call_graph.py`, `l2_callees.py` and the dataflow
364
+ # builder all read. It is stripped at EMIT time (see `wire_json`), not with a
365
+ # field-level `exclude`: the analysis cache round-trips through the same
366
+ # serializer, so excluding it would drop it from the cache too and a warm-cache
367
+ # run would rebuild with no call sites at all.
373
368
  call_sites: List[PyCallsite] = []
374
369
  callables: Dict[str, "PyCallable"] = {} # nested callables (closures)
375
370
  types: Dict[str, "PyClass"] = {} # nested (local) classes
@@ -389,7 +384,6 @@ class PyCallable(BaseModel):
389
384
 
390
385
 
391
386
  @builder
392
- @msgpk
393
387
  class PyClassAttribute(BaseModel):
394
388
  """Represents a Python class attribute."""
395
389
 
@@ -397,12 +391,12 @@ class PyClassAttribute(BaseModel):
397
391
  type: Optional[str] = None
398
392
  initializer: Optional[str] = None
399
393
  comments: List[PyComment] = []
394
+ decorators: List[PyDecorator] = []
400
395
  start_line: int = -1
401
396
  end_line: int = -1
402
397
 
403
398
 
404
399
  @builder
405
- @msgpk
406
400
  class PyClass(BaseModel):
407
401
  """Represents a Python class."""
408
402
 
@@ -413,6 +407,9 @@ class PyClass(BaseModel):
413
407
  span: Optional[Span] = None
414
408
  comments: List[PyComment] = []
415
409
  base_classes: List[str] = []
410
+ decorators: List[PyDecorator] = []
411
+ entrypoints: List[PyEntrypoint] = []
412
+ is_entrypoint: bool = False
416
413
  callables: Dict[str, PyCallable] = {} # methods, keystone containment name
417
414
  attributes: Dict[str, PyClassAttribute] = {}
418
415
  types: Dict[str, "PyClass"] = {} # inner classes, keystone containment name
@@ -425,7 +422,6 @@ class PyClass(BaseModel):
425
422
 
426
423
 
427
424
  @builder
428
- @msgpk
429
425
  class PyModule(BaseModel):
430
426
  """Represents a Python module."""
431
427
 
@@ -446,7 +442,6 @@ class PyModule(BaseModel):
446
442
 
447
443
 
448
444
  @builder
449
- @msgpk
450
445
  class PyCallEdge(BaseModel):
451
446
  """Identity-only call-graph edge with weight (keystone shape: the list name
452
447
  IS the edge type, so there is no ``type`` field).
@@ -460,11 +455,10 @@ class PyCallEdge(BaseModel):
460
455
  src: str # caller callable id
461
456
  dst: str # callee callable (or external) id
462
457
  weight: int = 1
463
- prov: List[Literal["jedi", "pycg", "joern"]] = []
458
+ prov: List[Literal["jedi", "defuse"]] = []
464
459
 
465
460
 
466
461
  @builder
467
- @msgpk
468
462
  class PyExternalSymbol(BaseModel):
469
463
  """A call-graph target outside the analyzed project -- an imported library or
470
464
  builtin member. An edge-endpoint id home, not a tree node: keyed in
@@ -477,7 +471,6 @@ class PyExternalSymbol(BaseModel):
477
471
 
478
472
 
479
473
  @builder
480
- @msgpk
481
474
  class PyRepositoryInfo(BaseModel):
482
475
  """Where the analyzed source came from: git provenance captured at analysis time."""
483
476
 
@@ -487,7 +480,6 @@ class PyRepositoryInfo(BaseModel):
487
480
 
488
481
 
489
482
  @builder
490
- @msgpk
491
483
  class PyAnalyzerInfo(BaseModel):
492
484
  """Which analyzer produced this snapshot, and how it was configured.
493
485
  Lives on the ``Analysis`` envelope (keystone ``analyzer{name,version}``;
@@ -499,7 +491,6 @@ class PyAnalyzerInfo(BaseModel):
499
491
 
500
492
 
501
493
  @builder
502
- @msgpk
503
494
  class PyApplication(BaseModel):
504
495
  """Represents a Python application."""
505
496
 
@@ -511,6 +502,8 @@ class PyApplication(BaseModel):
511
502
  # builtin members), keyed by signature. Populated by the analyzer so every
512
503
  # backend (JSON and Neo4j) shares one authoritative external-symbol set.
513
504
  external_symbols: Dict[str, PyExternalSymbol] = {}
505
+ # Coverage/failure record for the entrypoint pass; see PyEntrypointReport (#27).
506
+ entrypoint_report: PyEntrypointReport = PyEntrypointReport()
514
507
  # Git provenance of the analyzed checkout, captured at analysis time.
515
508
  repository: Optional[PyRepositoryInfo] = None
516
509
  # Interprocedural parameter-passing edges (formal↔actual); populated at L4.
@@ -519,7 +512,6 @@ class PyApplication(BaseModel):
519
512
 
520
513
 
521
514
  @builder
522
- @msgpk
523
515
  class Analysis(BaseModel):
524
516
  """v2 payload root: envelope + the application tree node. ``k_limit`` is an
525
517
  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
- PyCG-derived edges via ``merge_edges``.
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
- PyCG-discovered closure nodes are correctly recognised as app symbols.
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 + PyCG).
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] = e.model_copy()
299
+ by_key[k] = model_copy(e)
284
300
  return list(by_key.values())