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.
Files changed (45) hide show
  1. codeanalyzer/__main__.py +119 -118
  2. codeanalyzer/artifacts/__init__.py +20 -0
  3. codeanalyzer/artifacts/config_keys.py +588 -0
  4. codeanalyzer/artifacts/config_use.py +597 -0
  5. codeanalyzer/artifacts/config_use_rules.yml +58 -0
  6. codeanalyzer/artifacts/dependencies.py +237 -0
  7. codeanalyzer/artifacts/discovery.py +167 -0
  8. codeanalyzer/artifacts/parsers.py +248 -0
  9. codeanalyzer/core.py +112 -45
  10. codeanalyzer/dataflow/access_paths.py +26 -4
  11. codeanalyzer/dataflow/builder.py +22 -1
  12. codeanalyzer/dataflow/identity.py +1 -1
  13. codeanalyzer/dataflow/pdg.py +7 -2
  14. codeanalyzer/dataflow/scc.py +1 -1
  15. codeanalyzer/entrypoints/__init__.py +3 -0
  16. codeanalyzer/entrypoints/detect.py +124 -0
  17. codeanalyzer/entrypoints/matching.py +182 -0
  18. codeanalyzer/entrypoints/pipeline.py +131 -0
  19. codeanalyzer/entrypoints/rules.py +159 -0
  20. codeanalyzer/entrypoints/rules.yml +88 -0
  21. codeanalyzer/neo4j/bolt.py +1 -1
  22. codeanalyzer/neo4j/project.py +277 -60
  23. codeanalyzer/neo4j/schema.py +92 -34
  24. codeanalyzer/options/__init__.py +2 -2
  25. codeanalyzer/options/options.py +7 -26
  26. codeanalyzer/schema/__init__.py +48 -0
  27. codeanalyzer/schema/ids.py +21 -0
  28. codeanalyzer/schema/l1_body.py +11 -1
  29. codeanalyzer/schema/l2_callees.py +29 -13
  30. codeanalyzer/schema/py_schema.py +213 -103
  31. codeanalyzer/semantic_analysis/call_graph.py +20 -4
  32. codeanalyzer/semantic_analysis/defuse_linker.py +1499 -0
  33. codeanalyzer/syntactic_analysis/symbol_table_builder.py +99 -3
  34. {codeanalyzer_python-1.1.1.dist-info → codeanalyzer_python-1.3.0.dist-info}/METADATA +143 -164
  35. {codeanalyzer_python-1.1.1.dist-info → codeanalyzer_python-1.3.0.dist-info}/RECORD +39 -31
  36. {codeanalyzer_python-1.1.1.dist-info → codeanalyzer_python-1.3.0.dist-info}/WHEEL +1 -1
  37. codeanalyzer/config/__init__.py +0 -3
  38. codeanalyzer/config/config.py +0 -8
  39. codeanalyzer/semantic_analysis/pycg/__init__.py +0 -20
  40. codeanalyzer/semantic_analysis/pycg/pycg_analysis.py +0 -1115
  41. codeanalyzer/semantic_analysis/pycg/pycg_exceptions.py +0 -23
  42. codeanalyzer/semantic_analysis/pycg/shard_planner.py +0 -401
  43. {codeanalyzer_python-1.1.1.dist-info → codeanalyzer_python-1.3.0.dist-info}/entry_points.txt +0 -0
  44. {codeanalyzer_python-1.1.1.dist-info → codeanalyzer_python-1.3.0.dist-info}/licenses/LICENSE +0 -0
  45. {codeanalyzer_python-1.1.1.dist-info → codeanalyzer_python-1.3.0.dist-info}/licenses/NOTICE +0 -0
@@ -1,3 +1,3 @@
1
- from .options import AnalysisOptions, EmitTarget, OutputFormat, ShardStrategy
1
+ from .options import AnalysisOptions, EmitTarget
2
2
 
3
- __all__ = ["AnalysisOptions", "EmitTarget", "OutputFormat", "ShardStrategy"]
3
+ __all__ = ["AnalysisOptions", "EmitTarget"]
@@ -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
- pycg_shard: bool = False
65
- pycg_shard_ceiling: int = 100
66
- pycg_shard_timeout: int = 120
67
- pycg_shard_strategy: ShardStrategy = ShardStrategy.JEDI
68
- pycg_max_iter: int = 50
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
@@ -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
  ])
@@ -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}"
@@ -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(kind="call", span=span, callee=None)
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(cs.callee_signature, cs.callee_signature)
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(app: PyApplication, sig_to_id: dict) -> None:
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)