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
@@ -47,7 +47,8 @@ from codeanalyzer.schema import (
47
47
  PyModule,
48
48
  PyVariableDeclaration,
49
49
  )
50
- from codeanalyzer.schema.py_schema import PyCallsite
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 PyCFGNode merge key for a callable's body node: the
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 _cfg_ref(callable_id: str, local_key: str) -> NodeRef:
128
- return NodeRef("PyCFGNode", "id", _global_ordinal(callable_id, local_key))
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(b: RowBuilder, app: PyApplication) -> None:
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 ``PyCFGNode`` (merge key ``id`` = the GLOBAL ordinal
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
- ``PY_HAS_CFG_NODE`` from the owning callable, ``PY_CFG_NEXT`` (prop ``kind``)
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 ``PyCFGNode`` keys). All idempotent under MERGE — no-ops below L4."""
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
- ["PyCFGNode"],
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("PY_HAS_CFG_NODE", owner, ref)
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
- _cfg_ref(c.id, e.src),
187
- _cfg_ref(c.id, e.dst),
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", _cfg_ref(c.id, e.src), _cfg_ref(c.id, e.dst))
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
- _cfg_ref(c.id, e.src),
201
- _cfg_ref(c.id, e.dst),
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 PyCFGNode refs.
239
+ # pass-throughs); LOCAL ids resolved to global PyBodyNode refs.
207
240
  for e in c.summary or []:
208
- b.edge("PY_SUMMARY", _cfg_ref(c.id, e.src), _cfg_ref(c.id, e.dst))
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 PyCFGNode ids
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("PyCFGNode", "id", e.src),
219
- NodeRef("PyCFGNode", "id", e.dst),
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("PyCFGNode", "id", e.src),
225
- NodeRef("PyCFGNode", "id", e.dst),
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: str) -> None:
443
- dec = b.node(["PyDecorator"], "name", decorator, {"name": decorator})
444
- b.edge("PY_DECORATED_BY", on, dec)
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": list(c.decorators or []),
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:
@@ -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
- "PyCFGNode",
200
- "PyCFGNode",
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("PY_HAS_CALLSITE", ["PyCallable"], ["PyCallSite"]),
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("PY_DECORATED_BY", ["PyCallable"], ["PyDecorator"]),
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("PY_HAS_CFG_NODE", ["PyCallable"], ["PyCFGNode"]),
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", ["PyCFGNode"], ["PyCFGNode"], {"kind": "string", "_k": "string"}),
247
- RelType("PY_CDG", ["PyCFGNode"], ["PyCFGNode"]),
248
- RelType("PY_DDG", ["PyCFGNode"], ["PyCFGNode"], {"var": "string", "prov": "string[]", "_k": "string"}),
249
- RelType("PY_PARAM_IN", ["PyCFGNode"], ["PyCFGNode"], {"var": "string"}),
250
- RelType("PY_PARAM_OUT", ["PyCFGNode"], ["PyCFGNode"], {"var": "string"}),
251
- RelType("PY_SUMMARY", ["PyCFGNode"], ["PyCFGNode"]),
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