codeanalyzer-python 0.3.0__py3-none-any.whl → 1.0.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 +77 -4
- codeanalyzer/core.py +174 -72
- codeanalyzer/dataflow/__init__.py +35 -0
- codeanalyzer/dataflow/access_paths.py +563 -0
- codeanalyzer/dataflow/alias.py +93 -0
- codeanalyzer/dataflow/builder.py +688 -0
- codeanalyzer/dataflow/cfg.py +605 -0
- codeanalyzer/dataflow/defuse.py +113 -0
- codeanalyzer/dataflow/dominance.py +140 -0
- codeanalyzer/dataflow/identity.py +91 -0
- codeanalyzer/dataflow/pdg.py +100 -0
- codeanalyzer/dataflow/scalpel_oracle.py +269 -0
- codeanalyzer/dataflow/scc.py +91 -0
- codeanalyzer/dataflow/sdg.py +424 -0
- codeanalyzer/dataflow/slicing.py +93 -0
- codeanalyzer/dataflow/summaries.py +217 -0
- codeanalyzer/dataflow/syntactic.py +26 -0
- codeanalyzer/neo4j/__init__.py +1 -1
- codeanalyzer/neo4j/bolt.py +19 -4
- codeanalyzer/neo4j/cypher.py +9 -3
- codeanalyzer/neo4j/emit.py +10 -5
- codeanalyzer/neo4j/project.py +307 -60
- codeanalyzer/neo4j/rows.py +18 -15
- codeanalyzer/neo4j/schema.py +297 -15
- codeanalyzer/options/options.py +4 -0
- codeanalyzer/provenance.py +61 -0
- codeanalyzer/schema/__init__.py +19 -0
- codeanalyzer/schema/assign_ids.py +37 -0
- codeanalyzer/schema/call_graph_ids.py +12 -0
- codeanalyzer/schema/ids.py +23 -0
- codeanalyzer/schema/l1_body.py +29 -0
- codeanalyzer/schema/l2_callees.py +36 -0
- codeanalyzer/schema/py_schema.py +175 -26
- codeanalyzer/semantic_analysis/call_graph.py +24 -27
- codeanalyzer/semantic_analysis/pycg/pycg_analysis.py +10 -10
- codeanalyzer/semantic_analysis/pycg/shard_planner.py +7 -7
- codeanalyzer/syntactic_analysis/import_resolver.py +67 -0
- codeanalyzer/syntactic_analysis/symbol_table_builder.py +103 -20
- {codeanalyzer_python-0.3.0.dist-info → codeanalyzer_python-1.0.0.dist-info}/METADATA +233 -43
- codeanalyzer_python-1.0.0.dist-info/RECORD +59 -0
- {codeanalyzer_python-0.3.0.dist-info → codeanalyzer_python-1.0.0.dist-info}/WHEEL +1 -1
- codeanalyzer/neo4j/catalog.py +0 -245
- codeanalyzer_python-0.3.0.dist-info/RECORD +0 -38
- {codeanalyzer_python-0.3.0.dist-info → codeanalyzer_python-1.0.0.dist-info}/entry_points.txt +0 -0
- {codeanalyzer_python-0.3.0.dist-info → codeanalyzer_python-1.0.0.dist-info}/licenses/LICENSE +0 -0
- {codeanalyzer_python-0.3.0.dist-info → codeanalyzer_python-1.0.0.dist-info}/licenses/NOTICE +0 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
################################################################################
|
|
2
|
+
# Copyright IBM Corporation 2025
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
################################################################################
|
|
16
|
+
|
|
17
|
+
"""Stage 6 of the level-3 dataflow ladder: bottom-up function summaries.
|
|
18
|
+
|
|
19
|
+
A summary is relational: which formal inputs (parameters, captures, read
|
|
20
|
+
globals) may flow to which formal outputs (the return value, caller-visible
|
|
21
|
+
parameter mutations, written globals). Summaries compose bottom-up over the
|
|
22
|
+
SCC condensation DAG of the call-graph oracle; within an SCC (mutual
|
|
23
|
+
recursion) all members iterate to a monotone fixpoint — the domains (formal
|
|
24
|
+
keys and qualified global names) are finite and effects only grow, so
|
|
25
|
+
termination is structural. k-limiting bounds the access-path vocabulary.
|
|
26
|
+
|
|
27
|
+
At statement granularity a callsite node is already a transformer (all its
|
|
28
|
+
defs depend on all its uses), so the composition step callee summaries
|
|
29
|
+
actually contribute is the *global footprint*: a callsite node gains the
|
|
30
|
+
callee's transitive global reads as uses and writes as defs, the reaching
|
|
31
|
+
definitions are re-solved, and flows are re-derived. External/unmodeled
|
|
32
|
+
callees default to conservative pass-through (their argument paths are
|
|
33
|
+
already weak-defined and used at the call statement).
|
|
34
|
+
|
|
35
|
+
Summary flow keys: ``param:NAME``, ``capture:NAME``, ``global:MODULE::NAME``
|
|
36
|
+
for inputs; ``return``, ``param:NAME`` (mutation), ``global:MODULE::NAME``
|
|
37
|
+
for outputs.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import ast
|
|
43
|
+
from dataclasses import dataclass, field
|
|
44
|
+
from typing import Dict, List, Optional, Set, Tuple
|
|
45
|
+
|
|
46
|
+
from codeanalyzer.dataflow.access_paths import RETURN_PATH, base_of, suffix_of
|
|
47
|
+
from codeanalyzer.dataflow.alias import TypeBasedAliasOracle
|
|
48
|
+
from codeanalyzer.dataflow.defuse import DDGEdge, ddg_edges
|
|
49
|
+
from codeanalyzer.dataflow.pdg import FunctionPDG
|
|
50
|
+
from codeanalyzer.dataflow.scc import strongly_connected_components
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class CallSite:
|
|
55
|
+
"""One resolved call at one CFG statement node (builder-provided)."""
|
|
56
|
+
|
|
57
|
+
node_id: int
|
|
58
|
+
targets: Tuple[str, ...] # callee signatures declared in the symbol table
|
|
59
|
+
# callee param name -> actual access path (None: a non-path expression)
|
|
60
|
+
arg_paths: Tuple[Tuple[str, Optional[str]], ...] = ()
|
|
61
|
+
line: int = -1
|
|
62
|
+
|
|
63
|
+
def arg_path_of(self, param: str) -> Optional[str]:
|
|
64
|
+
for name, path in self.arg_paths:
|
|
65
|
+
if name == param:
|
|
66
|
+
return path
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class FunctionInfo:
|
|
72
|
+
"""Everything the interprocedural stages need about one callable."""
|
|
73
|
+
|
|
74
|
+
signature: str
|
|
75
|
+
pdg: FunctionPDG
|
|
76
|
+
oracle: TypeBasedAliasOracle
|
|
77
|
+
call_sites: List[CallSite] = field(default_factory=list)
|
|
78
|
+
# Nested callables defined at a statement node: (def_node_id, nested_sig).
|
|
79
|
+
nested_defs: List[Tuple[int, str]] = field(default_factory=list)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class FunctionSummary:
|
|
84
|
+
global_reads: Set[str] = field(default_factory=set)
|
|
85
|
+
global_writes: Set[str] = field(default_factory=set)
|
|
86
|
+
mutated_params: Set[str] = field(default_factory=set)
|
|
87
|
+
flows: Set[Tuple[str, str]] = field(default_factory=set)
|
|
88
|
+
|
|
89
|
+
def __eq__(self, other):
|
|
90
|
+
return (
|
|
91
|
+
isinstance(other, FunctionSummary)
|
|
92
|
+
and self.global_reads == other.global_reads
|
|
93
|
+
and self.global_writes == other.global_writes
|
|
94
|
+
and self.mutated_params == other.mutated_params
|
|
95
|
+
and self.flows == other.flows
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _is_global(path: str) -> bool:
|
|
100
|
+
return "::" in base_of(path)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def augmented_facts(info: FunctionInfo, summaries: Dict[str, FunctionSummary]):
|
|
104
|
+
"""Per-node facts with callee global footprints injected at callsites."""
|
|
105
|
+
facts = {nid: f for nid, f in info.pdg.facts.items()}
|
|
106
|
+
out = {}
|
|
107
|
+
for nid, f in facts.items():
|
|
108
|
+
out[nid] = type(f)(defs=set(f.defs), uses=set(f.uses))
|
|
109
|
+
for cs in info.call_sites:
|
|
110
|
+
for target in cs.targets:
|
|
111
|
+
s = summaries.get(target)
|
|
112
|
+
if s is None:
|
|
113
|
+
continue
|
|
114
|
+
out[cs.node_id].uses |= s.global_reads
|
|
115
|
+
out[cs.node_id].defs |= s.global_writes
|
|
116
|
+
return out
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def solve_function(
|
|
120
|
+
info: FunctionInfo, summaries: Dict[str, FunctionSummary]
|
|
121
|
+
) -> Tuple[FunctionSummary, Dict[int, object], List[DDGEdge]]:
|
|
122
|
+
"""One summary iteration: inject callee footprints, re-solve reaching
|
|
123
|
+
definitions, derive flows. Returns (summary, augmented facts, DDG)."""
|
|
124
|
+
facts = augmented_facts(info, summaries)
|
|
125
|
+
ddg = ddg_edges(info.pdg.cfg, facts, info.oracle)
|
|
126
|
+
|
|
127
|
+
# Forward adjacency over DDG ∪ CDG (a statement transforms all its
|
|
128
|
+
# inputs into all its outputs — statement-granularity posture).
|
|
129
|
+
adj: Dict[int, List[int]] = {}
|
|
130
|
+
for e in ddg:
|
|
131
|
+
adj.setdefault(e.source, []).append(e.target)
|
|
132
|
+
for e in info.pdg.edges:
|
|
133
|
+
if e.type == "CDG":
|
|
134
|
+
adj.setdefault(e.source, []).append(e.target)
|
|
135
|
+
|
|
136
|
+
entry = info.pdg.cfg.entry_id
|
|
137
|
+
scope = info.pdg.scope
|
|
138
|
+
|
|
139
|
+
# Seeds: the ENTRY-def DDG edges, grouped by formal key.
|
|
140
|
+
seeds: Dict[str, Set[int]] = {}
|
|
141
|
+
for e in ddg:
|
|
142
|
+
if e.source != entry:
|
|
143
|
+
continue
|
|
144
|
+
b = base_of(e.var)
|
|
145
|
+
if b == scope.self_name or b in scope.params:
|
|
146
|
+
key = f"param:{b}"
|
|
147
|
+
elif b in scope.captures:
|
|
148
|
+
key = f"capture:{b}"
|
|
149
|
+
elif _is_global(e.var):
|
|
150
|
+
key = f"global:{b}"
|
|
151
|
+
else:
|
|
152
|
+
continue
|
|
153
|
+
seeds.setdefault(key, set()).add(e.target)
|
|
154
|
+
|
|
155
|
+
def reach(start: Set[int]) -> Set[int]:
|
|
156
|
+
seen: Set[int] = set()
|
|
157
|
+
stack = list(start)
|
|
158
|
+
while stack:
|
|
159
|
+
n = stack.pop()
|
|
160
|
+
if n in seen:
|
|
161
|
+
continue
|
|
162
|
+
seen.add(n)
|
|
163
|
+
stack.extend(adj.get(n, []))
|
|
164
|
+
return seen
|
|
165
|
+
|
|
166
|
+
summary = FunctionSummary()
|
|
167
|
+
param_names = set(scope.params)
|
|
168
|
+
if scope.self_name:
|
|
169
|
+
param_names.add(scope.self_name)
|
|
170
|
+
|
|
171
|
+
for nid, f in facts.items():
|
|
172
|
+
if nid == entry:
|
|
173
|
+
continue
|
|
174
|
+
for d in f.defs:
|
|
175
|
+
b = base_of(d)
|
|
176
|
+
if _is_global(d):
|
|
177
|
+
summary.global_writes.add(b)
|
|
178
|
+
elif b in param_names and suffix_of(d):
|
|
179
|
+
summary.mutated_params.add(b)
|
|
180
|
+
for u in f.uses:
|
|
181
|
+
if _is_global(u):
|
|
182
|
+
summary.global_reads.add(base_of(u))
|
|
183
|
+
|
|
184
|
+
for key, start in seeds.items():
|
|
185
|
+
for nid in reach(start):
|
|
186
|
+
f = facts[nid]
|
|
187
|
+
if RETURN_PATH in f.defs:
|
|
188
|
+
summary.flows.add((key, "return"))
|
|
189
|
+
for d in f.defs:
|
|
190
|
+
b = base_of(d)
|
|
191
|
+
if _is_global(d):
|
|
192
|
+
summary.flows.add((key, f"global:{b}"))
|
|
193
|
+
elif b in param_names and suffix_of(d):
|
|
194
|
+
summary.flows.add((key, f"param:{b}"))
|
|
195
|
+
|
|
196
|
+
return summary, facts, ddg
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def compute_summaries(
|
|
200
|
+
infos: Dict[str, FunctionInfo],
|
|
201
|
+
call_edges: List[Tuple[str, str]],
|
|
202
|
+
) -> Dict[str, FunctionSummary]:
|
|
203
|
+
"""Bottom-up composition over the SCC condensation DAG, monotone fixpoint
|
|
204
|
+
within each SCC."""
|
|
205
|
+
order = strongly_connected_components(sorted(infos), call_edges)
|
|
206
|
+
summaries: Dict[str, FunctionSummary] = {}
|
|
207
|
+
for scc in order:
|
|
208
|
+
members = [s for s in scc if s in infos]
|
|
209
|
+
changed = True
|
|
210
|
+
while changed:
|
|
211
|
+
changed = False
|
|
212
|
+
for sig in members:
|
|
213
|
+
new, _, _ = solve_function(infos[sig], summaries)
|
|
214
|
+
if summaries.get(sig) != new:
|
|
215
|
+
summaries[sig] = new
|
|
216
|
+
changed = True
|
|
217
|
+
return summaries
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
################################################################################
|
|
2
|
+
# Copyright IBM Corporation 2025
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
################################################################################
|
|
16
|
+
|
|
17
|
+
"""The L3 (syntactic) alias oracle: two access paths alias iff they are the
|
|
18
|
+
identical path. Bypasses the type-based may-alias so def-use yields only
|
|
19
|
+
name-equality (textual) edges — the alias-derived edges are the L4 delta."""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SyntacticOracle:
|
|
25
|
+
def may_alias(self, path_a: str, path_b: str) -> bool:
|
|
26
|
+
return path_a == path_b
|
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/bolt.py
CHANGED
|
@@ -44,7 +44,10 @@ from codeanalyzer.neo4j.rows import EdgeRow, GraphRows, NodeRow, chunk
|
|
|
44
44
|
from codeanalyzer.neo4j.schema import CONSTRAINTS, INDEXES
|
|
45
45
|
from codeanalyzer.utils import logger
|
|
46
46
|
|
|
47
|
-
DESCENDANTS =
|
|
47
|
+
DESCENDANTS = (
|
|
48
|
+
"[:PY_DECLARES|PY_HAS_METHOD|PY_HAS_ATTRIBUTE|PY_DECLARES_VAR"
|
|
49
|
+
"|PY_HAS_CALLSITE|PY_HAS_CFG_NODE*1..]"
|
|
50
|
+
)
|
|
48
51
|
BATCH = 1000
|
|
49
52
|
|
|
50
53
|
|
|
@@ -193,21 +196,33 @@ def _upsert_nodes(session, neo4j, nodes: List[NodeRow]) -> None:
|
|
|
193
196
|
def _upsert_edges(session, neo4j, edges: List[EdgeRow]) -> None:
|
|
194
197
|
groups: Dict[str, List[EdgeRow]] = {}
|
|
195
198
|
for e in edges:
|
|
196
|
-
key =
|
|
199
|
+
key = (
|
|
200
|
+
f"{e.type}|{e.from_ref.label}.{e.from_ref.key_prop}"
|
|
201
|
+
f"|{e.to_ref.label}.{e.to_ref.key_prop}|{e.key is not None}"
|
|
202
|
+
)
|
|
197
203
|
groups.setdefault(key, []).append(e)
|
|
198
204
|
|
|
199
205
|
for group in groups.values():
|
|
200
206
|
first = group[0]
|
|
201
207
|
from_ref, to_ref = first.from_ref, first.to_ref
|
|
208
|
+
# Discriminated relationships MERGE on ``{_k}`` so several distinct
|
|
209
|
+
# edges of one type coexist between the same endpoint pair (per-var
|
|
210
|
+
# PY_DDG, the true/false PY_CFG_NEXT pair). See EdgeRow.key.
|
|
211
|
+
rel_key = " {_k: row.k}" if first.key is not None else ""
|
|
202
212
|
cypher = (
|
|
203
213
|
f"UNWIND $rows AS row "
|
|
204
214
|
f"MATCH (a:{from_ref.label} {{{from_ref.key_prop}: row.f}}) "
|
|
205
215
|
f"MATCH (b:{to_ref.label} {{{to_ref.key_prop}: row.t}}) "
|
|
206
|
-
f"MERGE (a)-[r:{first.type}]->(b) SET r += row.p"
|
|
216
|
+
f"MERGE (a)-[r:{first.type}{rel_key}]->(b) SET r += row.p"
|
|
207
217
|
)
|
|
208
218
|
for batch in chunk(group, BATCH):
|
|
209
219
|
payload = [
|
|
210
|
-
{
|
|
220
|
+
{
|
|
221
|
+
"f": e.from_ref.value,
|
|
222
|
+
"t": e.to_ref.value,
|
|
223
|
+
"k": e.key,
|
|
224
|
+
"p": _to_params(e.props, neo4j),
|
|
225
|
+
}
|
|
211
226
|
for e in batch
|
|
212
227
|
]
|
|
213
228
|
with session() as s:
|
codeanalyzer/neo4j/cypher.py
CHANGED
|
@@ -115,24 +115,30 @@ def _node_statements(nodes: List[NodeRow]) -> List[str]:
|
|
|
115
115
|
def _edge_statements(edges: List[EdgeRow]) -> List[str]:
|
|
116
116
|
groups: Dict[str, List[EdgeRow]] = {}
|
|
117
117
|
for e in edges:
|
|
118
|
-
key =
|
|
118
|
+
key = (
|
|
119
|
+
f"{e.type}|{e.from_ref.label}.{e.from_ref.key_prop}"
|
|
120
|
+
f"|{e.to_ref.label}.{e.to_ref.key_prop}|{e.key is not None}"
|
|
121
|
+
)
|
|
119
122
|
groups.setdefault(key, []).append(e)
|
|
120
123
|
|
|
121
124
|
blocks: List[str] = []
|
|
122
125
|
for group in groups.values():
|
|
123
126
|
first = group[0]
|
|
124
127
|
from_ref, to_ref = first.from_ref, first.to_ref
|
|
128
|
+
# Discriminated relationships MERGE on ``{_k}`` — see EdgeRow.key.
|
|
129
|
+
rel_key = " {_k: row.k}" if first.key is not None else ""
|
|
125
130
|
for batch in chunk(group, BATCH):
|
|
126
131
|
rows_lit = ",\n".join(
|
|
127
132
|
f" {{f: {cypher_value(e.from_ref.value)}, t: {cypher_value(e.to_ref.value)}, "
|
|
128
|
-
f"
|
|
133
|
+
+ (f"k: {cypher_value(e.key)}, " if first.key is not None else "")
|
|
134
|
+
+ f"p: {cypher_map(e.props)}}}"
|
|
129
135
|
for e in batch
|
|
130
136
|
)
|
|
131
137
|
blocks.append(
|
|
132
138
|
f"UNWIND [\n{rows_lit}\n] AS row\n"
|
|
133
139
|
f"MATCH (a:{from_ref.label} {{{from_ref.key_prop}: row.f}})\n"
|
|
134
140
|
f"MATCH (b:{to_ref.label} {{{to_ref.key_prop}: row.t}})\n"
|
|
135
|
-
f"MERGE (a)-[r:{first.type}]->(b)\n"
|
|
141
|
+
f"MERGE (a)-[r:{first.type}{rel_key}]->(b)\n"
|
|
136
142
|
f"SET r += row.p;"
|
|
137
143
|
)
|
|
138
144
|
return blocks
|
codeanalyzer/neo4j/emit.py
CHANGED
|
@@ -28,17 +28,18 @@ 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
|
|
35
|
-
from codeanalyzer.schema import
|
|
35
|
+
from codeanalyzer.schema import Analysis
|
|
36
|
+
from codeanalyzer.schema.assign_ids import assign_ids
|
|
36
37
|
from codeanalyzer.utils import logger
|
|
37
38
|
|
|
38
39
|
|
|
39
40
|
def emit_schema(output: Optional[Path]) -> None:
|
|
40
41
|
"""Emit the Neo4j schema contract (``schema.json``) — a static artifact derived
|
|
41
|
-
from the in-repo
|
|
42
|
+
from the in-repo schema, independent of any analyzed project. With no
|
|
42
43
|
``output`` it prints to stdout."""
|
|
43
44
|
doc = json.dumps(build_schema_document(), indent=2) + "\n"
|
|
44
45
|
if output is None:
|
|
@@ -49,11 +50,15 @@ def emit_schema(output: Optional[Path]) -> None:
|
|
|
49
50
|
logger.info(f"Neo4j schema written to {output / 'schema.json'}")
|
|
50
51
|
|
|
51
52
|
|
|
52
|
-
def emit_neo4j(
|
|
53
|
+
def emit_neo4j(analysis: Analysis, options: AnalysisOptions) -> None:
|
|
53
54
|
"""Project the analysis to a graph and write it: a live Bolt push when
|
|
54
55
|
``--neo4j-uri`` is set, otherwise a self-contained ``graph.cypher`` snapshot."""
|
|
55
56
|
app_name = options.app_name or Path(options.input).resolve().name
|
|
56
|
-
|
|
57
|
+
# ``assign_ids`` is idempotent: it stamps every module/class/callable with its
|
|
58
|
+
# canonical ``can://`` id and returns the ``signature -> id`` map the projection
|
|
59
|
+
# keys nodes on, so the JSON and Neo4j projections agree.
|
|
60
|
+
sig_to_id = assign_ids(analysis.application, app_name)
|
|
61
|
+
rows = project(analysis.application, app_name, sig_to_id, analyzer=analysis.analyzer)
|
|
57
62
|
|
|
58
63
|
if options.neo4j_uri:
|
|
59
64
|
cfg = BoltConfig(
|