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
@@ -22,85 +22,8 @@ for static analysis purposes.
22
22
  from __future__ import annotations
23
23
  from pathlib import Path
24
24
  from typing import Any, Dict, List, Optional, Tuple
25
- import gzip
26
-
27
25
  from pydantic import BaseModel
28
26
  from typing_extensions import Literal
29
- import msgpack
30
-
31
-
32
- def msgpk(cls):
33
- """
34
- Decorator that adds MessagePack serialization methods to Pydantic models.
35
-
36
- Adds methods:
37
- - to_msgpack_bytes() -> bytes: Serialize to compact binary format
38
- - from_msgpack_bytes(data: bytes) -> cls: Deserialize from binary format
39
- - to_msgpack_dict() -> dict: Convert to msgpack-compatible dict
40
- - from_msgpack_dict(data: dict) -> cls: Create instance from msgpack dict
41
- """
42
-
43
- def _prepare_for_serialization(obj: Any) -> Any:
44
- """Convert objects to serialization-friendly format."""
45
- if isinstance(obj, Path):
46
- return str(obj)
47
- elif isinstance(obj, dict):
48
- return {
49
- _prepare_for_serialization(k): _prepare_for_serialization(v)
50
- for k, v in obj.items()
51
- }
52
- elif isinstance(obj, list):
53
- return [_prepare_for_serialization(item) for item in obj]
54
- elif isinstance(obj, tuple):
55
- return tuple(_prepare_for_serialization(item) for item in obj)
56
- elif isinstance(obj, set):
57
- return [_prepare_for_serialization(item) for item in obj]
58
- elif hasattr(obj, "model_dump"): # Pydantic model
59
- return _prepare_for_serialization(obj.model_dump())
60
- else:
61
- return obj
62
-
63
- def to_msgpack_bytes(self) -> bytes:
64
- """Serialize the model to compact binary format using MessagePack + gzip."""
65
- data = _prepare_for_serialization(self.model_dump())
66
- msgpack_data = msgpack.packb(data, use_bin_type=True)
67
- return gzip.compress(msgpack_data)
68
-
69
- @classmethod
70
- def from_msgpack_bytes(cls_obj, data: bytes):
71
- """Deserialize from MessagePack + gzip binary format."""
72
- decompressed_data = gzip.decompress(data)
73
- obj_dict = msgpack.unpackb(decompressed_data, raw=False)
74
- return cls_obj.model_validate(obj_dict)
75
-
76
- def to_msgpack_dict(self) -> dict:
77
- """Convert to msgpack-compatible dictionary format."""
78
- return _prepare_for_serialization(self.model_dump())
79
-
80
- @classmethod
81
- def from_msgpack_dict(cls_obj, data: dict):
82
- """Create instance from msgpack-compatible dictionary."""
83
- return cls_obj.model_validate(data)
84
-
85
- def get_msgpack_size(self) -> int:
86
- """Get the size of the msgpack serialization in bytes."""
87
- return len(self.to_msgpack_bytes())
88
-
89
- def get_compression_ratio(self) -> float:
90
- """Get compression ratio compared to JSON."""
91
- json_size = len(self.model_dump_json().encode("utf-8"))
92
- msgpack_gzip_size = self.get_msgpack_size()
93
- return msgpack_gzip_size / json_size if json_size > 0 else 1.0
94
-
95
- # Add methods to the class
96
- cls.to_msgpack_bytes = to_msgpack_bytes
97
- cls.from_msgpack_bytes = from_msgpack_bytes
98
- cls.to_msgpack_dict = to_msgpack_dict
99
- cls.from_msgpack_dict = from_msgpack_dict
100
- cls.get_msgpack_size = get_msgpack_size
101
- cls.get_compression_ratio = get_compression_ratio
102
-
103
- return cls
104
27
 
105
28
 
106
29
  def builder(cls):
