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/neo4j/project.py
CHANGED
|
@@ -47,7 +47,8 @@ from codeanalyzer.schema import (
|
|
|
47
47
|
PyModule,
|
|
48
48
|
PyVariableDeclaration,
|
|
49
49
|
)
|
|
50
|
-
from codeanalyzer.schema.
|
|
50
|
+
from codeanalyzer.schema.ids import application_id, purl_pypi
|
|
51
|
+
from codeanalyzer.schema.py_schema import PyDecorator
|
|
51
52
|
|
|
52
53
|
|
|
53
54
|
def project(app: PyApplication, app_name: str, sig_to_id: dict,
|
|
@@ -98,7 +99,17 @@ def project(app: PyApplication, app_name: str, sig_to_id: dict,
|
|
|
98
99
|
|
|
99
100
|
# Level-3 CPG overlay: each callable's v2 body/cfg/cdg/ddg. Idempotent under
|
|
100
101
|
# MERGE — a no-op when no callable carries L3 fields (levels 1/2).
|
|
101
|
-
_project_program_graphs(b, app)
|
|
102
|
+
_project_program_graphs(b, app, externals, sig_to_id)
|
|
103
|
+
|
|
104
|
+
# Neutral artifact/dependency subgraph (Task 6). L1 data — always present,
|
|
105
|
+
# full-depth-always regardless of -a.
|
|
106
|
+
_project_artifacts(b, app, app_name, app_ref)
|
|
107
|
+
|
|
108
|
+
# config_use (#162): the resolved-read bridge (PyBodyNode from
|
|
109
|
+
# _project_program_graphs above) into the config-key subgraph
|
|
110
|
+
# (ConfigKey from _project_artifacts above), plus first-class unresolved
|
|
111
|
+
# reads.
|
|
112
|
+
_project_config_uses(b, app, app_ref, externals, sig_to_id)
|
|
102
113
|
|
|
103
114
|
return b.finish()
|
|
104
115
|
|
|
@@ -109,7 +120,7 @@ def project(app: PyApplication, app_name: str, sig_to_id: dict,
|
|
|
109
120
|
|
|
110
121
|
|
|
111
122
|
def _global_ordinal(callable_id: str, local_key: str) -> str:
|
|
112
|
-
"""The globally-unique
|
|
123
|
+
"""The globally-unique PyBodyNode merge key for a callable's body node: the
|
|
113
124
|
callable's ``can://`` id joined to its LOCAL body key with a single ``@``.
|
|
114
125
|
The synthetic bookends already carry the leading ``@`` (``"@entry"``/
|
|
115
126
|
``"@exit"``); real statements are bare ``"line:col"`` and gain the ``@``.
|
|
@@ -124,18 +135,20 @@ def _global_ordinal(callable_id: str, local_key: str) -> str:
|
|
|
124
135
|
)
|
|
125
136
|
|
|
126
137
|
|
|
127
|
-
def
|
|
128
|
-
return NodeRef("
|
|
138
|
+
def _body_ref(callable_id: str, local_key: str) -> NodeRef:
|
|
139
|
+
return NodeRef("PyBodyNode", "id", _global_ordinal(callable_id, local_key))
|
|
129
140
|
|
|
130
141
|
|
|
131
|
-
def _project_program_graphs(
|
|
142
|
+
def _project_program_graphs(
|
|
143
|
+
b: RowBuilder, app: PyApplication, externals: dict, sig_to_id: dict
|
|
144
|
+
) -> None:
|
|
132
145
|
"""Level-3 CPG overlay, projected off each callable's v2 ``body``/``cfg``/
|
|
133
146
|
``cdg``/``ddg`` (populated by ``emit_l3_body`` at ``-a 3``; empty otherwise).
|
|
134
147
|
|
|
135
|
-
Node label ``
|
|
148
|
+
Node label ``PyBodyNode`` (merge key ``id`` = the GLOBAL ordinal
|
|
136
149
|
``<callable can:// id>@<local body key>`` — identical to the JSON body key
|
|
137
150
|
prefixed with the callable id, so the two projections agree). Edges:
|
|
138
|
-
``
|
|
151
|
+
``PY_HAS_BODY_NODE`` from the owning callable, ``PY_CFG_NEXT`` (prop ``kind``)
|
|
139
152
|
over the CFG, ``PY_CDG`` over control dependence, and ``PY_DDG`` (props
|
|
140
153
|
``var``/``prov``) over data dependence. The vocabulary is cross-language in
|
|
141
154
|
shape but PY_-namespaced like every other row family, so a multi-language
|
|
@@ -149,7 +162,7 @@ def _project_program_graphs(b: RowBuilder, app: PyApplication) -> None:
|
|
|
149
162
|
each callable's transitive pass-throughs (LOCAL ids → global refs), and the
|
|
150
163
|
app-level ``PY_PARAM_IN``/``PY_PARAM_OUT`` edges connect actual↔formal
|
|
151
164
|
vertices across callables (endpoints are already GLOBAL ordinals matching the
|
|
152
|
-
emitted ``
|
|
165
|
+
emitted ``PyBodyNode`` keys). All idempotent under MERGE — no-ops below L4."""
|
|
153
166
|
from codeanalyzer.semantic_analysis.call_graph import _walk_module_callables
|
|
154
167
|
|
|
155
168
|
for file_key, mod in app.symbol_table.items():
|
|
@@ -163,7 +176,7 @@ def _project_program_graphs(b: RowBuilder, app: PyApplication) -> None:
|
|
|
163
176
|
# their owning callsite (``parent``) instead of span lines; both
|
|
164
177
|
# are None on ordinary statement nodes and pruned away there.
|
|
165
178
|
ref = b.node(
|
|
166
|
-
["
|
|
179
|
+
["PyBodyNode"],
|
|
167
180
|
"id",
|
|
168
181
|
_global_ordinal(c.id, local_key),
|
|
169
182
|
prune(
|
|
@@ -173,23 +186,43 @@ def _project_program_graphs(b: RowBuilder, app: PyApplication) -> None:
|
|
|
173
186
|
"end_line": span.end[0] if span else None,
|
|
174
187
|
"var": node.of,
|
|
175
188
|
"call_node": node.parent,
|
|
189
|
+
# Call-site detail (#120). The JSON emits one node per
|
|
190
|
+
# call site; the graph now does too, instead of a
|
|
191
|
+
# separate :PyCallSite under a third id scheme.
|
|
192
|
+
"method_name": node.method_name,
|
|
193
|
+
"receiver_expr": node.receiver_expr,
|
|
194
|
+
"receiver_type": node.receiver_type,
|
|
195
|
+
"return_type": node.return_type,
|
|
196
|
+
"is_constructor_call": node.is_constructor_call,
|
|
197
|
+
"arguments_json": _stringify_if(node.arguments),
|
|
176
198
|
"_module": file_key,
|
|
177
199
|
}
|
|
178
200
|
),
|
|
179
201
|
)
|
|
180
|
-
b.edge("
|
|
202
|
+
b.edge("PY_HAS_BODY_NODE", owner, ref)
|
|
203
|
+
if node.kind == "call" and node.callee:
|
|
204
|
+
# `callee` is ALREADY a resolved can:// id (a declared callable
|
|
205
|
+
# or an @external home), so it must not go through
|
|
206
|
+
# `_symbol_ref`, which expects a dotted signature and would
|
|
207
|
+
# fall back to matching a `signature` property against an id --
|
|
208
|
+
# emitting an edge that matches nothing at load time.
|
|
209
|
+
b.edge(
|
|
210
|
+
"PY_RESOLVES_TO",
|
|
211
|
+
ref,
|
|
212
|
+
_call_endpoint(b, node.callee, externals, sig_to_id),
|
|
213
|
+
)
|
|
181
214
|
for e in c.cfg or []:
|
|
182
215
|
# kind-discriminated: a conditional's true/false pair between one
|
|
183
216
|
# endpoint pair must stay two relationships, not one MERGE.
|
|
184
217
|
b.edge(
|
|
185
218
|
"PY_CFG_NEXT",
|
|
186
|
-
|
|
187
|
-
|
|
219
|
+
_body_ref(c.id, e.src),
|
|
220
|
+
_body_ref(c.id, e.dst),
|
|
188
221
|
{"kind": e.kind},
|
|
189
222
|
key=e.kind,
|
|
190
223
|
)
|
|
191
224
|
for e in c.cdg or []:
|
|
192
|
-
b.edge("PY_CDG",
|
|
225
|
+
b.edge("PY_CDG", _body_ref(c.id, e.src), _body_ref(c.id, e.dst))
|
|
193
226
|
for e in c.ddg or []:
|
|
194
227
|
# (var, prov)-discriminated: the DDG legitimately carries several
|
|
195
228
|
# edges between one statement pair (one per variable, and the
|
|
@@ -197,32 +230,213 @@ def _project_program_graphs(b: RowBuilder, app: PyApplication) -> None:
|
|
|
197
230
|
# them and silently drops dependences.
|
|
198
231
|
b.edge(
|
|
199
232
|
"PY_DDG",
|
|
200
|
-
|
|
201
|
-
|
|
233
|
+
_body_ref(c.id, e.src),
|
|
234
|
+
_body_ref(c.id, e.dst),
|
|
202
235
|
prune({"var": e.var, "prov": list(e.prov) if e.prov else None}),
|
|
203
236
|
key=f"{e.var or ''}|{','.join(e.prov or [])}",
|
|
204
237
|
)
|
|
205
238
|
# L4 intraprocedural summaries (transitive actual_in → actual_out
|
|
206
|
-
# pass-throughs); LOCAL ids resolved to global
|
|
239
|
+
# pass-throughs); LOCAL ids resolved to global PyBodyNode refs.
|
|
207
240
|
for e in c.summary or []:
|
|
208
|
-
b.edge("PY_SUMMARY",
|
|
241
|
+
b.edge("PY_SUMMARY", _body_ref(c.id, e.src), _body_ref(c.id, e.dst))
|
|
209
242
|
|
|
210
243
|
# L4 interprocedural parameter passing, emitted once at the app scope. The
|
|
211
244
|
# endpoints are ALREADY global ordinals (emit_l4 resolved them through the
|
|
212
|
-
# endpoint functions' identity maps), so they land on the very
|
|
245
|
+
# endpoint functions' identity maps), so they land on the very PyBodyNode ids
|
|
213
246
|
# projected above — a formal_in global id equals _global_ordinal(callee.id,
|
|
214
247
|
# "@formal_in:0"). No dangling references.
|
|
215
248
|
for e in app.param_in or []:
|
|
216
249
|
b.edge(
|
|
217
250
|
"PY_PARAM_IN",
|
|
218
|
-
NodeRef("
|
|
219
|
-
NodeRef("
|
|
251
|
+
NodeRef("PyBodyNode", "id", e.src),
|
|
252
|
+
NodeRef("PyBodyNode", "id", e.dst),
|
|
220
253
|
)
|
|
221
254
|
for e in app.param_out or []:
|
|
222
255
|
b.edge(
|
|
223
256
|
"PY_PARAM_OUT",
|
|
224
|
-
NodeRef("
|
|
225
|
-
NodeRef("
|
|
257
|
+
NodeRef("PyBodyNode", "id", e.src),
|
|
258
|
+
NodeRef("PyBodyNode", "id", e.dst),
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# ----------------------------------------------------------------------------------------------
|
|
263
|
+
# Artifact / dependency subgraph (spec 2026-08-27, Task 6)
|
|
264
|
+
# ----------------------------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
_LOCK_BASENAMES = ("poetry.lock", "uv.lock", "Pipfile.lock")
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _import_ghost(b: RowBuilder, app_can_id: str, name: str) -> NodeRef:
|
|
270
|
+
"""A ``:PyExternal`` ghost for a bare imported module name (``PY_PROVIDES``'s
|
|
271
|
+
``provides_imports`` entries, ``PY_UNRESOLVED_IMPORT``'s ``module``).
|
|
272
|
+
|
|
273
|
+
``app.external_symbols`` only homes call-graph endpoints (``_home_external_
|
|
274
|
+
symbols`` walks ``app.call_graph``), so a module that is imported but never
|
|
275
|
+
called — the overwhelmingly common case for ``provides_imports`` and the
|
|
276
|
+
*only* case for an unresolved import — has no existing ghost to MERGE onto.
|
|
277
|
+
This builds one with the same id shape ``_call_endpoint``/``_home_external_
|
|
278
|
+
symbols`` use for a dot-less (no ``.`` in the signature) call target:
|
|
279
|
+
``<app can:// id>/@external/<name>``, ``module=None``. Same two-label
|
|
280
|
+
``["PySymbol", "PyExternal"]`` idiom as ``_call_endpoint`` -- the schema
|
|
281
|
+
declares :PyExternal's merge label as PySymbol, and RowBuilder MERGEs by
|
|
282
|
+
``(labels[0], value)``, so if a call to that same bare name is ever
|
|
283
|
+
projected too, both rows collapse onto this one node — correctly, since
|
|
284
|
+
they name the same real-world symbol."""
|
|
285
|
+
return b.node(
|
|
286
|
+
["PySymbol", "PyExternal"], "id", f"{app_can_id}/@external/{name}", {"name": name}
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _project_artifacts(b: RowBuilder, app: PyApplication, app_name: str, app_ref: NodeRef) -> None:
|
|
291
|
+
"""Non-code artifacts, declared dependencies and undeclared imports (Tasks
|
|
292
|
+
1-5) -- neutral ``Artifact``/``Package`` nodes with no ``Py`` prefix
|
|
293
|
+
(deliberate: cross-language merge targets, unlike everything else this
|
|
294
|
+
module projects). Always emitted regardless of ``-a`` -- this section is
|
|
295
|
+
L1 data, identical at every analysis level (mirrors ``analysis.json``)."""
|
|
296
|
+
app_can_id = application_id(app_name)
|
|
297
|
+
|
|
298
|
+
for path in sorted(app.artifacts or {}):
|
|
299
|
+
art = app.artifacts[path]
|
|
300
|
+
art_ref = b.node(
|
|
301
|
+
["Artifact"],
|
|
302
|
+
"id",
|
|
303
|
+
art.id,
|
|
304
|
+
prune(
|
|
305
|
+
{
|
|
306
|
+
"path": art.path,
|
|
307
|
+
"format": art.format,
|
|
308
|
+
"roles": art.roles,
|
|
309
|
+
"size_bytes": art.size_bytes,
|
|
310
|
+
"sha256": art.sha256,
|
|
311
|
+
"source": art.source,
|
|
312
|
+
"text_truncated": art.text_truncated,
|
|
313
|
+
"extraction": art.extraction,
|
|
314
|
+
}
|
|
315
|
+
),
|
|
316
|
+
)
|
|
317
|
+
b.edge("HAS_ARTIFACT", app_ref, art_ref)
|
|
318
|
+
|
|
319
|
+
# Config keys flattened out of this artifact (#152) -- sorted by key
|
|
320
|
+
# for deterministic row order, matching the JSON side's L1 determinism.
|
|
321
|
+
for ck in sorted(art.config_keys or [], key=lambda k: k.key):
|
|
322
|
+
ck_ref = b.node(
|
|
323
|
+
["ConfigKey"],
|
|
324
|
+
"id",
|
|
325
|
+
ck.id,
|
|
326
|
+
prune(
|
|
327
|
+
{
|
|
328
|
+
"key": ck.key,
|
|
329
|
+
"namespace": ck.namespace,
|
|
330
|
+
"value": ck.value,
|
|
331
|
+
"references": list(ck.references or []),
|
|
332
|
+
"start_line": ck.span.start[0] if ck.span else None,
|
|
333
|
+
"end_line": ck.span.end[0] if ck.span else None,
|
|
334
|
+
}
|
|
335
|
+
),
|
|
336
|
+
)
|
|
337
|
+
b.edge("DEFINES_CONFIG", art_ref, ck_ref)
|
|
338
|
+
|
|
339
|
+
# Every lock artifact present LOCKS every dependency it pinned. The pins
|
|
340
|
+
# from all lock files are already merged into one `locked_version` per
|
|
341
|
+
# dependency upstream (Task 5 `build_dependency_view`) -- there is no
|
|
342
|
+
# per-lock-file attribution to split on, so (like the JSON projection) a
|
|
343
|
+
# dependency locked with N lock artifacts present gets N LOCKS edges.
|
|
344
|
+
lock_ids = [
|
|
345
|
+
app.artifacts[p].id
|
|
346
|
+
for p in sorted(app.artifacts or {})
|
|
347
|
+
if p.rsplit("/", 1)[-1] in _LOCK_BASENAMES
|
|
348
|
+
]
|
|
349
|
+
|
|
350
|
+
# app.dependencies has one PyDependency per DECLARING MANIFEST, so a
|
|
351
|
+
# package declared in 2+ manifests (e.g. requirements.txt +
|
|
352
|
+
# requirements-dev.txt both listing "requests") walks this loop once per
|
|
353
|
+
# manifest. DECLARES_DEPENDENCY is correctly one row per declaration (its
|
|
354
|
+
# `from_ref` is the manifest, so those rows are already distinct) -- but
|
|
355
|
+
# LOCKS/PY_PROVIDES/PY_UNRESOLVED_IMPORT are per-PACKAGE facts, and
|
|
356
|
+
# RowBuilder.edge() is append-only (unlike node(), it does not MERGE-dedup)
|
|
357
|
+
# -- so without a guard they'd be emitted once per declaring manifest
|
|
358
|
+
# instead of once, violating GraphRows' documented deduped-bag contract.
|
|
359
|
+
seen: set = set()
|
|
360
|
+
|
|
361
|
+
for d in app.dependencies or []:
|
|
362
|
+
pkg_id = purl_pypi(d.name)
|
|
363
|
+
pkg_ref = b.node(["Package"], "id", pkg_id, {"ecosystem": "pypi", "name": d.name})
|
|
364
|
+
# kind-discriminated: the same manifest may declare one package twice
|
|
365
|
+
# under different kinds (e.g. requests in [project.dependencies] AND
|
|
366
|
+
# again under [project.optional-dependencies]) -- same endpoint pair,
|
|
367
|
+
# so a plain MERGE would collapse the two declarations into one row.
|
|
368
|
+
b.edge(
|
|
369
|
+
"DECLARES_DEPENDENCY",
|
|
370
|
+
NodeRef("Artifact", "id", d.declared_in),
|
|
371
|
+
pkg_ref,
|
|
372
|
+
prune({"spec": d.spec, "kind": d.kind, "extras": d.extras, "prov": d.prov, "direct": d.direct}),
|
|
373
|
+
key=d.kind,
|
|
374
|
+
)
|
|
375
|
+
if d.locked_version:
|
|
376
|
+
for lock_id in lock_ids:
|
|
377
|
+
key = ("LOCKS", lock_id, pkg_id)
|
|
378
|
+
if key not in seen:
|
|
379
|
+
seen.add(key)
|
|
380
|
+
b.edge(
|
|
381
|
+
"LOCKS",
|
|
382
|
+
NodeRef("Artifact", "id", lock_id),
|
|
383
|
+
pkg_ref,
|
|
384
|
+
{"version": d.locked_version},
|
|
385
|
+
)
|
|
386
|
+
for top in d.provides_imports:
|
|
387
|
+
ghost_ref = _import_ghost(b, app_can_id, top)
|
|
388
|
+
key = ("PY_PROVIDES", pkg_id, ghost_ref.value)
|
|
389
|
+
if key not in seen:
|
|
390
|
+
seen.add(key)
|
|
391
|
+
b.edge("PY_PROVIDES", pkg_ref, ghost_ref)
|
|
392
|
+
|
|
393
|
+
for u in app.unresolved_imports or []:
|
|
394
|
+
ghost_ref = _import_ghost(b, app_can_id, u.module)
|
|
395
|
+
key = ("PY_UNRESOLVED_IMPORT", app_ref.value, ghost_ref.value)
|
|
396
|
+
if key not in seen:
|
|
397
|
+
seen.add(key)
|
|
398
|
+
b.edge("PY_UNRESOLVED_IMPORT", app_ref, ghost_ref, prune({"prov": u.prov}))
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _project_config_uses(
|
|
402
|
+
b: RowBuilder, app: PyApplication, app_ref: NodeRef, externals: dict, sig_to_id: dict,
|
|
403
|
+
) -> None:
|
|
404
|
+
"""config_use (#162): PY_USES_CONFIG (`app.config_uses`) and
|
|
405
|
+
PY_READS_CONFIG_UNRESOLVED (`app.config_reads_unresolved`).
|
|
406
|
+
|
|
407
|
+
`PyConfigUseEdge.src`/`.dst` are already a GLOBAL ordinal id and a
|
|
408
|
+
ConfigKey id (both resolved upstream by `resolve_uses`), so — like
|
|
409
|
+
`param_in`/`param_out` — they address existing PyBodyNode/ConfigKey rows
|
|
410
|
+
directly with a plain :class:`NodeRef`; no defer-and-gate needed. Call
|
|
411
|
+
nodes enter a callable's `body` at L1 (before any config_use tier runs),
|
|
412
|
+
so the src PyBodyNode is always already projected by
|
|
413
|
+
`_project_program_graphs`, whatever level this ran at.
|
|
414
|
+
|
|
415
|
+
`PyConfigRead.callee` is already the full external `can://…/@external/…`
|
|
416
|
+
id (not a bare module name), so its ghost goes through `_call_endpoint`
|
|
417
|
+
(which looks it up in `externals` directly) rather than `_import_ghost`
|
|
418
|
+
(built for a bare imported name) — same inline node()+edge() shape
|
|
419
|
+
`PY_UNRESOLVED_IMPORT` uses. `_k` discriminates by (key, reason): the same
|
|
420
|
+
external callee (e.g. `os.getenv`) legitimately reads several distinct
|
|
421
|
+
undeclared/dynamic keys across a codebase, and without a discriminant a
|
|
422
|
+
plain endpoint-pair MERGE would collapse those onto one relationship.
|
|
423
|
+
"""
|
|
424
|
+
for e in app.config_uses:
|
|
425
|
+
b.edge(
|
|
426
|
+
"PY_USES_CONFIG",
|
|
427
|
+
NodeRef("PyBodyNode", "id", e.src),
|
|
428
|
+
NodeRef("ConfigKey", "id", e.dst),
|
|
429
|
+
prune({"prov": list(e.prov) if e.prov else None}),
|
|
430
|
+
)
|
|
431
|
+
for r in app.config_reads_unresolved:
|
|
432
|
+
ghost_ref = _call_endpoint(b, r.callee, externals, sig_to_id)
|
|
433
|
+
b.edge(
|
|
434
|
+
"PY_READS_CONFIG_UNRESOLVED",
|
|
435
|
+
app_ref,
|
|
436
|
+
ghost_ref,
|
|
437
|
+
prune({"key": r.key, "reason": r.reason, "prov": list(r.prov) if r.prov else None}),
|
|
438
|
+
# _k=(key,reason) does not per-site discriminate the non-literal bucket (accepted prop-list ceiling).
|
|
439
|
+
key=f"{r.key or ''}|{r.reason}",
|
|
226
440
|
)
|
|
227
441
|
|
|
228
442
|
|
|
@@ -369,6 +583,9 @@ def _project_class(
|
|
|
369
583
|
)
|
|
370
584
|
b.edge(parent_rel, parent, ref)
|
|
371
585
|
|
|
586
|
+
for d in cl.decorators or []:
|
|
587
|
+
_project_decorator(b, ref, d)
|
|
588
|
+
|
|
372
589
|
for base in cl.base_classes or []:
|
|
373
590
|
if base:
|
|
374
591
|
b.edge_to_symbol("PY_EXTENDS", ref, _symbol_ref(base, externals, sig_to_id))
|
|
@@ -397,19 +614,6 @@ def _project_callable(
|
|
|
397
614
|
for d in c.decorators or []:
|
|
398
615
|
_project_decorator(b, ref, d)
|
|
399
616
|
|
|
400
|
-
for s in c.call_sites or []:
|
|
401
|
-
# Key off the relative file (a call site lives in its callable's file) so ids stay portable.
|
|
402
|
-
cs_id = (
|
|
403
|
-
f"{file_key}#{s.start_line}:{s.start_column}-{s.end_line}:{s.end_column}"
|
|
404
|
-
)
|
|
405
|
-
cs = b.node(["PyCallSite"], "id", cs_id, _call_site_props(s, file_key))
|
|
406
|
-
b.edge("PY_HAS_CALLSITE", ref, cs)
|
|
407
|
-
if s.callee_signature:
|
|
408
|
-
b.edge_to_symbol(
|
|
409
|
-
"PY_RESOLVES_TO", cs,
|
|
410
|
-
_symbol_ref(s.callee_signature, externals, sig_to_id),
|
|
411
|
-
)
|
|
412
|
-
|
|
413
617
|
for v in c.local_variables or []:
|
|
414
618
|
_project_variable(b, file_key, ref, c.signature, v)
|
|
415
619
|
for ic in (c.callables or {}).values():
|
|
@@ -439,9 +643,36 @@ def _project_variable(
|
|
|
439
643
|
b.edge("PY_DECLARES_VAR", owner, ref)
|
|
440
644
|
|
|
441
645
|
|
|
442
|
-
def _project_decorator(b: RowBuilder, on: NodeRef, decorator:
|
|
443
|
-
|
|
444
|
-
|
|
646
|
+
def _project_decorator(b: RowBuilder, on: NodeRef, decorator: PyDecorator) -> None:
|
|
647
|
+
"""Project one decorator application (#128).
|
|
648
|
+
|
|
649
|
+
The merge key is the resolved ``qualified_name`` when Jedi supplies one, so
|
|
650
|
+
``@lru_cache`` and ``@lru_cache(maxsize=128)`` land on one node instead of two,
|
|
651
|
+
and two spellings of one decorator stop being separate nodes. Unresolved
|
|
652
|
+
decorators fall back to the written spelling. Per-application facts (the
|
|
653
|
+
arguments) ride on the relationship, not the shared node -- ``:PyDecorator``
|
|
654
|
+
has no ``_module`` and is never pruned, so anything application-specific on it
|
|
655
|
+
would accumulate across every project in the database.
|
|
656
|
+
"""
|
|
657
|
+
key = decorator.qualified_name or decorator.name
|
|
658
|
+
dec = b.node(
|
|
659
|
+
["PyDecorator"],
|
|
660
|
+
"name",
|
|
661
|
+
key,
|
|
662
|
+
{"name": key, "qualified_name": decorator.qualified_name or ""},
|
|
663
|
+
)
|
|
664
|
+
b.edge(
|
|
665
|
+
"PY_DECORATED_BY",
|
|
666
|
+
on,
|
|
667
|
+
dec,
|
|
668
|
+
{
|
|
669
|
+
"expression": decorator.expression or "",
|
|
670
|
+
"positional_arguments": list(decorator.positional_arguments or []),
|
|
671
|
+
"keyword_arguments_json": json.dumps(
|
|
672
|
+
dict(decorator.keyword_arguments or {}), sort_keys=True
|
|
673
|
+
),
|
|
674
|
+
},
|
|
675
|
+
)
|
|
445
676
|
|
|
446
677
|
|
|
447
678
|
# ----------------------------------------------------------------------------------------------
|
|
@@ -482,10 +713,13 @@ def _class_props(cl: PyClass, file_key: str, source: str) -> Props:
|
|
|
482
713
|
"name": cl.name,
|
|
483
714
|
"code": _span_code(source, cl.span),
|
|
484
715
|
"base_classes": list(cl.base_classes or []),
|
|
716
|
+
"decorators": [d.qualified_name or d.name for d in (cl.decorators or [])],
|
|
485
717
|
"docstring": _docstring_of(cl.comments),
|
|
486
718
|
"start_line": cl.start_line,
|
|
487
719
|
"end_line": cl.end_line,
|
|
488
720
|
"_module": file_key,
|
|
721
|
+
"is_entrypoint": bool(cl.entrypoints),
|
|
722
|
+
"entrypoint_frameworks": sorted({e.framework for e in (cl.entrypoints or [])}),
|
|
489
723
|
}
|
|
490
724
|
)
|
|
491
725
|
|
|
@@ -504,10 +738,13 @@ def _callable_props(c: PyCallable, file_key: str, source: str) -> Props:
|
|
|
504
738
|
"start_line": c.start_line,
|
|
505
739
|
"end_line": c.end_line,
|
|
506
740
|
"docstring": _docstring_of(c.comments),
|
|
507
|
-
"decorators":
|
|
741
|
+
"decorators": [d.qualified_name or d.name for d in (c.decorators or [])],
|
|
742
|
+
"modifiers": list(c.modifiers or []),
|
|
508
743
|
"parameters_json": _stringify_if(c.parameters),
|
|
509
744
|
"accessed_symbols_json": _stringify_if(c.accessed_symbols),
|
|
510
745
|
"_module": file_key,
|
|
746
|
+
"is_entrypoint": bool(c.entrypoints),
|
|
747
|
+
"entrypoint_frameworks": sorted({e.framework for e in (c.entrypoints or [])}),
|
|
511
748
|
}
|
|
512
749
|
)
|
|
513
750
|
|
|
@@ -542,26 +779,6 @@ def _variable_props(v: PyVariableDeclaration, var_id: str, file_key: str) -> Pro
|
|
|
542
779
|
)
|
|
543
780
|
|
|
544
781
|
|
|
545
|
-
def _call_site_props(s: PyCallsite, file_key: str) -> Props:
|
|
546
|
-
cs_id = f"{file_key}#{s.start_line}:{s.start_column}-{s.end_line}:{s.end_column}"
|
|
547
|
-
return prune(
|
|
548
|
-
{
|
|
549
|
-
"id": cs_id,
|
|
550
|
-
"method_name": s.method_name,
|
|
551
|
-
"receiver_expr": s.receiver_expr,
|
|
552
|
-
"receiver_type": s.receiver_type,
|
|
553
|
-
"argument_types": list(s.argument_types or []),
|
|
554
|
-
"arguments_json": _stringify_if(s.arguments),
|
|
555
|
-
"return_type": s.return_type,
|
|
556
|
-
"callee_signature": s.callee_signature,
|
|
557
|
-
"is_constructor_call": s.is_constructor_call,
|
|
558
|
-
"start_line": s.start_line,
|
|
559
|
-
"start_column": s.start_column,
|
|
560
|
-
"end_line": s.end_line,
|
|
561
|
-
"end_column": s.end_column,
|
|
562
|
-
"_module": file_key,
|
|
563
|
-
}
|
|
564
|
-
)
|
|
565
782
|
|
|
566
783
|
|
|
567
784
|
def _call_edge_props(weight: int, prov: List[str]) -> Props:
|
codeanalyzer/neo4j/schema.py
CHANGED
|
@@ -101,9 +101,12 @@ NODE_LABELS: List[NodeLabel] = [
|
|
|
101
101
|
"name": "string",
|
|
102
102
|
"code": "string",
|
|
103
103
|
"base_classes": "string[]",
|
|
104
|
+
"decorators": "string[]",
|
|
104
105
|
"docstring": "string",
|
|
105
106
|
**_SPAN,
|
|
106
107
|
"_module": "string",
|
|
108
|
+
"is_entrypoint": "boolean",
|
|
109
|
+
"entrypoint_frameworks": "string[]",
|
|
107
110
|
},
|
|
108
111
|
),
|
|
109
112
|
NodeLabel(
|
|
@@ -122,9 +125,12 @@ NODE_LABELS: List[NodeLabel] = [
|
|
|
122
125
|
**_SPAN,
|
|
123
126
|
"docstring": "string",
|
|
124
127
|
"decorators": "string[]",
|
|
128
|
+
"modifiers": "string[]",
|
|
125
129
|
"parameters_json": "string",
|
|
126
130
|
"accessed_symbols_json": "string",
|
|
127
131
|
"_module": "string",
|
|
132
|
+
"is_entrypoint": "boolean",
|
|
133
|
+
"entrypoint_frameworks": "string[]",
|
|
128
134
|
},
|
|
129
135
|
),
|
|
130
136
|
NodeLabel(
|
|
@@ -138,28 +144,7 @@ NODE_LABELS: List[NodeLabel] = [
|
|
|
138
144
|
"PyDecorator",
|
|
139
145
|
"PyDecorator",
|
|
140
146
|
"name",
|
|
141
|
-
{"name": "string"},
|
|
142
|
-
),
|
|
143
|
-
NodeLabel(
|
|
144
|
-
"PyCallSite",
|
|
145
|
-
"PyCallSite",
|
|
146
|
-
"id",
|
|
147
|
-
{
|
|
148
|
-
"id": "string",
|
|
149
|
-
"method_name": "string",
|
|
150
|
-
"receiver_expr": "string",
|
|
151
|
-
"receiver_type": "string",
|
|
152
|
-
"argument_types": "string[]",
|
|
153
|
-
"arguments_json": "string",
|
|
154
|
-
"return_type": "string",
|
|
155
|
-
"callee_signature": "string",
|
|
156
|
-
"is_constructor_call": "boolean",
|
|
157
|
-
"start_line": "integer",
|
|
158
|
-
"start_column": "integer",
|
|
159
|
-
"end_line": "integer",
|
|
160
|
-
"end_column": "integer",
|
|
161
|
-
"_module": "string",
|
|
162
|
-
},
|
|
147
|
+
{"name": "string", "qualified_name": "string"},
|
|
163
148
|
),
|
|
164
149
|
NodeLabel(
|
|
165
150
|
"PyAttribute",
|
|
@@ -196,18 +181,50 @@ NODE_LABELS: List[NodeLabel] = [
|
|
|
196
181
|
# another's. `id` = "<signature>#<node_id>"; parameter-passing nodes
|
|
197
182
|
# (formal/actual in/out) ride the same label with `var`/`call_node`.
|
|
198
183
|
NodeLabel(
|
|
199
|
-
"
|
|
200
|
-
"
|
|
184
|
+
"PyBodyNode",
|
|
185
|
+
"PyBodyNode",
|
|
201
186
|
"id",
|
|
202
187
|
{
|
|
203
188
|
"id": "string",
|
|
204
189
|
"kind": "string",
|
|
205
190
|
"var": "string",
|
|
206
191
|
"call_node": "string",
|
|
192
|
+
# Call-site detail (#120): the graph emits one node per call site,
|
|
193
|
+
# matching analysis.json, instead of a separate :PyCallSite.
|
|
194
|
+
"method_name": "string",
|
|
195
|
+
"receiver_expr": "string",
|
|
196
|
+
"receiver_type": "string",
|
|
197
|
+
"return_type": "string",
|
|
198
|
+
"is_constructor_call": "boolean",
|
|
199
|
+
"arguments_json": "string",
|
|
207
200
|
**_SPAN,
|
|
208
201
|
"_module": "string",
|
|
209
202
|
},
|
|
210
203
|
),
|
|
204
|
+
# Neutral artifact/dependency subgraph (spec 2026-08-27, Task 6). No `Py`
|
|
205
|
+
# prefix -- deliberate: `Artifact`/`Package` are cross-language merge
|
|
206
|
+
# targets, so a sibling-language analyzer over the same repo lands on the
|
|
207
|
+
# same nodes instead of a per-language duplicate. `PY_PROVIDES` /
|
|
208
|
+
# `PY_UNRESOLVED_IMPORT` stay PY_-namespaced (this analyzer's own claim
|
|
209
|
+
# about what an import resolves to) and target `:PyExternal`.
|
|
210
|
+
NodeLabel("Artifact", "Artifact", "id", {
|
|
211
|
+
"id": "string", "path": "string", "format": "string",
|
|
212
|
+
"roles": "string[]", "size_bytes": "integer", "sha256": "string",
|
|
213
|
+
"source": "string", "text_truncated": "boolean", "extraction": "string",
|
|
214
|
+
}),
|
|
215
|
+
NodeLabel("Package", "Package", "id", {
|
|
216
|
+
"id": "string", "ecosystem": "string", "name": "string",
|
|
217
|
+
}),
|
|
218
|
+
# A configuration key flattened out of a config-bearing Artifact (#152).
|
|
219
|
+
# Neutral vocabulary like Artifact/Package -- a yaml/env/ini key is not a
|
|
220
|
+
# Python concept. `value` is omitted (not null) when the source model's
|
|
221
|
+
# value is None (--no-artifact-text, or a namespace with no value at that
|
|
222
|
+
# path); `references` is always present, possibly empty.
|
|
223
|
+
NodeLabel("ConfigKey", "ConfigKey", "id", {
|
|
224
|
+
"id": "string", "key": "string", "namespace": "string",
|
|
225
|
+
"value": "string", "references": "string[]",
|
|
226
|
+
**_SPAN,
|
|
227
|
+
}),
|
|
211
228
|
]
|
|
212
229
|
|
|
213
230
|
_DECL_TARGETS = ["PyClass", "PyCallable"]
|
|
@@ -219,8 +236,7 @@ REL_TYPES: List[RelType] = [
|
|
|
219
236
|
RelType("PY_HAS_METHOD", ["PyClass"], ["PyCallable"]),
|
|
220
237
|
RelType("PY_HAS_ATTRIBUTE", ["PyClass"], ["PyAttribute"]),
|
|
221
238
|
RelType("PY_DECLARES_VAR", ["PyModule", "PyCallable"], ["PyVariable"]),
|
|
222
|
-
RelType("
|
|
223
|
-
RelType("PY_RESOLVES_TO", ["PyCallSite"], ["PyCallable", "PyExternal"]),
|
|
239
|
+
RelType("PY_RESOLVES_TO", ["PyBodyNode"], ["PyCallable", "PyExternal"]),
|
|
224
240
|
RelType(
|
|
225
241
|
"PY_CALLS",
|
|
226
242
|
["PyCallable", "PyExternal"],
|
|
@@ -234,21 +250,63 @@ REL_TYPES: List[RelType] = [
|
|
|
234
250
|
["PyModule", "PyPackage"],
|
|
235
251
|
{"spellings": "string[]", "imported_names": "string[]", "aliases": "string[]"},
|
|
236
252
|
),
|
|
237
|
-
RelType(
|
|
253
|
+
RelType(
|
|
254
|
+
"PY_DECORATED_BY",
|
|
255
|
+
["PyCallable", "PyClass"],
|
|
256
|
+
["PyDecorator"],
|
|
257
|
+
{
|
|
258
|
+
"expression": "string",
|
|
259
|
+
"positional_arguments": "string[]",
|
|
260
|
+
"keyword_arguments_json": "string",
|
|
261
|
+
},
|
|
262
|
+
),
|
|
238
263
|
# Level-3 CPG overlay (-a 3 only): the cross-language dataflow vocabulary,
|
|
239
264
|
# PY_-namespaced so per-language SDK backends can scope their queries.
|
|
240
|
-
RelType("
|
|
265
|
+
RelType("PY_HAS_BODY_NODE", ["PyCallable"], ["PyBodyNode"]),
|
|
241
266
|
# ``_k`` is the relationship-identity discriminant (internal, underscore-
|
|
242
267
|
# prefixed like ``_module``): PY_CFG_NEXT merges per ``kind`` (a conditional's
|
|
243
268
|
# true/false pair), PY_DDG per ``(var, prov)`` (one dependence per variable,
|
|
244
269
|
# and the ssa/points-to split) — a plain endpoint-pair MERGE would collapse
|
|
245
270
|
# legitimately-distinct edges.
|
|
246
|
-
RelType("PY_CFG_NEXT", ["
|
|
247
|
-
RelType("PY_CDG", ["
|
|
248
|
-
RelType("PY_DDG", ["
|
|
249
|
-
RelType("PY_PARAM_IN", ["
|
|
250
|
-
RelType("PY_PARAM_OUT", ["
|
|
251
|
-
RelType("PY_SUMMARY", ["
|
|
271
|
+
RelType("PY_CFG_NEXT", ["PyBodyNode"], ["PyBodyNode"], {"kind": "string", "_k": "string"}),
|
|
272
|
+
RelType("PY_CDG", ["PyBodyNode"], ["PyBodyNode"]),
|
|
273
|
+
RelType("PY_DDG", ["PyBodyNode"], ["PyBodyNode"], {"var": "string", "prov": "string[]", "_k": "string"}),
|
|
274
|
+
RelType("PY_PARAM_IN", ["PyBodyNode"], ["PyBodyNode"], {"var": "string"}),
|
|
275
|
+
RelType("PY_PARAM_OUT", ["PyBodyNode"], ["PyBodyNode"], {"var": "string"}),
|
|
276
|
+
RelType("PY_SUMMARY", ["PyBodyNode"], ["PyBodyNode"]),
|
|
277
|
+
# Neutral artifact/dependency subgraph (Task 6).
|
|
278
|
+
RelType("HAS_ARTIFACT", ["PyApplication"], ["Artifact"]),
|
|
279
|
+
# A config key nests under exactly one owning artifact (its id is
|
|
280
|
+
# `<artifact-id>@key/<dotted.key>`) -- a plain containment edge, no
|
|
281
|
+
# per-edge properties or discriminant needed (#152).
|
|
282
|
+
RelType("DEFINES_CONFIG", ["Artifact"], ["ConfigKey"]),
|
|
283
|
+
# ``_k`` (merges per ``kind``): the same manifest may declare one package
|
|
284
|
+
# twice under different kinds (e.g. a runtime dep re-listed under an
|
|
285
|
+
# optional extra) -- same endpoint pair, so without the discriminant the
|
|
286
|
+
# plain MERGE collapses the two declarations into one row.
|
|
287
|
+
RelType("DECLARES_DEPENDENCY", ["Artifact"], ["Package"], {
|
|
288
|
+
"spec": "string", "kind": "string", "extras": "string[]", "prov": "string[]",
|
|
289
|
+
"direct": "boolean", "_k": "string",
|
|
290
|
+
}),
|
|
291
|
+
RelType("LOCKS", ["Artifact"], ["Package"], {"version": "string"}),
|
|
292
|
+
RelType("PY_PROVIDES", ["Package"], ["PyExternal"]),
|
|
293
|
+
RelType("PY_UNRESOLVED_IMPORT", ["PyApplication"], ["PyExternal"], {"prov": "string[]"}),
|
|
294
|
+
# config_use (#162): the resolved-read bridge from a call site's body node
|
|
295
|
+
# to the PyConfigKey it reads. `src`/`dst` are already GLOBAL ordinal /
|
|
296
|
+
# ConfigKey ids (resolved upstream by `resolve_uses`), so no discriminant
|
|
297
|
+
# is needed -- one call site reads one key per edge.
|
|
298
|
+
RelType("PY_USES_CONFIG", ["PyBodyNode"], ["ConfigKey"], {"prov": "string[]"}),
|
|
299
|
+
# A detector-matched read that never closed on exactly one declared key --
|
|
300
|
+
# first-class per #162, PyApplication -> PyExternal ghost of the callee
|
|
301
|
+
# (mirrors PY_UNRESOLVED_IMPORT's shape). `_k` discriminates by (key,
|
|
302
|
+
# reason): the same external callee (e.g. `os.getenv`) legitimately reads
|
|
303
|
+
# several distinct undeclared/dynamic keys across a codebase -- without a
|
|
304
|
+
# discriminant a plain endpoint-pair MERGE would collapse those onto one
|
|
305
|
+
# relationship and silently drop every key but the last one SET.
|
|
306
|
+
RelType(
|
|
307
|
+
"PY_READS_CONFIG_UNRESOLVED", ["PyApplication"], ["PyExternal"],
|
|
308
|
+
{"key": "string", "reason": "string", "prov": "string[]", "_k": "string"},
|
|
309
|
+
),
|
|
252
310
|
]
|
|
253
311
|
|
|
254
312
|
|