codeanalyzer-python 0.3.0__py3-none-any.whl → 0.3.1__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 +26 -0
- codeanalyzer/core.py +34 -3
- codeanalyzer/neo4j/__init__.py +1 -1
- codeanalyzer/neo4j/emit.py +2 -2
- codeanalyzer/neo4j/project.py +84 -18
- codeanalyzer/neo4j/schema.py +261 -15
- codeanalyzer/provenance.py +61 -0
- codeanalyzer/schema/py_schema.py +38 -0
- codeanalyzer/syntactic_analysis/import_resolver.py +67 -0
- codeanalyzer/syntactic_analysis/symbol_table_builder.py +74 -10
- {codeanalyzer_python-0.3.0.dist-info → codeanalyzer_python-0.3.1.dist-info}/METADATA +4 -1
- {codeanalyzer_python-0.3.0.dist-info → codeanalyzer_python-0.3.1.dist-info}/RECORD +16 -15
- {codeanalyzer_python-0.3.0.dist-info → codeanalyzer_python-0.3.1.dist-info}/WHEEL +1 -1
- codeanalyzer/neo4j/catalog.py +0 -245
- {codeanalyzer_python-0.3.0.dist-info → codeanalyzer_python-0.3.1.dist-info}/entry_points.txt +0 -0
- {codeanalyzer_python-0.3.0.dist-info → codeanalyzer_python-0.3.1.dist-info}/licenses/LICENSE +0 -0
- {codeanalyzer_python-0.3.0.dist-info → codeanalyzer_python-0.3.1.dist-info}/licenses/NOTICE +0 -0
codeanalyzer/__main__.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
from importlib.metadata import version as _pkg_version, PackageNotFoundError
|
|
1
2
|
from pathlib import Path
|
|
2
3
|
from typing import Optional, Annotated
|
|
3
4
|
|
|
@@ -10,7 +11,32 @@ from codeanalyzer.schema import model_dump_json
|
|
|
10
11
|
from codeanalyzer.options import AnalysisOptions, EmitTarget, ShardStrategy
|
|
11
12
|
|
|
12
13
|
|
|
14
|
+
def _version_callback(value: bool) -> None:
|
|
15
|
+
"""Print the installed ``codeanalyzer-python`` version and exit.
|
|
16
|
+
|
|
17
|
+
Eager so it fires before the rest of the CLI callback (no -i/--input
|
|
18
|
+
required). Reads the version from package metadata so it always reflects
|
|
19
|
+
what is actually installed rather than a hardcoded string."""
|
|
20
|
+
if not value:
|
|
21
|
+
return
|
|
22
|
+
try:
|
|
23
|
+
installed = _pkg_version("codeanalyzer-python")
|
|
24
|
+
except PackageNotFoundError:
|
|
25
|
+
installed = "unknown"
|
|
26
|
+
typer.echo(f"canpy {installed}")
|
|
27
|
+
raise typer.Exit()
|
|
28
|
+
|
|
29
|
+
|
|
13
30
|
def main(
|
|
31
|
+
version: Annotated[
|
|
32
|
+
Optional[bool],
|
|
33
|
+
typer.Option(
|
|
34
|
+
"--version",
|
|
35
|
+
help="Show the canpy version and exit.",
|
|
36
|
+
callback=_version_callback,
|
|
37
|
+
is_eager=True,
|
|
38
|
+
),
|
|
39
|
+
] = None,
|
|
14
40
|
input: Annotated[
|
|
15
41
|
Optional[Path],
|
|
16
42
|
typer.Option(
|
codeanalyzer/core.py
CHANGED
|
@@ -26,9 +26,11 @@ from codeanalyzer.semantic_analysis.call_graph import (
|
|
|
26
26
|
)
|
|
27
27
|
from codeanalyzer.semantic_analysis.pycg import PyCG, PyCGExceptions
|
|
28
28
|
from codeanalyzer.syntactic_analysis.exceptions import SymbolTableBuilderRayError
|
|
29
|
+
from codeanalyzer.syntactic_analysis.import_resolver import resolve_imports
|
|
29
30
|
from codeanalyzer.syntactic_analysis.symbol_table_builder import SymbolTableBuilder
|
|
30
31
|
from codeanalyzer.utils import ProgressBar
|
|
31
32
|
from codeanalyzer.options import AnalysisOptions
|
|
33
|
+
from codeanalyzer.provenance import analyzer_info, repository_info
|
|
32
34
|
|
|
33
35
|
@ray.remote
|
|
34
36
|
def _process_file_with_ray(py_file: Union[Path, str], project_dir: Union[Path, str], virtualenv: Union[Path, str, None]) -> Dict[str, PyModule]:
|
|
@@ -411,8 +413,8 @@ class Codeanalyzer:
|
|
|
411
413
|
Uses caching to avoid re-analyzing unchanged files.
|
|
412
414
|
"""
|
|
413
415
|
cache_file = self.cache_dir / "analysis_cache.json"
|
|
414
|
-
|
|
415
|
-
# Try to load existing cached analysis
|
|
416
|
+
|
|
417
|
+
# Try to load existing cached analysis
|
|
416
418
|
cached_pyapplication = None
|
|
417
419
|
if not self.rebuild_analysis and cache_file.exists():
|
|
418
420
|
try:
|
|
@@ -422,6 +424,12 @@ class Codeanalyzer:
|
|
|
422
424
|
logger.warning(f"Failed to load cache: {e}. Rebuilding analysis.")
|
|
423
425
|
cached_pyapplication = None
|
|
424
426
|
|
|
427
|
+
if cached_pyapplication is not None and not self._cache_analyzer_matches(
|
|
428
|
+
cached_pyapplication, analyzer_info(self.analysis_level).version
|
|
429
|
+
):
|
|
430
|
+
logger.info("Analysis cache written by a different analyzer version; rebuilding.")
|
|
431
|
+
cached_pyapplication = None
|
|
432
|
+
|
|
425
433
|
# Build symbol table from cached application if available (if no available, the build a new one)
|
|
426
434
|
symbol_table = self._build_symbol_table(cached_pyapplication.symbol_table if cached_pyapplication else {})
|
|
427
435
|
|
|
@@ -454,12 +462,35 @@ class Codeanalyzer:
|
|
|
454
462
|
.external_symbols(external_symbols)
|
|
455
463
|
.build()
|
|
456
464
|
)
|
|
457
|
-
|
|
465
|
+
|
|
466
|
+
# Every run re-resolves import spellings against the analyzed module
|
|
467
|
+
# set -- pure and cheap; cached modules from older caches default to
|
|
468
|
+
# resolved_module=None and get stamped here (issue #82).
|
|
469
|
+
resolve_imports(app, self.project_dir)
|
|
470
|
+
|
|
471
|
+
# Single choke point for provenance: every produced app (fresh symbol
|
|
472
|
+
# table or reused-from-cache) passes through here before being cached
|
|
473
|
+
# or returned, so analyzer/repository always reflect *this* run/checkout
|
|
474
|
+
# even when the symbol table itself came from the on-disk cache.
|
|
475
|
+
app.analyzer = analyzer_info(self.analysis_level)
|
|
476
|
+
app.repository = repository_info(self.project_dir)
|
|
477
|
+
|
|
458
478
|
# Save to cache
|
|
459
479
|
self._save_analysis_cache(app, cache_file)
|
|
460
480
|
|
|
461
481
|
return app
|
|
462
482
|
|
|
483
|
+
@staticmethod
|
|
484
|
+
def _cache_analyzer_matches(cached_app: Optional[PyApplication], current_version: str) -> bool:
|
|
485
|
+
"""A cache written by another analyzer version (or before versions were
|
|
486
|
+
recorded) may lack fields the current models populate — pydantic fills
|
|
487
|
+
silent defaults, which would masquerade as analyzed absence."""
|
|
488
|
+
return (
|
|
489
|
+
cached_app is not None
|
|
490
|
+
and cached_app.analyzer is not None
|
|
491
|
+
and cached_app.analyzer.version == current_version
|
|
492
|
+
)
|
|
493
|
+
|
|
463
494
|
def _load_pyapplication_from_cache(self, cache_file: Path) -> PyApplication:
|
|
464
495
|
"""Load cached analysis from file.
|
|
465
496
|
|
codeanalyzer/neo4j/__init__.py
CHANGED
|
@@ -19,7 +19,7 @@ plus the two writers (cypher snapshot / bolt incremental). Nothing here runs
|
|
|
19
19
|
unless ``--emit neo4j`` (or ``--emit schema``) is selected.
|
|
20
20
|
"""
|
|
21
21
|
from codeanalyzer.neo4j.bolt import BoltConfig, bolt_writer
|
|
22
|
-
from codeanalyzer.neo4j.
|
|
22
|
+
from codeanalyzer.neo4j.schema import (
|
|
23
23
|
MARKER_LABELS,
|
|
24
24
|
NODE_LABELS,
|
|
25
25
|
REL_TYPES,
|
codeanalyzer/neo4j/emit.py
CHANGED
|
@@ -28,7 +28,7 @@ from pathlib import Path
|
|
|
28
28
|
from typing import Optional
|
|
29
29
|
|
|
30
30
|
from codeanalyzer.neo4j.bolt import BoltConfig, bolt_writer
|
|
31
|
-
from codeanalyzer.neo4j.
|
|
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
34
|
from codeanalyzer.options import AnalysisOptions
|
|
@@ -38,7 +38,7 @@ from codeanalyzer.utils import logger
|
|
|
38
38
|
|
|
39
39
|
def emit_schema(output: Optional[Path]) -> None:
|
|
40
40
|
"""Emit the Neo4j schema contract (``schema.json``) — a static artifact derived
|
|
41
|
-
from the in-repo
|
|
41
|
+
from the in-repo schema, independent of any analyzed project. With no
|
|
42
42
|
``output`` it prints to stdout."""
|
|
43
43
|
doc = json.dumps(build_schema_document(), indent=2) + "\n"
|
|
44
44
|
if output is None:
|
codeanalyzer/neo4j/project.py
CHANGED
|
@@ -36,7 +36,7 @@ import json
|
|
|
36
36
|
from pathlib import Path
|
|
37
37
|
from typing import Any, List, Optional
|
|
38
38
|
|
|
39
|
-
from codeanalyzer.neo4j.
|
|
39
|
+
from codeanalyzer.neo4j.schema import SCHEMA_VERSION
|
|
40
40
|
from codeanalyzer.neo4j.rows import GraphRows, NodeRef, Props, RowBuilder, prune
|
|
41
41
|
from codeanalyzer.schema import (
|
|
42
42
|
PyApplication,
|
|
@@ -53,10 +53,26 @@ from codeanalyzer.schema.py_schema import PyCallsite
|
|
|
53
53
|
def project(app: PyApplication, app_name: str) -> GraphRows:
|
|
54
54
|
b = RowBuilder()
|
|
55
55
|
|
|
56
|
-
app_ref = b.node(
|
|
56
|
+
app_ref = b.node(
|
|
57
|
+
["PyApplication"],
|
|
58
|
+
"name",
|
|
59
|
+
app_name,
|
|
60
|
+
prune(
|
|
61
|
+
{
|
|
62
|
+
"schema_version": SCHEMA_VERSION,
|
|
63
|
+
"analyzer_name": app.analyzer.name if app.analyzer else None,
|
|
64
|
+
"analyzer_version": app.analyzer.version if app.analyzer else None,
|
|
65
|
+
"repo_uri": app.repository.uri if app.repository else None,
|
|
66
|
+
"source_revision": app.repository.revision if app.repository else None,
|
|
67
|
+
"repo_dirty": app.repository.dirty if app.repository else None,
|
|
68
|
+
}
|
|
69
|
+
),
|
|
70
|
+
)
|
|
57
71
|
|
|
58
72
|
for file_key, mod in app.symbol_table.items():
|
|
59
|
-
mod_ref = b.node(
|
|
73
|
+
mod_ref = b.node(
|
|
74
|
+
["PyModule"], "file_key", file_key, _module_props(mod, file_key)
|
|
75
|
+
)
|
|
60
76
|
b.edge("PY_HAS_MODULE", app_ref, mod_ref)
|
|
61
77
|
_project_module_body(b, file_key, mod_ref, mod)
|
|
62
78
|
|
|
@@ -66,7 +82,9 @@ def project(app: PyApplication, app_name: str) -> GraphRows:
|
|
|
66
82
|
for e in app.call_graph:
|
|
67
83
|
src = _call_endpoint(b, e.source, externals)
|
|
68
84
|
tgt = _call_endpoint(b, e.target, externals)
|
|
69
|
-
b.edge(
|
|
85
|
+
b.edge(
|
|
86
|
+
"PY_CALLS", src, tgt, _call_edge_props(e.weight, list(e.provenance or []))
|
|
87
|
+
)
|
|
70
88
|
|
|
71
89
|
return b.finish()
|
|
72
90
|
|
|
@@ -86,9 +104,18 @@ def _call_endpoint(b: RowBuilder, signature: str, externals: dict) -> NodeRef:
|
|
|
86
104
|
ext = externals.get(signature)
|
|
87
105
|
if ext is None and b.has_key("PySymbol", signature):
|
|
88
106
|
return _sym(signature)
|
|
89
|
-
name =
|
|
107
|
+
name = (
|
|
108
|
+
ext.name
|
|
109
|
+
if ext is not None
|
|
110
|
+
else (signature.rsplit(".", 1)[-1] if "." in signature else signature)
|
|
111
|
+
)
|
|
90
112
|
module = ext.module if ext is not None else None
|
|
91
|
-
return b.node(
|
|
113
|
+
return b.node(
|
|
114
|
+
["PySymbol", "PyExternal"],
|
|
115
|
+
"signature",
|
|
116
|
+
signature,
|
|
117
|
+
prune({"name": name, "module": module}),
|
|
118
|
+
)
|
|
92
119
|
|
|
93
120
|
|
|
94
121
|
# ----------------------------------------------------------------------------------------------
|
|
@@ -96,7 +123,9 @@ def _call_endpoint(b: RowBuilder, signature: str, externals: dict) -> NodeRef:
|
|
|
96
123
|
# ----------------------------------------------------------------------------------------------
|
|
97
124
|
|
|
98
125
|
|
|
99
|
-
def _project_module_body(
|
|
126
|
+
def _project_module_body(
|
|
127
|
+
b: RowBuilder, file_key: str, mod_ref: NodeRef, mod: PyModule
|
|
128
|
+
) -> None:
|
|
100
129
|
for fn in (mod.functions or {}).values():
|
|
101
130
|
_project_callable(b, file_key, mod_ref, "PY_DECLARES", fn)
|
|
102
131
|
for cl in (mod.classes or {}).values():
|
|
@@ -107,25 +136,47 @@ def _project_module_body(b: RowBuilder, file_key: str, mod_ref: NodeRef, mod: Py
|
|
|
107
136
|
|
|
108
137
|
|
|
109
138
|
def _project_imports(b: RowBuilder, mod_ref: NodeRef, mod: PyModule) -> None:
|
|
110
|
-
#
|
|
111
|
-
#
|
|
139
|
+
# At most one PY_IMPORTS edge per (module, target) pair -- mirrors PY_CALLS,
|
|
140
|
+
# which pre-aggregates for the same reason: both writers MERGE edges on
|
|
141
|
+
# (type, from, to) and SET their props, so a second row for the same pair
|
|
142
|
+
# would silently overwrite the first in Neo4j instead of adding an edge.
|
|
143
|
+
# Buckets key on the edge's target identity (the resolved module, or the
|
|
144
|
+
# spelling itself for unresolved/external imports), so the SAME target
|
|
145
|
+
# imported under different spellings (``from pkg import util``,
|
|
146
|
+
# ``from . import util as u``, ``from .util import helper``) collapses
|
|
147
|
+
# onto one edge; the raw spellings ride along as a ``spellings`` array.
|
|
148
|
+
# Resolved internal imports point at the real :PyModule; externals keep
|
|
149
|
+
# the shared :PyPackage. Unresolved *relative* spellings (".", ".foo")
|
|
150
|
+
# name no package -- they are dropped from the graph (the spelling
|
|
151
|
+
# survives in analysis.json), instead of minting bogus
|
|
152
|
+
# :PyPackage{name: "."} nodes.
|
|
112
153
|
agg: dict = {}
|
|
113
154
|
for im in mod.imports or []:
|
|
114
155
|
if not im.module:
|
|
115
|
-
continue
|
|
116
|
-
|
|
156
|
+
continue
|
|
157
|
+
if im.resolved_module is None and im.module.startswith("."):
|
|
158
|
+
continue
|
|
159
|
+
key = im.resolved_module or im.module
|
|
160
|
+
a = agg.setdefault(
|
|
161
|
+
key, {"spellings": set(), "names": set(), "aliases": set(), "resolved": im.resolved_module}
|
|
162
|
+
)
|
|
163
|
+
a["spellings"].add(im.module)
|
|
117
164
|
if im.name:
|
|
118
165
|
a["names"].add(im.name)
|
|
119
166
|
if im.alias:
|
|
120
167
|
a["aliases"].add(im.alias)
|
|
121
|
-
for
|
|
122
|
-
|
|
168
|
+
for key, a in agg.items():
|
|
169
|
+
if a["resolved"] is not None:
|
|
170
|
+
target = NodeRef("PyModule", "file_key", a["resolved"])
|
|
171
|
+
else:
|
|
172
|
+
target = b.node(["PyPackage"], "name", key, {})
|
|
123
173
|
b.edge(
|
|
124
174
|
"PY_IMPORTS",
|
|
125
175
|
mod_ref,
|
|
126
|
-
|
|
176
|
+
target,
|
|
127
177
|
prune(
|
|
128
178
|
{
|
|
179
|
+
"spellings": sorted(a["spellings"]),
|
|
129
180
|
"imported_names": sorted(a["names"]) or None,
|
|
130
181
|
"aliases": sorted(a["aliases"]) or None,
|
|
131
182
|
}
|
|
@@ -141,7 +192,9 @@ def _project_imports(b: RowBuilder, mod_ref: NodeRef, mod: PyModule) -> None:
|
|
|
141
192
|
def _project_class(
|
|
142
193
|
b: RowBuilder, file_key: str, parent: NodeRef, parent_rel: str, cl: PyClass
|
|
143
194
|
) -> None:
|
|
144
|
-
ref = b.node(
|
|
195
|
+
ref = b.node(
|
|
196
|
+
["PySymbol", "PyClass"], "signature", cl.signature, _class_props(cl, file_key)
|
|
197
|
+
)
|
|
145
198
|
b.edge(parent_rel, parent, ref)
|
|
146
199
|
|
|
147
200
|
for base in cl.base_classes or []:
|
|
@@ -158,7 +211,12 @@ def _project_class(
|
|
|
158
211
|
def _project_callable(
|
|
159
212
|
b: RowBuilder, file_key: str, owner: NodeRef, owner_rel: str, c: PyCallable
|
|
160
213
|
) -> None:
|
|
161
|
-
ref = b.node(
|
|
214
|
+
ref = b.node(
|
|
215
|
+
["PySymbol", "PyCallable"],
|
|
216
|
+
"signature",
|
|
217
|
+
c.signature,
|
|
218
|
+
_callable_props(c, file_key),
|
|
219
|
+
)
|
|
162
220
|
b.edge(owner_rel, owner, ref)
|
|
163
221
|
|
|
164
222
|
for d in c.decorators or []:
|
|
@@ -166,7 +224,9 @@ def _project_callable(
|
|
|
166
224
|
|
|
167
225
|
for s in c.call_sites or []:
|
|
168
226
|
# Key off the relative file (a call site lives in its callable's file) so ids stay portable.
|
|
169
|
-
cs_id =
|
|
227
|
+
cs_id = (
|
|
228
|
+
f"{file_key}#{s.start_line}:{s.start_column}-{s.end_line}:{s.end_column}"
|
|
229
|
+
)
|
|
170
230
|
cs = b.node(["PyCallSite"], "id", cs_id, _call_site_props(s, file_key))
|
|
171
231
|
b.edge("PY_HAS_CALLSITE", ref, cs)
|
|
172
232
|
if s.callee_signature:
|
|
@@ -189,7 +249,11 @@ def _project_attribute(
|
|
|
189
249
|
|
|
190
250
|
|
|
191
251
|
def _project_variable(
|
|
192
|
-
b: RowBuilder,
|
|
252
|
+
b: RowBuilder,
|
|
253
|
+
file_key: str,
|
|
254
|
+
owner: NodeRef,
|
|
255
|
+
owner_id: str,
|
|
256
|
+
v: PyVariableDeclaration,
|
|
193
257
|
) -> None:
|
|
194
258
|
var_id = f"{owner_id}#{v.name}@{v.start_line}"
|
|
195
259
|
ref = b.node(["PyVariable"], "id", var_id, _variable_props(v, var_id, file_key))
|
|
@@ -258,6 +322,7 @@ def _attribute_props(a: PyClassAttribute, attr_id: str, file_key: str) -> Props:
|
|
|
258
322
|
"id": attr_id,
|
|
259
323
|
"name": a.name,
|
|
260
324
|
"type": a.type,
|
|
325
|
+
"initializer": a.initializer,
|
|
261
326
|
"docstring": _docstring_of(a.comments),
|
|
262
327
|
"start_line": a.start_line,
|
|
263
328
|
"end_line": a.end_line,
|
|
@@ -290,6 +355,7 @@ def _call_site_props(s: PyCallsite, file_key: str) -> Props:
|
|
|
290
355
|
"receiver_expr": s.receiver_expr,
|
|
291
356
|
"receiver_type": s.receiver_type,
|
|
292
357
|
"argument_types": list(s.argument_types or []),
|
|
358
|
+
"arguments_json": _stringify_if(s.arguments),
|
|
293
359
|
"return_type": s.return_type,
|
|
294
360
|
"callee_signature": s.callee_signature,
|
|
295
361
|
"is_constructor_call": s.is_constructor_call,
|
codeanalyzer/neo4j/schema.py
CHANGED
|
@@ -14,26 +14,272 @@
|
|
|
14
14
|
# limitations under the License.
|
|
15
15
|
################################################################################
|
|
16
16
|
|
|
17
|
-
"""The Cypher DDL — uniqueness constraints and indexes — shared by both writers.
|
|
18
|
-
Run BEFORE any load so MERGE uses an index seek (not a label scan) and the
|
|
19
|
-
identity invariant is enforced by the database. Every statement is idempotent
|
|
20
|
-
(``IF NOT EXISTS``).
|
|
21
17
|
"""
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
18
|
+
The declarative Neo4j schema — the single in-repo source of truth for the graph contract: node
|
|
19
|
+
labels with their keys and typed properties, relationship types and their endpoints, and the
|
|
20
|
+
Cypher DDL (uniqueness constraints + indexes). The constraints are DERIVED from the node labels
|
|
21
|
+
(one per distinct mergeLabel/key) so a new label brings its own constraint — there is no second
|
|
22
|
+
list to keep in sync. `--emit schema` serializes all of this to a machine-readable schema.json,
|
|
23
|
+
and the conformance test (``test/test_neo4j_schema.py``) asserts the real emitter never produces a
|
|
24
|
+
label / relationship / property that isn't declared here — so this file cannot silently drift
|
|
25
|
+
from :mod:`codeanalyzer.neo4j.project`.
|
|
26
|
+
|
|
27
|
+
SCHEMA_VERSION is the contract version: bump MAJOR on a breaking change (renamed/removed label,
|
|
28
|
+
relationship or key), MINOR on an additive change (new label/rel/property). It is stamped onto
|
|
29
|
+
the :PyApplication node of every emitted graph so any consumer can detect a producer/consumer
|
|
30
|
+
mismatch at runtime.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
from dataclasses import dataclass, field
|
|
36
|
+
from typing import Dict, List
|
|
37
|
+
|
|
38
|
+
SCHEMA_VERSION = "1.2.0"
|
|
39
|
+
|
|
40
|
+
# PropType ∈ {"string", "integer", "float", "boolean", "string[]", "integer[]"}.
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class NodeLabel:
|
|
45
|
+
label: str # the specific label (also the catalog key)
|
|
46
|
+
merge_label: str # the label the uniqueness constraint / MERGE is on
|
|
47
|
+
key: str
|
|
48
|
+
properties: Dict[str, str]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class RelType:
|
|
53
|
+
type: str
|
|
54
|
+
from_labels: List[str]
|
|
55
|
+
to_labels: List[str]
|
|
56
|
+
properties: Dict[str, str] = field(default_factory=dict)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# Labels layered onto a node in addition to its primary/specific label.
|
|
60
|
+
MARKER_LABELS: List[str] = []
|
|
61
|
+
|
|
62
|
+
_SPAN = {"start_line": "integer", "end_line": "integer"}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
NODE_LABELS: List[NodeLabel] = [
|
|
66
|
+
NodeLabel(
|
|
67
|
+
"PyApplication",
|
|
68
|
+
"PyApplication",
|
|
69
|
+
"name",
|
|
70
|
+
{
|
|
71
|
+
"name": "string",
|
|
72
|
+
"schema_version": "string",
|
|
73
|
+
"analyzer_name": "string",
|
|
74
|
+
"analyzer_version": "string",
|
|
75
|
+
"repo_uri": "string",
|
|
76
|
+
"source_revision": "string",
|
|
77
|
+
"repo_dirty": "boolean",
|
|
78
|
+
},
|
|
79
|
+
),
|
|
80
|
+
NodeLabel(
|
|
81
|
+
"PyModule",
|
|
82
|
+
"PyModule",
|
|
83
|
+
"file_key",
|
|
84
|
+
{
|
|
85
|
+
"file_key": "string",
|
|
86
|
+
"module_name": "string",
|
|
87
|
+
"content_hash": "string",
|
|
88
|
+
"last_modified": "float",
|
|
89
|
+
"file_size": "integer",
|
|
90
|
+
"_module": "string",
|
|
91
|
+
},
|
|
92
|
+
),
|
|
93
|
+
NodeLabel(
|
|
94
|
+
"PyClass",
|
|
95
|
+
"PySymbol",
|
|
96
|
+
"signature",
|
|
97
|
+
{
|
|
98
|
+
"signature": "string",
|
|
99
|
+
"name": "string",
|
|
100
|
+
"code": "string",
|
|
101
|
+
"base_classes": "string[]",
|
|
102
|
+
"docstring": "string",
|
|
103
|
+
**_SPAN,
|
|
104
|
+
"_module": "string",
|
|
105
|
+
},
|
|
106
|
+
),
|
|
107
|
+
NodeLabel(
|
|
108
|
+
"PyCallable",
|
|
109
|
+
"PySymbol",
|
|
110
|
+
"signature",
|
|
111
|
+
{
|
|
112
|
+
"signature": "string",
|
|
113
|
+
"name": "string",
|
|
114
|
+
"path": "string",
|
|
115
|
+
"return_type": "string",
|
|
116
|
+
"cyclomatic_complexity": "integer",
|
|
117
|
+
"code": "string",
|
|
118
|
+
"code_start_line": "integer",
|
|
119
|
+
**_SPAN,
|
|
120
|
+
"docstring": "string",
|
|
121
|
+
"decorators": "string[]",
|
|
122
|
+
"parameters_json": "string",
|
|
123
|
+
"accessed_symbols_json": "string",
|
|
124
|
+
"_module": "string",
|
|
125
|
+
},
|
|
126
|
+
),
|
|
127
|
+
NodeLabel(
|
|
128
|
+
"PyExternal",
|
|
129
|
+
"PySymbol",
|
|
130
|
+
"signature",
|
|
131
|
+
{"signature": "string", "name": "string", "module": "string"},
|
|
132
|
+
),
|
|
133
|
+
NodeLabel("PyPackage", "PyPackage", "name", {"name": "string"}),
|
|
134
|
+
NodeLabel(
|
|
135
|
+
"PyDecorator",
|
|
136
|
+
"PyDecorator",
|
|
137
|
+
"name",
|
|
138
|
+
{"name": "string"},
|
|
139
|
+
),
|
|
140
|
+
NodeLabel(
|
|
141
|
+
"PyCallSite",
|
|
142
|
+
"PyCallSite",
|
|
143
|
+
"id",
|
|
144
|
+
{
|
|
145
|
+
"id": "string",
|
|
146
|
+
"method_name": "string",
|
|
147
|
+
"receiver_expr": "string",
|
|
148
|
+
"receiver_type": "string",
|
|
149
|
+
"argument_types": "string[]",
|
|
150
|
+
"arguments_json": "string",
|
|
151
|
+
"return_type": "string",
|
|
152
|
+
"callee_signature": "string",
|
|
153
|
+
"is_constructor_call": "boolean",
|
|
154
|
+
"start_line": "integer",
|
|
155
|
+
"start_column": "integer",
|
|
156
|
+
"end_line": "integer",
|
|
157
|
+
"end_column": "integer",
|
|
158
|
+
"_module": "string",
|
|
159
|
+
},
|
|
160
|
+
),
|
|
161
|
+
NodeLabel(
|
|
162
|
+
"PyAttribute",
|
|
163
|
+
"PyAttribute",
|
|
164
|
+
"id",
|
|
165
|
+
{
|
|
166
|
+
"id": "string",
|
|
167
|
+
"name": "string",
|
|
168
|
+
"type": "string",
|
|
169
|
+
"initializer": "string",
|
|
170
|
+
"docstring": "string",
|
|
171
|
+
**_SPAN,
|
|
172
|
+
"_module": "string",
|
|
173
|
+
},
|
|
174
|
+
),
|
|
175
|
+
NodeLabel(
|
|
176
|
+
"PyVariable",
|
|
177
|
+
"PyVariable",
|
|
178
|
+
"id",
|
|
179
|
+
{
|
|
180
|
+
"id": "string",
|
|
181
|
+
"name": "string",
|
|
182
|
+
"type": "string",
|
|
183
|
+
"initializer": "string",
|
|
184
|
+
"scope": "string",
|
|
185
|
+
**_SPAN,
|
|
186
|
+
"_module": "string",
|
|
187
|
+
},
|
|
188
|
+
),
|
|
33
189
|
]
|
|
34
190
|
|
|
191
|
+
_DECL_TARGETS = ["PyClass", "PyCallable"]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
REL_TYPES: List[RelType] = [
|
|
195
|
+
RelType("PY_HAS_MODULE", ["PyApplication"], ["PyModule"]),
|
|
196
|
+
RelType("PY_DECLARES", ["PyModule", "PyClass", "PyCallable"], _DECL_TARGETS),
|
|
197
|
+
RelType("PY_HAS_METHOD", ["PyClass"], ["PyCallable"]),
|
|
198
|
+
RelType("PY_HAS_ATTRIBUTE", ["PyClass"], ["PyAttribute"]),
|
|
199
|
+
RelType("PY_DECLARES_VAR", ["PyModule", "PyCallable"], ["PyVariable"]),
|
|
200
|
+
RelType("PY_HAS_CALLSITE", ["PyCallable"], ["PyCallSite"]),
|
|
201
|
+
RelType("PY_RESOLVES_TO", ["PyCallSite"], ["PyCallable", "PyExternal"]),
|
|
202
|
+
RelType(
|
|
203
|
+
"PY_CALLS",
|
|
204
|
+
["PyCallable", "PyExternal"],
|
|
205
|
+
["PyCallable", "PyExternal"],
|
|
206
|
+
{"weight": "integer", "provenance": "string[]"},
|
|
207
|
+
),
|
|
208
|
+
RelType("PY_EXTENDS", ["PyClass"], ["PyClass"]),
|
|
209
|
+
RelType(
|
|
210
|
+
"PY_IMPORTS",
|
|
211
|
+
["PyModule"],
|
|
212
|
+
["PyModule", "PyPackage"],
|
|
213
|
+
{"spellings": "string[]", "imported_names": "string[]", "aliases": "string[]"},
|
|
214
|
+
),
|
|
215
|
+
RelType("PY_DECORATED_BY", ["PyCallable"], ["PyDecorator"]),
|
|
216
|
+
]
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def uniqueness_constraints() -> list[str]:
|
|
220
|
+
"""One uniqueness constraint per distinct (merge_label, key)."""
|
|
221
|
+
seen: set[tuple[str, str]] = set()
|
|
222
|
+
out: list[str] = []
|
|
223
|
+
|
|
224
|
+
for node in NODE_LABELS:
|
|
225
|
+
identifier = (node.merge_label, node.key)
|
|
226
|
+
if identifier in seen:
|
|
227
|
+
continue
|
|
228
|
+
|
|
229
|
+
seen.add(identifier)
|
|
230
|
+
out.append(
|
|
231
|
+
f"CREATE CONSTRAINT {node.merge_label.lower()}_{node.key} "
|
|
232
|
+
f"IF NOT EXISTS FOR (x:{node.merge_label}) "
|
|
233
|
+
f"REQUIRE x.{node.key} IS UNIQUE"
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
return out
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
CONSTRAINTS: List[str] = uniqueness_constraints()
|
|
240
|
+
|
|
35
241
|
INDEXES: List[str] = [
|
|
36
242
|
"CREATE INDEX py_callable_name IF NOT EXISTS FOR (c:PyCallable) ON (c.name)",
|
|
37
243
|
"CREATE INDEX py_class_name IF NOT EXISTS FOR (c:PyClass) ON (c.name)",
|
|
38
244
|
"CREATE FULLTEXT INDEX py_code_fts IF NOT EXISTS FOR (c:PyCallable) ON EACH [c.code, c.docstring]",
|
|
39
245
|
]
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@dataclass
|
|
249
|
+
class SchemaDocument:
|
|
250
|
+
schema_version: str
|
|
251
|
+
generator: str
|
|
252
|
+
marker_labels: List[str]
|
|
253
|
+
node_labels: List[NodeLabel]
|
|
254
|
+
relationship_types: List[RelType]
|
|
255
|
+
constraints: List[str]
|
|
256
|
+
indexes: List[str]
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def build_schema_document() -> dict:
|
|
260
|
+
"""Build the full machine-readable schema document emitted by ``--emit schema``."""
|
|
261
|
+
return {
|
|
262
|
+
"schema_version": SCHEMA_VERSION,
|
|
263
|
+
"generator": "codeanalyzer-python",
|
|
264
|
+
"marker_labels": list(MARKER_LABELS),
|
|
265
|
+
"node_labels": [
|
|
266
|
+
{
|
|
267
|
+
"label": n.label,
|
|
268
|
+
"merge_label": n.merge_label,
|
|
269
|
+
"key": n.key,
|
|
270
|
+
"properties": n.properties,
|
|
271
|
+
}
|
|
272
|
+
for n in NODE_LABELS
|
|
273
|
+
],
|
|
274
|
+
"relationship_types": [
|
|
275
|
+
{
|
|
276
|
+
"type": r.type,
|
|
277
|
+
"from": r.from_labels,
|
|
278
|
+
"to": r.to_labels,
|
|
279
|
+
"properties": r.properties,
|
|
280
|
+
}
|
|
281
|
+
for r in REL_TYPES
|
|
282
|
+
],
|
|
283
|
+
"constraints": list(CONSTRAINTS),
|
|
284
|
+
"indexes": list(INDEXES),
|
|
285
|
+
}
|