@@ -192,7 +115,6 @@ def byte_offsets(source: str, start_line: int, start_col: int,
192
115
 
193
116
 
194
117
  @builder
195
- @msgpk
196
118
  class Span(BaseModel):
197
119
  """Where a node lives in source. `start`/`end` are [line, col] (1-based line,
198
120
  0-based col, ast semantics); `bytes` are utf-8 offsets into module.source."""
@@ -202,7 +124,6 @@ class Span(BaseModel):
202
124
 
203
125
 
204
126
  @builder
205
- @msgpk
206
127
  class BodyNode(BaseModel):
207
128
  """A node in a callable's `body`: an AST region (statement/call/branch/…) or
208
129
  a synthetic analysis vertex (entry/exit/formal_in/out/actual_in/out)."""
@@ -211,40 +132,46 @@ class BodyNode(BaseModel):
211
132
  callee: Optional[str] = None # only on `call` nodes; the sanctioned null→id slot
212
133
  of: Optional[str] = None # param vertices: the variable/return they carry
213
134
  parent: Optional[str] = None # actuals: owning callsite ordinal id
135
+ # Call-site detail (#120). Previously reachable only through the parallel
136
+ # `PyCallable.call_sites` list, which emitted the same fact a second time under
137
+ # an unrelated id scheme. `method_name` and `is_constructor_call` are carried
138
+ # rather than derived from `callee`: measured across `requests` and `flask`,
139
+ # 20-28% of call sites never resolve a callee, so deriving them would lose them
140
+ # on one call in four.
141
+ method_name: Optional[str] = None
142
+ receiver_expr: Optional[str] = None
143
+ receiver_type: Optional[str] = None
144
+ return_type: Optional[str] = None
145
+ is_constructor_call: Optional[bool] = None
146
+ arguments: List["PyCallArgument"] = []
214
147
 
215
148
 
216
149
  @builder
217
- @msgpk
218
150
  class CfgEdge(BaseModel):
219
151
  src: str; dst: str; kind: str = "fallthrough"
220
152
 
221
153
 
222
154
  @builder
223
- @msgpk
224
155
  class CdgEdge(BaseModel):
225
156
  src: str; dst: str
226
157
 
227
158
 
228
159
  @builder
229
- @msgpk
230
160
  class DdgEdge(BaseModel):
231
161
  src: str; dst: str; var: Optional[str] = None; prov: List[str] = []
232
162
 
233
163
 
234
164
  @builder
235
- @msgpk
236
165
  class SummaryEdge(BaseModel):
237
166
  src: str; dst: str
238
167
 
239
168
 
240
169
  @builder
241
- @msgpk
242
170
  class ParamEdge(BaseModel):
243
171
  src: str; dst: str
244
172
 
245
173
 
246
174
  @builder
247
- @msgpk
248
175
  class PyImport(BaseModel):
249
176
  """Represents a Python import statement."""
250
177
 
@@ -259,7 +186,6 @@ class PyImport(BaseModel):
259
186
 
260
187
 
261
188
  @builder
262
- @msgpk
263
189
  class PyComment(BaseModel):
264
190
  """Represents a Python comment."""
265
191
 
@@ -272,7 +198,6 @@ class PyComment(BaseModel):
272
198
 
273
199
 
274
200
  @builder
275
- @msgpk
276
201
  class PySymbol(BaseModel):
277
202
  """Represents a symbol used or declared in Python code."""
278
203
 
@@ -287,7 +212,6 @@ class PySymbol(BaseModel):
287
212
 
288
213
 
289
214
  @builder
290
- @msgpk
291
215
  class PyVariableDeclaration(BaseModel):
292
216
  """Represents a Python variable declaration."""
293
217
 
@@ -306,13 +230,66 @@ class PyVariableDeclaration(BaseModel):
306
230
 
307
231
 
308
232
  @builder
309
- @msgpk
233
+ class PyDecorator(BaseModel):
234
+ """One decorator application, structured rather than a source string (#128).
235
+
236
+ ``name`` is the spelling as written (``lru_cache``, ``builtins.staticmethod``);
237
+ ``qualified_name`` is Jedi's resolution of it (``functools.lru_cache``) and is
238
+ absent when it cannot be resolved. ``expression`` keeps the full unparsed source
239
+ so nothing is lost for decorators too complex to decompose.
240
+ """
241
+
242
+ name: str
243
+ qualified_name: Optional[str] = None
244
+ positional_arguments: List[str] = []
245
+ keyword_arguments: Dict[str, str] = {}
246
+ expression: str = ""
247
+ span: Optional[Span] = None
248
+
249
+
250
+ @builder
251
+ class PyEntrypoint(BaseModel):
252
+ """One way a callable or class is invoked from outside the application (#27).
253
+
254
+ A node may hold several: two ``@app.route`` decorators, or a function that
255
+ is both a Celery task and a CLI command. ``confidence`` lets a consumer
256
+ threshold on evidence quality rather than inheriting this analyzer's
257
+ judgement.
258
+ """
259
+
260
+ framework: str
261
+ confidence: str = "certain" # "declared" | "certain" | "heuristic"
262
+ rule: str = "" # rules.yml `id:`, or an engine name
263
+ ruleset: str = "shipped" # "shipped" | "user:<path>"
264
+ evidence: Optional[str] = None
265
+ route: Optional[str] = None
266
+ http_methods: List[str] = []
267
+ via: Optional[str] = None # can:// id of the routed node dispatching here
268
+
269
+
270
+ @builder
271
+ class PyEntrypointReport(BaseModel):
272
+ """Coverage and failure record for the entrypoint pass (#27).
273
+
274
+ The pass under-approximates by design, so silence is its failure mode.
275
+ This is what makes a gap visible instead of indistinguishable from
276
+ "this project has no entrypoints".
277
+ """
278
+
279
+ frameworks_detected: List[str] = []
280
+ rulesets: List[str] = []
281
+ unresolved: Dict[str, int] = {}
282
+ errors: List[str] = []
283
+
284
+
285
+ @builder
310
286
  class PyCallableParameter(BaseModel):
311
287
  """Represents a parameter of a Python callable (function/method)."""
312
288
 
313
289
  name: str
314
290
  type: Optional[str] = None
315
291
  default_value: Optional[str] = None
292
+ decorators: List[PyDecorator] = []
316
293
  start_line: int = -1
317
294
  end_line: int = -1
318
295
  start_column: int = -1
@@ -320,7 +297,6 @@ class PyCallableParameter(BaseModel):
320
297
 
321
298
 
322
299
  @builder
323
- @msgpk
324
300
  class PyCallArgument(BaseModel):
325
301
  """One call-site argument: AST category + inferred type, kept separate.
326
302
 
@@ -330,10 +306,27 @@ class PyCallArgument(BaseModel):
330
306
 
331
307
  ast_kind: str
332
308
  inferred_type: Optional[str] = None
309
+ # Literal capture (#162): populated only for an `ast.Constant` argument
310
+ # whose value is str/int/float/bool/None, JSON-encoded (`json.dumps`) --
311
+ # decode with `json.loads` to recover the Python constant. `None` for
312
+ # every non-constant argument (and for a Constant of another type, e.g.
313
+ # bytes/complex/Ellipsis).
314
+ value: Optional[str] = None
315
+ # Bare-identifier capture (#162): the `id` of an `ast.Name` argument
316
+ # (e.g. `f(KEY)` -> `"KEY"`) -- ships alongside `value` as the dataflow
317
+ # tier's join point back to the variable's own definitions. `None` for
318
+ # every other argument shape.
319
+ name: Optional[str] = None
320
+
321
+
322
+ # BodyNode.arguments forward-references PyCallArgument (defined later);
323
+ # pydantic v1 resolves string annotations only when told to, while v2
324
+ # rebuilds automatically (and its update_forward_refs shim rejects localns).
325
+ if not hasattr(BodyNode, "model_rebuild"): # pydantic v1
326
+ BodyNode.update_forward_refs(PyCallArgument=PyCallArgument)
333
327
 
334
328
 
335
329
  @builder
336
- @msgpk
337
330
  class PyCallsite(BaseModel):
338
331
  """Represents a Python call site (function or method invocation) with contextual metadata."""
339
332
 
@@ -352,7 +345,6 @@ class PyCallsite(BaseModel):
352
345
 
353
346
 
354
347
  @builder
355
- @msgpk
356
348
  class PyCallable(BaseModel):
357
349
  """Represents a Python callable (function/method)."""
358
350
 
@@ -363,13 +355,27 @@ class PyCallable(BaseModel):
363
355
  kind: str = "function"
364
356
  span: Optional[Span] = None
365
357
  comments: List[PyComment] = []
366
- decorators: List[str] = []
358
+ decorators: List[PyDecorator] = []
359
+ # Language-level modifiers on the declaration itself (#130). `async` is the
360
+ # only one Python has today. It lives here rather than in `kind` because it
361
+ # is orthogonal to every kind -- an async method is both -- so encoding it
362
+ # in the discriminant would need async_function, async_method,
363
+ # async_generator and so on, combinatorially.
364
+ modifiers: List[str] = []
365
+ entrypoints: List[PyEntrypoint] = []
366
+ is_entrypoint: bool = False
367
367
  parameters: List[PyCallableParameter] = []
368
368
  return_type: Optional[str] = None
369
369
  start_line: int = -1
370
370
  end_line: int = -1
371
371
  code_start_line: int = -1
372
372
  accessed_symbols: List[PySymbol] = []
373
+ # Internal (#120): the Jedi-produced record that `l1_body` derives `body{}`
374
+ # call nodes from, and that `call_graph.py`, `l2_callees.py` and the dataflow
375
+ # builder all read. It is stripped at EMIT time (see `wire_json`), not with a
376
+ # field-level `exclude`: the analysis cache round-trips through the same
377
+ # serializer, so excluding it would drop it from the cache too and a warm-cache
378
+ # run would rebuild with no call sites at all.
373
379
  call_sites: List[PyCallsite] = []
374
380
  callables: Dict[str, "PyCallable"] = {} # nested callables (closures)
375
381
  types: Dict[str, "PyClass"] = {} # nested (local) classes
@@ -389,7 +395,6 @@ class PyCallable(BaseModel):
389
395
 
390
396
 
391
397
  @builder
392
- @msgpk
393
398
  class PyClassAttribute(BaseModel):
394
399
  """Represents a Python class attribute."""
395
400
 
@@ -397,12 +402,12 @@ class PyClassAttribute(BaseModel):
397
402
  type: Optional[str] = None
398
403
  initializer: Optional[str] = None
399
404
  comments: List[PyComment] = []
405
+ decorators: List[PyDecorator] = []
400
406
  start_line: int = -1
401
407
  end_line: int = -1
402
408
 
403
409
 
404
410
  @builder
405
- @msgpk
406
411
  class PyClass(BaseModel):
407
412
  """Represents a Python class."""
408
413
 
@@ -413,6 +418,9 @@ class PyClass(BaseModel):
413
418
  span: Optional[Span] = None
414
419
  comments: List[PyComment] = []
415
420
  base_classes: List[str] = []
421
+ decorators: List[PyDecorator] = []
422
+ entrypoints: List[PyEntrypoint] = []
423
+ is_entrypoint: bool = False
416
424
  callables: Dict[str, PyCallable] = {} # methods, keystone containment name
417
425
  attributes: Dict[str, PyClassAttribute] = {}
418
426
  types: Dict[str, "PyClass"] = {} # inner classes, keystone containment name
@@ -425,7 +433,6 @@ class PyClass(BaseModel):
425
433
 
426
434
 
427
435
  @builder
428
- @msgpk
429
436
  class PyModule(BaseModel):
430
437
  """Represents a Python module."""
431
438
 
@@ -446,7 +453,6 @@ class PyModule(BaseModel):
446
453
 
447
454
 
448
455
  @builder
449
- @msgpk
450
456
  class PyCallEdge(BaseModel):
451
457
  """Identity-only call-graph edge with weight (keystone shape: the list name
452
458
  IS the edge type, so there is no ``type`` field).
@@ -460,11 +466,10 @@ class PyCallEdge(BaseModel):
460
466
  src: str # caller callable id
461
467
  dst: str # callee callable (or external) id
462
468
  weight: int = 1
463
- prov: List[Literal["jedi", "pycg", "joern"]] = []
469
+ prov: List[Literal["jedi", "defuse"]] = []
464
470
 
465
471
 
466
472
  @builder
467
- @msgpk
468
473
  class PyExternalSymbol(BaseModel):
469
474
  """A call-graph target outside the analyzed project -- an imported library or
470
475
  builtin member. An edge-endpoint id home, not a tree node: keyed in
@@ -477,7 +482,104 @@ class PyExternalSymbol(BaseModel):
477
482
 
478
483
 
479
484
  @builder
480
- @msgpk
485
+ class PyConfigKey(BaseModel):
486
+ """A configuration key flattened out of a config-bearing ``PyArtifact``
487
+ (#152). Graph vocabulary stays neutral (label ``ConfigKey``, edge
488
+ ``DEFINES_CONFIG``) -- the ``Py`` prefix here is only the ``PyArtifact``
489
+ naming precedent, not a Python-specific claim. L1 data, identical at
490
+ every analysis level; nested under the owning artifact, containment
491
+ mirrors ``DEFINES_CONFIG``."""
492
+
493
+ id: str = "" # <artifact-id>@key/<dotted.key>
494
+ key: str # dotted path; numeric segments for arrays, e.g. "services.web.ports.0"
495
+ namespace: str # env|yaml|json|toml|ini|properties|dockerfile
496
+ value: Optional[str] = None # populated only when options.artifact_text is on
497
+ span: Optional[Span] = None # into the artifact's source; best-effort for yaml/json/toml
498
+ references: List[str] = [] # raw recognized tokens, order of appearance, deduplicated
499
+
500
+
501
+ @builder
502
+ class PyArtifact(BaseModel):
503
+ """Any non-`.py` project file (config, manifest, CI, container spec, or
504
+ plain data/binary) -- never dropped from the walk. Captured broadly (node
505
+ + verbatim ``source``); *meaning* is extracted narrowly -- only
506
+ ``dependency-manifest`` roles feed ``dependencies`` today. ``id`` is
507
+ language-neutral (``can://artifact/<app>/<path>``)."""
508
+
509
+ id: str = ""
510
+ kind: str = "artifact"
511
+ path: str # repo-relative POSIX path (also the map key)
512
+ format: str # toml|yaml|json|ini|properties|requirements|dockerfile|text|binary
513
+ roles: List[str] = []
514
+ size_bytes: int = 0
515
+ sha256: str = "" # always the full file's hash, even when source is truncated/empty
516
+ source: str = "" # verbatim by default; "" for binary or when capture is disabled
517
+ text_truncated: bool = False # True when `source` is a prefix, not the full file
518
+ extraction: str = "none" # none|partial|full
519
+ config_keys: List[PyConfigKey] = [] # flattened config keys (#152); [] when not namespace-eligible
520
+
521
+
522
+ @builder
523
+ class PyConfigUseEdge(BaseModel):
524
+ """One resolved config read (#162): a detector-matched call's key
525
+ argument closed on exactly one string literal that matches a declared
526
+ ``PyConfigKey``. ``src`` is the call's GLOBAL ordinal id
527
+ (``<callable-id>@<local-id>``); ``dst`` is the matched ``PyConfigKey.id``
528
+ -- application scope, mirroring ``param_in`` (endpoints span callables/
529
+ artifacts). Superset-monotonic across levels, same additive contract as
530
+ the DDG's ``prov`` widening: literal (``-a 2``+) subset of +dataflow
531
+ (``-a 3``/``-a 4``)."""
532
+
533
+ src: str
534
+ dst: str
535
+ prov: List[Literal["literal", "dataflow"]] = []
536
+
537
+
538
+ @builder
539
+ class PyConfigRead(BaseModel):
540
+ """A detector-matched call whose key did not close on exactly one string
541
+ literal -- first-class so a config read nobody can trace is as visible
542
+ as one that resolves (#162). ``key`` is the decoded literal text only
543
+ when it IS a literal but matches no declared ``PyConfigKey``
544
+ (``reason="undefined-key"``); ``None`` for a key that never closed on a
545
+ literal at all (``reason="non-literal"``). ``prov`` lists every tier
546
+ that was attempted before giving up."""
547
+
548
+ site: str # GLOBAL ordinal id
549
+ callee: str # external id (can://.../@external/<module>/<name>)
550
+ key: Optional[str] = None
551
+ reason: Literal["non-literal", "undefined-key"]
552
+ prov: List[Literal["literal", "dataflow"]] = []
553
+
554
+
555
+ @builder
556
+ class PyDependency(BaseModel):
557
+ """One declared third-party dependency, evidence-tagged via ``prov``."""
558
+
559
+ name: str # PEP 503 normalized
560
+ ecosystem: str = "pypi" # SDK symmetry with purl (#152 rider); the only ecosystem this analyzer emits
561
+ spec: str = ""
562
+ kind: str = "runtime" # runtime|dev|optional|build
563
+ extras: List[str] = []
564
+ declared_in: str = "" # PyArtifact id
565
+ # False for lockfile-only (transitive) dependencies -- pinned in a lock
566
+ # with no manifest declaration (#152 reconciliation).
567
+ direct: bool = True
568
+ locked_version: Optional[str] = None
569
+ provides_imports: List[str] = []
570
+ prov: List[str] = [] # declared|lockfile|installed-metadata|heuristic
571
+
572
+
573
+ @builder
574
+ class PyImportBinding(BaseModel):
575
+ """A top-level import no declared dependency accounts for."""
576
+
577
+ module: str
578
+ bound_to: Optional[str] = None # best-effort distribution name
579
+ prov: List[str] = []
580
+
581
+
582
+ @builder
481
583
  class PyRepositoryInfo(BaseModel):
482
584
  """Where the analyzed source came from: git provenance captured at analysis time."""
483
585
 
@@ -487,7 +589,6 @@ class PyRepositoryInfo(BaseModel):
487
589
 
488
590
 
489
591
  @builder
490
- @msgpk
491
592
  class PyAnalyzerInfo(BaseModel):
492
593
  """Which analyzer produced this snapshot, and how it was configured.
493
594
  Lives on the ``Analysis`` envelope (keystone ``analyzer{name,version}``;
@@ -499,7 +600,6 @@ class PyAnalyzerInfo(BaseModel):
499
600
 
500
601
 
501
602
  @builder
502
- @msgpk
503
603
  class PyApplication(BaseModel):
504
604
  """Represents a Python application."""
505
605
 
@@ -511,15 +611,25 @@ class PyApplication(BaseModel):
511
611
  # builtin members), keyed by signature. Populated by the analyzer so every
512
612
  # backend (JSON and Neo4j) shares one authoritative external-symbol set.
513
613
  external_symbols: Dict[str, PyExternalSymbol] = {}
614
+ # Non-code artifacts, declared dependencies, and undeclared imports
615
+ # (spec 2026-08-27). L1 data: identical at every analysis level.
616
+ artifacts: Dict[str, PyArtifact] = {}
617
+ dependencies: List[PyDependency] = []
618
+ unresolved_imports: List[PyImportBinding] = []
619
+ # Coverage/failure record for the entrypoint pass; see PyEntrypointReport (#27).
620
+ entrypoint_report: PyEntrypointReport = PyEntrypointReport()
514
621
  # Git provenance of the analyzed checkout, captured at analysis time.
515
622
  repository: Optional[PyRepositoryInfo] = None
516
623
  # Interprocedural parameter-passing edges (formal↔actual); populated at L4.
517
624
  param_in: List[ParamEdge] = []
518
625
  param_out: List[ParamEdge] = []
626
+ # config_use (#162): PY_USES_CONFIG edges + first-class unresolved reads.
627
+ # Literal tier from L2; dataflow tiers widen the set at L3/L4 (additive).
628
+ config_uses: List[PyConfigUseEdge] = []
629
+ config_reads_unresolved: List[PyConfigRead] = []
519
630
 
520
631
 
521
632
  @builder
522
- @msgpk
523
633
  class Analysis(BaseModel):
524
634
  """v2 payload root: envelope + the application tree node. ``k_limit`` is an
525
635
  L3+ envelope key (None below the dataflow levels; exclude_none drops it)."""
@@ -28,6 +28,8 @@ from typing import Dict, Iterator, List, Tuple
28
28
 
29
29
  import networkx as nx
30
30
 
31
+ from codeanalyzer.semantic_analysis.defuse_linker import _module_qual
32
+ from codeanalyzer.schema import model_copy
31
33
  from codeanalyzer.schema.py_schema import (
32
34
  PyApplication,
33
35
  PyCallable,
@@ -170,7 +172,7 @@ def jedi_call_graph_edges(
170
172
 
171
173
  Edges are coalesced on ``(source, target)``: ``weight`` is the count of
172
174
  matching sites. Provenance is always ``["jedi"]``; combine with
173
- PyCG-derived edges via ``merge_edges``.
175
+ defuse-linker edges via ``merge_edges``.
174
176
  """
175
177
  counts: Counter = Counter()
176
178
  for caller in iter_callables_in_symbol_table(symbol_table):
@@ -253,10 +255,24 @@ def filter_external_edges(
253
255
  retained; only lib→lib edges are dropped. The app symbol set is built by
254
256
  walking every callable in the symbol table recursively (including nested
255
257
  functions and closures via ``callables``) plus every class, so
256
- PyCG-discovered closure nodes are correctly recognised as app symbols.
258
+ resolver-discovered closure nodes are correctly recognised as app symbols.
259
+
260
+ Module names count as app symbols too (#131). A resolver attributes a call in
261
+ module scope to the MODULE -- ``app -> functools.reduce`` for a module-level
262
+ ``functools.reduce(...)``, or for a decorator applied to a top-level
263
+ definition, since a decorator executes in its enclosing scope. Without the
264
+ module names here, both endpoints looked third-party and every module-scope
265
+ call to a library target was discarded as lib→lib.
257
266
  """
258
267
  app_symbols: set = {c.signature for c in iter_callables_in_symbol_table(symbol_table)}
259
268
  app_symbols.update(cls.signature for cls in iter_classes_in_symbol_table(symbol_table))
269
+ app_symbols.update(
270
+ mod.module_name for mod in symbol_table.values() if mod.module_name
271
+ )
272
+ # Dotted module quals too: the defuse linker attributes module- and
273
+ # class-scope calls to "pkg.module" (collision-free across packages),
274
+ # which the bare `module_name` stems above never match.
275
+ app_symbols.update(_module_qual(key) for key in symbol_table)
260
276
 
261
277
  return [
262
278
  e for e in edges
@@ -269,7 +285,7 @@ def merge_edges(*edge_lists: list) -> list:
269
285
 
270
286
  Edges with the same ``(source, target)`` are coalesced: weights sum,
271
287
  provenance is the sorted union. Useful for combining edges produced
272
- by different backends (e.g. Jedi + PyCG).
288
+ by different backends (e.g. Jedi + the defuse linker).
273
289
  """
274
290
  by_key: Dict[Tuple[str, str], PyCallEdge] = {}
275
291
  for edges in edge_lists:
@@ -280,5 +296,5 @@ def merge_edges(*edge_lists: list) -> list:
280
296
  cur.weight += e.weight
281
297
  cur.prov = sorted(set(cur.prov) | set(e.prov))
282
298
  else:
283
- by_key[k] = e.model_copy()
299
+ by_key[k] = model_copy(e)
284
300
  return list(by_key.values())