arcgraph 0.1.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 (165) hide show
  1. arcgraph/__init__.py +41 -0
  2. arcgraph/_build_provenance.json +10 -0
  3. arcgraph/adapters/__init__.py +6 -0
  4. arcgraph/adapters/arq_adapter.py +369 -0
  5. arcgraph/adapters/celery_adapter.py +440 -0
  6. arcgraph/adapters/common.py +436 -0
  7. arcgraph/adapters/django_adapter.py +765 -0
  8. arcgraph/adapters/fastapi_adapter.py +415 -0
  9. arcgraph/adapters/logging_adapter.py +335 -0
  10. arcgraph/adapters/mcp_adapter.py +100 -0
  11. arcgraph/adapters/pydantic_adapter.py +290 -0
  12. arcgraph/adapters/pytest_adapter.py +221 -0
  13. arcgraph/adapters/pytest_collection.py +96 -0
  14. arcgraph/adapters/pytest_fixtures.py +371 -0
  15. arcgraph/adapters/pytest_types.py +251 -0
  16. arcgraph/adapters/registry.py +34 -0
  17. arcgraph/adapters/service_container_adapter.py +548 -0
  18. arcgraph/adapters/sqlalchemy_adapter.py +446 -0
  19. arcgraph/adapters/typer_click_adapter.py +292 -0
  20. arcgraph/analyzers/__init__.py +1 -0
  21. arcgraph/analyzers/bindings.py +782 -0
  22. arcgraph/analyzers/calls/__init__.py +6 -0
  23. arcgraph/analyzers/calls/analyzer.py +2921 -0
  24. arcgraph/analyzers/calls/constants.py +702 -0
  25. arcgraph/analyzers/calls/context.py +298 -0
  26. arcgraph/analyzers/calls/lexical.py +237 -0
  27. arcgraph/analyzers/coverage.py +826 -0
  28. arcgraph/analyzers/exports.py +46 -0
  29. arcgraph/analyzers/har_trace.py +323 -0
  30. arcgraph/analyzers/imports.py +190 -0
  31. arcgraph/analyzers/otel_trace.py +352 -0
  32. arcgraph/analyzers/precise_references.py +1199 -0
  33. arcgraph/analyzers/python_scopes.py +56 -0
  34. arcgraph/analyzers/runtime_trace.py +516 -0
  35. arcgraph/analyzers/similarity.py +430 -0
  36. arcgraph/analyzers/symbols.py +363 -0
  37. arcgraph/analyzers/type_unions.py +89 -0
  38. arcgraph/analyzers/types.py +2293 -0
  39. arcgraph/assets/__init__.py +1 -0
  40. arcgraph/assets/workbench/__init__.py +1 -0
  41. arcgraph/assets/workbench/index.html +2398 -0
  42. arcgraph/assets/workbench/vendor/LICENSE.d3.txt +15 -0
  43. arcgraph/assets/workbench/vendor/d3.v7.min.js +2 -0
  44. arcgraph/change/__init__.py +10 -0
  45. arcgraph/change/baseline.py +145 -0
  46. arcgraph/change/changed_regions.py +728 -0
  47. arcgraph/change/contracts.py +927 -0
  48. arcgraph/change/errors.py +88 -0
  49. arcgraph/change/evidence.py +289 -0
  50. arcgraph/change/graph_delta.py +1422 -0
  51. arcgraph/change/identities.py +1277 -0
  52. arcgraph/change/paths.py +234 -0
  53. arcgraph/change/pins.py +426 -0
  54. arcgraph/change/planner.py +504 -0
  55. arcgraph/change/protected_surfaces.py +328 -0
  56. arcgraph/change/provider.py +46 -0
  57. arcgraph/change/service.py +1030 -0
  58. arcgraph/change/source_provider.py +311 -0
  59. arcgraph/change/store.py +1138 -0
  60. arcgraph/change/trusted_runners.py +154 -0
  61. arcgraph/change/verdicts.py +141 -0
  62. arcgraph/change/verification_plan.py +113 -0
  63. arcgraph/change/verifier.py +579 -0
  64. arcgraph/core/__init__.py +1 -0
  65. arcgraph/core/assurance.py +343 -0
  66. arcgraph/core/binding_metrics.py +75 -0
  67. arcgraph/core/cleanup.py +701 -0
  68. arcgraph/core/confidence_profiles.py +108 -0
  69. arcgraph/core/evidence_manifest.py +862 -0
  70. arcgraph/core/expression_kinds.py +11 -0
  71. arcgraph/core/force_graph_export.py +1125 -0
  72. arcgraph/core/freshness.py +94 -0
  73. arcgraph/core/graph_store.py +1099 -0
  74. arcgraph/core/ids.py +206 -0
  75. arcgraph/core/language_tiers.py +150 -0
  76. arcgraph/core/merge.py +649 -0
  77. arcgraph/core/metadata.py +30 -0
  78. arcgraph/core/operation_lock.py +218 -0
  79. arcgraph/core/passes.py +142 -0
  80. arcgraph/core/payload_policy.py +708 -0
  81. arcgraph/core/precise_references.py +124 -0
  82. arcgraph/core/query_engine.py +3078 -0
  83. arcgraph/core/recovery.py +18 -0
  84. arcgraph/core/scanner.py +1454 -0
  85. arcgraph/core/schemas.py +700 -0
  86. arcgraph/core/semantic.py +263 -0
  87. arcgraph/core/semantic_metrics.py +619 -0
  88. arcgraph/core/sharing_retry.py +84 -0
  89. arcgraph/core/structural.py +260 -0
  90. arcgraph/core/target_resolver.py +486 -0
  91. arcgraph/core/type_metrics.py +67 -0
  92. arcgraph/core/unresolved_classification.py +471 -0
  93. arcgraph/core/utils.py +100 -0
  94. arcgraph/core/visual_contract.py +240 -0
  95. arcgraph/core/visual_slice_engine.py +933 -0
  96. arcgraph/interfaces/__init__.py +1 -0
  97. arcgraph/interfaces/agent_capabilities.py +493 -0
  98. arcgraph/interfaces/agent_help.py +313 -0
  99. arcgraph/interfaces/benchmark.py +548 -0
  100. arcgraph/interfaces/ci.py +1992 -0
  101. arcgraph/interfaces/cli.py +2799 -0
  102. arcgraph/interfaces/cli_change.py +690 -0
  103. arcgraph/interfaces/cli_feedback.py +176 -0
  104. arcgraph/interfaces/cli_ops.py +69 -0
  105. arcgraph/interfaces/cli_support.py +113 -0
  106. arcgraph/interfaces/cli_trial.py +42 -0
  107. arcgraph/interfaces/cli_visual.py +601 -0
  108. arcgraph/interfaces/cli_workspace.py +300 -0
  109. arcgraph/interfaces/client_setup.py +481 -0
  110. arcgraph/interfaces/docs.py +1255 -0
  111. arcgraph/interfaces/human_output.py +409 -0
  112. arcgraph/interfaces/local_state.py +577 -0
  113. arcgraph/interfaces/mcp_server.py +457 -0
  114. arcgraph/interfaces/mcp_tools.py +1209 -0
  115. arcgraph/interfaces/metrics.py +571 -0
  116. arcgraph/interfaces/ops.py +330 -0
  117. arcgraph/interfaces/precision.py +1670 -0
  118. arcgraph/interfaces/project_probe.py +75 -0
  119. arcgraph/interfaces/reports.py +1121 -0
  120. arcgraph/interfaces/stdio_encoding.py +29 -0
  121. arcgraph/interfaces/trace.py +592 -0
  122. arcgraph/interfaces/trial_feedback.py +519 -0
  123. arcgraph/interfaces/trial_setup.py +535 -0
  124. arcgraph/interfaces/visual_server.py +354 -0
  125. arcgraph/interfaces/visual_slice.py +168 -0
  126. arcgraph/interfaces/visual_smoke.py +666 -0
  127. arcgraph/interfaces/workbench.py +576 -0
  128. arcgraph/interfaces/workspace.py +1513 -0
  129. arcgraph/pipeline/__init__.py +1 -0
  130. arcgraph/pipeline/contracts.py +122 -0
  131. arcgraph/pipeline/external_frontend.py +451 -0
  132. arcgraph/pipeline/external_l3_frontends.py +368 -0
  133. arcgraph/pipeline/external_protocol.py +358 -0
  134. arcgraph/pipeline/frontends.py +437 -0
  135. arcgraph/pipeline/indexer.py +661 -0
  136. arcgraph/pipeline/openapi_frontend.py +708 -0
  137. arcgraph/pipeline/python_frontend.py +298 -0
  138. arcgraph/pipeline/reindexer.py +1333 -0
  139. arcgraph/pipeline/scip_frontend.py +726 -0
  140. arcgraph/pipeline/typescript_extractor/declarations.mjs +183 -0
  141. arcgraph/pipeline/typescript_extractor/edges.mjs +2794 -0
  142. arcgraph/pipeline/typescript_extractor/graph_primitives.mjs +189 -0
  143. arcgraph/pipeline/typescript_extractor/module_resolution.mjs +877 -0
  144. arcgraph/pipeline/typescript_extractor/runtime.mjs +56 -0
  145. arcgraph/pipeline/typescript_extractor/similarity.mjs +619 -0
  146. arcgraph/pipeline/typescript_extractor/similarity_runner.mjs +19 -0
  147. arcgraph/pipeline/typescript_extractor/sources.mjs +23 -0
  148. arcgraph/pipeline/typescript_extractor.mjs +177 -0
  149. arcgraph/pipeline/typescript_frameworks.py +947 -0
  150. arcgraph/pipeline/typescript_frontend.py +656 -0
  151. arcgraph/pipeline/typescript_references.mjs +167 -0
  152. arcgraph/providers/__init__.py +27 -0
  153. arcgraph/providers/context_provider.py +1258 -0
  154. arcgraph/providers/flow_payload.py +117 -0
  155. arcgraph/providers/memory_connector.py +216 -0
  156. arcgraph/providers/orchestrator.py +475 -0
  157. arcgraph/providers/relationship_payload.py +102 -0
  158. arcgraph/providers/risk_provider.py +843 -0
  159. arcgraph/providers/why_provider.py +349 -0
  160. arcgraph/version_info.py +246 -0
  161. arcgraph-0.1.0.dist-info/METADATA +676 -0
  162. arcgraph-0.1.0.dist-info/RECORD +165 -0
  163. arcgraph-0.1.0.dist-info/WHEEL +4 -0
  164. arcgraph-0.1.0.dist-info/entry_points.txt +2 -0
  165. arcgraph-0.1.0.dist-info/licenses/LICENSE +203 -0
@@ -0,0 +1,765 @@
1
+ """Django route, model, and admin extraction for ArcGraph."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+
7
+ from arcgraph.adapters.common import (
8
+ AdapterAnalysis,
9
+ ImportAliases,
10
+ NodeIndex,
11
+ SemanticAdapter,
12
+ call_name,
13
+ import_aliases,
14
+ keyword,
15
+ literal_str,
16
+ module_node_id,
17
+ resolve_alias,
18
+ )
19
+ from arcgraph.core.ids import class_id, function_id, method_id, route_id, table_id
20
+ from arcgraph.core.schemas import Edge, Evidence, FactResolution, FileRecord, Node
21
+
22
+
23
+ class DjangoAdapter(SemanticAdapter):
24
+ """Extract Django URL routes, ORM models, and admin registrations."""
25
+
26
+ name = "django"
27
+ capabilities = (
28
+ "entrypoint:route",
29
+ "resource:table",
30
+ "edge:invokes",
31
+ "edge:maps_to",
32
+ "edge:reads",
33
+ "edge:writes",
34
+ )
35
+ required_facts = ("module", "class", "function")
36
+
37
+ _URL_FUNCS = {"path", "re_path", "url"}
38
+ _MODEL_BASES = {"Model", "models.Model"}
39
+ _ADMIN_DECORATORS = {"admin.register", "register"}
40
+ _MANAGER_METHODS_READ = {
41
+ "get",
42
+ "filter",
43
+ "exclude",
44
+ "all",
45
+ "first",
46
+ "last",
47
+ "values",
48
+ "values_list",
49
+ "aggregate",
50
+ "annotate",
51
+ "count",
52
+ "exists",
53
+ "order_by",
54
+ "select_related",
55
+ "prefetch_related",
56
+ "get_or_create",
57
+ "distinct",
58
+ }
59
+ _MANAGER_METHODS_WRITE = {
60
+ "create",
61
+ "update",
62
+ "delete",
63
+ "bulk_create",
64
+ "bulk_update",
65
+ "update_or_create",
66
+ "save",
67
+ }
68
+
69
+ # ── detect ──────────────────────────────────────────────
70
+
71
+ def detect(
72
+ self,
73
+ files: list[FileRecord],
74
+ parsed_files: dict[str, ast.Module],
75
+ nodes: list[Node],
76
+ module_names: set[str],
77
+ ) -> bool:
78
+ del nodes, module_names
79
+ for file_record in files:
80
+ tree = parsed_files.get(file_record.path)
81
+ if tree is None:
82
+ continue
83
+ for stmt in tree.body:
84
+ if isinstance(stmt, (ast.Import, ast.ImportFrom)):
85
+ if self._is_django_import(stmt):
86
+ return True
87
+ return False
88
+
89
+ # ── analyze ─────────────────────────────────────────────
90
+
91
+ def analyze(
92
+ self,
93
+ files: list[FileRecord],
94
+ parsed_files: dict[str, ast.Module],
95
+ nodes: list[Node],
96
+ module_names: set[str],
97
+ ) -> AdapterAnalysis:
98
+ analysis = AdapterAnalysis()
99
+ node_index = NodeIndex(nodes)
100
+
101
+ # Pass 1: extract models and build table lookup
102
+ model_tables: dict[str, str] = {} # model class name -> table node id
103
+ _AMBIGUOUS = "<ambiguous>"
104
+ for file_record in files:
105
+ tree = parsed_files.get(file_record.path)
106
+ if tree is None:
107
+ continue
108
+ imports = import_aliases(file_record, tree, module_names)
109
+ for class_stmt in (s for s in tree.body if isinstance(s, ast.ClassDef)):
110
+ if self._is_django_model(class_stmt, imports):
111
+ table_info = self._extract_model(
112
+ file_record,
113
+ class_stmt,
114
+ imports,
115
+ node_index,
116
+ analysis,
117
+ )
118
+ if table_info:
119
+ # Always register by fully-qualified name (unambiguous)
120
+ fqn = f"{file_record.module}.{class_stmt.name}"
121
+ model_tables[fqn] = table_info
122
+ # Also register by simple name unless ambiguous
123
+ short = class_stmt.name
124
+ existing = model_tables.get(short)
125
+ if existing is not None and existing != table_info:
126
+ # Cross-app name collision — mark ambiguous
127
+ model_tables[short] = _AMBIGUOUS
128
+ else:
129
+ model_tables[short] = table_info
130
+ # Drop ambiguous entries so later passes never produce wrong edges
131
+ model_tables = {k: v for k, v in model_tables.items() if v != _AMBIGUOUS}
132
+
133
+ # Pass 2: extract URL patterns
134
+ for file_record in files:
135
+ tree = parsed_files.get(file_record.path)
136
+ if tree is None:
137
+ continue
138
+ imports = import_aliases(file_record, tree, module_names)
139
+ self._extract_urlpatterns(
140
+ file_record,
141
+ tree,
142
+ imports,
143
+ node_index,
144
+ analysis,
145
+ module_names,
146
+ )
147
+
148
+ # Pass 3: extract admin registrations
149
+ for file_record in files:
150
+ tree = parsed_files.get(file_record.path)
151
+ if tree is None:
152
+ continue
153
+ imports = import_aliases(file_record, tree, module_names)
154
+ self._extract_admin(
155
+ file_record,
156
+ tree,
157
+ imports,
158
+ node_index,
159
+ model_tables,
160
+ analysis,
161
+ )
162
+
163
+ # Pass 4: ORM query edges (read/write)
164
+ for file_record in files:
165
+ tree = parsed_files.get(file_record.path)
166
+ if tree is None:
167
+ continue
168
+ imports = import_aliases(file_record, tree, module_names)
169
+ self._extract_orm_edges(
170
+ file_record,
171
+ tree,
172
+ imports,
173
+ node_index,
174
+ model_tables,
175
+ analysis,
176
+ )
177
+
178
+ analysis.nodes.sort(key=lambda n: n.id)
179
+ analysis.edges.sort(key=lambda e: (e.source, e.target, e.kind))
180
+ return analysis
181
+
182
+ # ── models ──────────────────────────────────────────────
183
+
184
+ def _is_django_model(
185
+ self,
186
+ class_stmt: ast.ClassDef,
187
+ imports: ImportAliases,
188
+ ) -> bool:
189
+ for base in class_stmt.bases:
190
+ base_name = call_name(base)
191
+ if not base_name:
192
+ continue
193
+ resolved = resolve_alias(base_name, imports) or base_name
194
+ if base_name in self._MODEL_BASES:
195
+ return True
196
+ if resolved.endswith(".Model") or resolved.endswith("models.Model"):
197
+ return True
198
+ return False
199
+
200
+ def _extract_model(
201
+ self,
202
+ file_record: FileRecord,
203
+ class_stmt: ast.ClassDef,
204
+ imports: ImportAliases,
205
+ node_index: NodeIndex,
206
+ analysis: AdapterAnalysis,
207
+ ) -> str | None:
208
+ """Extract a Django Model class as a table node. Returns table node ID."""
209
+ # Determine table name from Meta.db_table or default
210
+ tbl_name = self._meta_db_table(class_stmt)
211
+ if not tbl_name:
212
+ # Django default: app_label + "_" + model_name.lower()
213
+ # Derive app_label from module path (first component)
214
+ app_label = file_record.module.split(".")[0]
215
+ tbl_name = f"{app_label}_{class_stmt.name.lower()}"
216
+
217
+ tbl_id = table_id(tbl_name)
218
+ model_cls_id = class_id(f"{file_record.module}.{class_stmt.name}")
219
+
220
+ analysis.nodes.append(
221
+ Node(
222
+ id=tbl_id,
223
+ kind="table",
224
+ name=tbl_name,
225
+ qualname=tbl_name,
226
+ path=file_record.path,
227
+ start_line=class_stmt.lineno,
228
+ end_line=getattr(class_stmt, "end_lineno", class_stmt.lineno),
229
+ properties={
230
+ "backend": "django_orm",
231
+ "model_class": class_stmt.name,
232
+ "fields": self._model_fields(class_stmt),
233
+ },
234
+ )
235
+ )
236
+ analysis.edges.append(
237
+ self._edge(
238
+ model_cls_id,
239
+ tbl_id,
240
+ "maps_to",
241
+ file_record,
242
+ class_stmt,
243
+ f"class {class_stmt.name} -> table {tbl_name}",
244
+ )
245
+ )
246
+ return tbl_id
247
+
248
+ @staticmethod
249
+ def _meta_db_table(class_stmt: ast.ClassDef) -> str | None:
250
+ """Read Meta.db_table if present."""
251
+ for item in class_stmt.body:
252
+ if isinstance(item, ast.ClassDef) and item.name == "Meta":
253
+ for meta_item in item.body:
254
+ if not isinstance(meta_item, ast.Assign):
255
+ continue
256
+ for target in meta_item.targets:
257
+ if isinstance(target, ast.Name) and target.id == "db_table":
258
+ return literal_str(meta_item.value)
259
+ return None
260
+
261
+ @staticmethod
262
+ def _model_fields(class_stmt: ast.ClassDef) -> list[str]:
263
+ """Extract field names from model body assignments."""
264
+ fields: list[str] = []
265
+ for item in class_stmt.body:
266
+ if not isinstance(item, ast.Assign):
267
+ continue
268
+ if not isinstance(item.value, ast.Call):
269
+ continue
270
+ func = call_name(item.value.func)
271
+ if func and ("Field" in func or func.startswith("models.")):
272
+ for target in item.targets:
273
+ if isinstance(target, ast.Name):
274
+ fields.append(target.id)
275
+ return fields
276
+
277
+ # ── URL patterns ────────────────────────────────────────
278
+
279
+ def _extract_urlpatterns(
280
+ self,
281
+ file_record: FileRecord,
282
+ tree: ast.Module,
283
+ imports: ImportAliases,
284
+ node_index: NodeIndex,
285
+ analysis: AdapterAnalysis,
286
+ module_names: set[str],
287
+ ) -> None:
288
+ """Scan for urlpatterns = [path(...), ...] at module level."""
289
+ for stmt in tree.body:
290
+ if not isinstance(stmt, ast.Assign):
291
+ continue
292
+ targets = [t.id for t in stmt.targets if isinstance(t, ast.Name)]
293
+ if "urlpatterns" not in targets:
294
+ continue
295
+ if not isinstance(stmt.value, (ast.List, ast.Tuple)):
296
+ # Could be urlpatterns += [...] or a function call
297
+ continue
298
+ for item in stmt.value.elts:
299
+ self._process_url_call(
300
+ file_record,
301
+ item,
302
+ "",
303
+ imports,
304
+ node_index,
305
+ analysis,
306
+ module_names,
307
+ )
308
+ # Also handle augmented assignment: urlpatterns += [...]
309
+ for stmt in tree.body:
310
+ if isinstance(stmt, ast.AugAssign) and isinstance(stmt.target, ast.Name):
311
+ if stmt.target.id == "urlpatterns" and isinstance(
312
+ stmt.value, (ast.List, ast.Tuple)
313
+ ):
314
+ for item in stmt.value.elts:
315
+ self._process_url_call(
316
+ file_record,
317
+ item,
318
+ "",
319
+ imports,
320
+ node_index,
321
+ analysis,
322
+ module_names,
323
+ )
324
+
325
+ def _process_url_call(
326
+ self,
327
+ file_record: FileRecord,
328
+ node: ast.AST,
329
+ prefix: str,
330
+ imports: ImportAliases,
331
+ node_index: NodeIndex,
332
+ analysis: AdapterAnalysis,
333
+ module_names: set[str],
334
+ ) -> None:
335
+ """Process a single path()/re_path()/url() call."""
336
+ if not isinstance(node, ast.Call):
337
+ return
338
+ func = call_name(node.func)
339
+ if not func:
340
+ return
341
+ # Strip module prefix: django.urls.path -> path
342
+ short = func.rsplit(".", 1)[-1] if "." in func else func
343
+ if short not in self._URL_FUNCS:
344
+ return
345
+ # First arg is the route pattern
346
+ pattern = literal_str(node.args[0]) if node.args else None
347
+ if pattern is None:
348
+ return
349
+
350
+ full_path = self._combine_url(prefix, pattern)
351
+
352
+ # Second arg or 'view' kwarg is the view
353
+ view_arg = node.args[1] if len(node.args) > 1 else keyword(node, "view")
354
+
355
+ # Check if this is an include()
356
+ if isinstance(view_arg, ast.Call):
357
+ inc_name = call_name(view_arg.func)
358
+ if inc_name and inc_name.rsplit(".", 1)[-1] == "include":
359
+ # include() — emit an includes_router edge if we can resolve the module
360
+ if view_arg.args:
361
+ included = literal_str(view_arg.args[0])
362
+ if included:
363
+ target_id = f"mod:{included}"
364
+ existing_target = node_index.by_id.get(target_id)
365
+ resolved_module = included in module_names or (
366
+ existing_target is not None
367
+ and existing_target.properties.get("resolution_status")
368
+ != "unresolved"
369
+ and existing_target.properties.get("external_reference")
370
+ is not True
371
+ )
372
+ if not resolved_module and existing_target is None:
373
+ placeholder = Node(
374
+ id=target_id,
375
+ kind="module",
376
+ name=included.rsplit(".", 1)[-1],
377
+ qualname=included,
378
+ properties={
379
+ "adapter": self.name,
380
+ "external_reference": True,
381
+ "resolution_status": "unresolved",
382
+ "included_module": included,
383
+ },
384
+ )
385
+ analysis.nodes.append(placeholder)
386
+ node_index.by_id[target_id] = placeholder
387
+ analysis.edges.append(
388
+ self._edge(
389
+ module_node_id(file_record),
390
+ target_id,
391
+ "includes_router",
392
+ file_record,
393
+ node,
394
+ f"include({included!r})",
395
+ confidence=(
396
+ "confirmed" if resolved_module else "unresolved"
397
+ ),
398
+ resolution=(
399
+ None
400
+ if resolved_module
401
+ else FactResolution(
402
+ status="unresolved",
403
+ strategy="django_include_literal",
404
+ candidate_count=0,
405
+ detail=(
406
+ "included module is outside the indexed "
407
+ "module registry"
408
+ ),
409
+ )
410
+ ),
411
+ )
412
+ )
413
+ return
414
+
415
+ # Regular view — create route node
416
+ # For CBV: UserView.as_view() -> extract full call chain name
417
+ view_name: str | None = None
418
+ if isinstance(view_arg, ast.Call):
419
+ # e.g. UserView.as_view() — get the full name of the call func
420
+ view_name = call_name(view_arg.func)
421
+ elif view_arg is not None:
422
+ view_name = call_name(view_arg)
423
+ name_kwarg = literal_str(keyword(node, "name")) or view_name or pattern
424
+ method = "ANY" # Django URL conf doesn't specify method
425
+ node_id = route_id(method, full_path)
426
+
427
+ handler_id: str | None = None
428
+ if view_name:
429
+ resolved = resolve_alias(view_name, imports) or view_name
430
+ # Could be a function view or CBV.as_view()
431
+ if resolved.endswith(".as_view"):
432
+ cls_name = resolved.rsplit(".as_view", 1)[0]
433
+ handler_id = class_id(cls_name)
434
+ else:
435
+ handler_id = node_index.function_id_for(
436
+ view_name, file_record, imports
437
+ ) or function_id(
438
+ (
439
+ f"{file_record.module}.{view_name}"
440
+ if "." not in view_name
441
+ else resolved
442
+ ),
443
+ )
444
+
445
+ analysis.nodes.append(
446
+ Node(
447
+ id=node_id,
448
+ kind="route",
449
+ name=f"{method} {full_path}",
450
+ qualname=f"{method} {full_path}",
451
+ path=file_record.path,
452
+ start_line=getattr(node, "lineno", None),
453
+ end_line=getattr(node, "end_lineno", getattr(node, "lineno", None)),
454
+ properties={
455
+ "method": method,
456
+ "path": full_path,
457
+ "name": name_kwarg,
458
+ "handler": handler_id,
459
+ "backend": "django",
460
+ },
461
+ )
462
+ )
463
+
464
+ if handler_id:
465
+ analysis.edges.append(
466
+ self._edge(
467
+ node_id,
468
+ handler_id,
469
+ "invokes",
470
+ file_record,
471
+ node,
472
+ f"path({pattern!r}) -> {view_name}",
473
+ )
474
+ )
475
+
476
+ @staticmethod
477
+ def _combine_url(prefix: str, pattern: str) -> str:
478
+ """Combine a URL prefix with a pattern."""
479
+ if not prefix:
480
+ result = f"/{pattern.lstrip('/')}" if pattern else "/"
481
+ elif not pattern:
482
+ result = f"/{prefix.strip('/')}"
483
+ else:
484
+ result = f"/{prefix.strip('/')}/{pattern.lstrip('/')}"
485
+ return result.replace("//", "/")
486
+
487
+ # ── admin registrations ─────────────────────────────────
488
+
489
+ def _extract_admin(
490
+ self,
491
+ file_record: FileRecord,
492
+ tree: ast.Module,
493
+ imports: ImportAliases,
494
+ node_index: NodeIndex,
495
+ model_tables: dict[str, str],
496
+ analysis: AdapterAnalysis,
497
+ ) -> None:
498
+ """Extract @admin.register(Model) decorators and admin.site.register() calls."""
499
+ for stmt in tree.body:
500
+ if isinstance(stmt, ast.ClassDef):
501
+ for decorator in stmt.decorator_list:
502
+ self._admin_register_decorator(
503
+ file_record,
504
+ stmt,
505
+ decorator,
506
+ imports,
507
+ node_index,
508
+ model_tables,
509
+ analysis,
510
+ )
511
+ # Also handle admin.site.register(Model, ModelAdmin)
512
+ if isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Call):
513
+ func = call_name(stmt.value.func)
514
+ if func and func.endswith("site.register"):
515
+ self._admin_site_register(
516
+ file_record,
517
+ stmt.value,
518
+ imports,
519
+ node_index,
520
+ model_tables,
521
+ analysis,
522
+ )
523
+
524
+ def _admin_register_decorator(
525
+ self,
526
+ file_record: FileRecord,
527
+ class_stmt: ast.ClassDef,
528
+ decorator: ast.expr,
529
+ imports: ImportAliases,
530
+ node_index: NodeIndex,
531
+ model_tables: dict[str, str],
532
+ analysis: AdapterAnalysis,
533
+ ) -> None:
534
+ if not isinstance(decorator, ast.Call):
535
+ return
536
+ func = call_name(decorator.func)
537
+ if not func:
538
+ return
539
+ short = func.rsplit(".", 1)[-1] if "." in func else func
540
+ if short != "register" or not decorator.args:
541
+ return
542
+ # Could be admin.register or just register (imported)
543
+ if "." in func and not func.endswith("admin.register"):
544
+ resolved = resolve_alias(func, imports) or func
545
+ if not resolved.endswith("admin.register"):
546
+ return
547
+
548
+ admin_cls_id = class_id(f"{file_record.module}.{class_stmt.name}")
549
+ for arg in decorator.args:
550
+ model_name = call_name(arg)
551
+ if not model_name:
552
+ continue
553
+ tbl_id = self._resolve_table(model_name, imports, model_tables)
554
+ if tbl_id:
555
+ analysis.edges.append(
556
+ self._edge(
557
+ admin_cls_id,
558
+ tbl_id,
559
+ "reads",
560
+ file_record,
561
+ decorator,
562
+ f"@admin.register({model_name})",
563
+ )
564
+ )
565
+
566
+ def _admin_site_register(
567
+ self,
568
+ file_record: FileRecord,
569
+ call: ast.Call,
570
+ imports: ImportAliases,
571
+ node_index: NodeIndex,
572
+ model_tables: dict[str, str],
573
+ analysis: AdapterAnalysis,
574
+ ) -> None:
575
+ if not call.args:
576
+ return
577
+ model_name = call_name(call.args[0])
578
+ if not model_name:
579
+ return
580
+ tbl_id = self._resolve_table(model_name, imports, model_tables)
581
+ admin_cls: str | None = None
582
+ if len(call.args) > 1:
583
+ admin_name = call_name(call.args[1])
584
+ if admin_name:
585
+ admin_cls = class_id(
586
+ (
587
+ f"{file_record.module}.{admin_name}"
588
+ if "." not in admin_name
589
+ else admin_name
590
+ ),
591
+ )
592
+ source = admin_cls or module_node_id(file_record)
593
+ if tbl_id:
594
+ analysis.edges.append(
595
+ self._edge(
596
+ source,
597
+ tbl_id,
598
+ "reads",
599
+ file_record,
600
+ call,
601
+ f"admin.site.register({model_name})",
602
+ )
603
+ )
604
+
605
+ # ── ORM query edges ─────────────────────────────────────
606
+
607
+ def _extract_orm_edges(
608
+ self,
609
+ file_record: FileRecord,
610
+ tree: ast.Module,
611
+ imports: ImportAliases,
612
+ node_index: NodeIndex,
613
+ model_tables: dict[str, str],
614
+ analysis: AdapterAnalysis,
615
+ ) -> None:
616
+ """Detect Model.objects.filter() / .create() etc. chains."""
617
+ source_ranges = self._function_line_index(file_record, tree)
618
+
619
+ for node in ast.walk(tree):
620
+ if not isinstance(node, ast.Call):
621
+ continue
622
+ if not isinstance(node.func, ast.Attribute):
623
+ continue
624
+ method_name = node.func.attr
625
+ is_read = method_name in self._MANAGER_METHODS_READ
626
+ is_write = method_name in self._MANAGER_METHODS_WRITE
627
+ if not is_read and not is_write:
628
+ continue
629
+
630
+ # Try to resolve Model.objects.method() or queryset.method()
631
+ model_name = self._resolve_orm_model(node.func.value)
632
+ if not model_name:
633
+ continue
634
+ tbl_id = self._resolve_table(model_name, imports, model_tables)
635
+ if not tbl_id:
636
+ continue
637
+
638
+ source = self._source_for_line(source_ranges, node.lineno)
639
+ if not source:
640
+ source = module_node_id(file_record)
641
+
642
+ kind = "reads" if is_read else "writes"
643
+ analysis.edges.append(
644
+ self._edge(
645
+ source,
646
+ tbl_id,
647
+ kind,
648
+ file_record,
649
+ node,
650
+ f"{model_name}.objects.{method_name}()",
651
+ )
652
+ )
653
+
654
+ @staticmethod
655
+ def _resolve_orm_model(node: ast.AST) -> str | None:
656
+ """Try to extract Model name from Model.objects chain."""
657
+ # Pattern: Model.objects.method()
658
+ if isinstance(node, ast.Attribute) and node.attr == "objects":
659
+ if isinstance(node.value, ast.Name):
660
+ return node.value.id
661
+ # Pattern: Model.objects.filter().method() (chained)
662
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
663
+ return DjangoAdapter._resolve_orm_model(node.func.value)
664
+ return None
665
+
666
+ # ── helpers ──────────────────────────────────────────────
667
+
668
+ @staticmethod
669
+ def _resolve_table(
670
+ model_name: str,
671
+ imports: ImportAliases,
672
+ model_tables: dict[str, str],
673
+ ) -> str | None:
674
+ """Resolve a model class name to its table node ID.
675
+
676
+ Tries fully-qualified name via import aliases first (always
677
+ unambiguous), then falls back to simple name (only works when no
678
+ cross-app collision exists).
679
+ """
680
+ resolved = resolve_alias(model_name, imports)
681
+ if resolved:
682
+ tbl_id = model_tables.get(resolved)
683
+ if tbl_id:
684
+ return tbl_id
685
+ return model_tables.get(model_name)
686
+
687
+ @staticmethod
688
+ def _is_django_import(stmt: ast.stmt) -> bool:
689
+ if isinstance(stmt, ast.Import):
690
+ return any(alias.name.startswith("django") for alias in stmt.names)
691
+ if isinstance(stmt, ast.ImportFrom):
692
+ return bool(stmt.module and stmt.module.startswith("django"))
693
+ return False
694
+
695
+ @staticmethod
696
+ def _function_line_index(
697
+ file_record: FileRecord,
698
+ tree: ast.Module,
699
+ ) -> list[tuple[int, int, str]]:
700
+ ranges: list[tuple[int, int, str]] = []
701
+ for stmt in tree.body:
702
+ if isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef)):
703
+ ranges.append(
704
+ (
705
+ stmt.lineno,
706
+ getattr(stmt, "end_lineno", stmt.lineno),
707
+ function_id(f"{file_record.module}.{stmt.name}"),
708
+ )
709
+ )
710
+ elif isinstance(stmt, ast.ClassDef):
711
+ for child in stmt.body:
712
+ if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
713
+ qn = f"{file_record.module}.{stmt.name}.{child.name}"
714
+ ranges.append(
715
+ (
716
+ child.lineno,
717
+ getattr(child, "end_lineno", child.lineno),
718
+ method_id(qn),
719
+ )
720
+ )
721
+ return ranges
722
+
723
+ @staticmethod
724
+ def _source_for_line(
725
+ ranges: list[tuple[int, int, str]],
726
+ line: int,
727
+ ) -> str | None:
728
+ for start, end, node_id in ranges:
729
+ if start <= line <= end:
730
+ return node_id
731
+ return None
732
+
733
+ @staticmethod
734
+ def _edge(
735
+ source: str,
736
+ target: str,
737
+ kind: str,
738
+ file_record: FileRecord,
739
+ node: ast.AST,
740
+ detail: str,
741
+ *,
742
+ confidence: str = "confirmed",
743
+ resolution: FactResolution | None = None,
744
+ ) -> Edge:
745
+ return Edge(
746
+ source=source,
747
+ target=target,
748
+ kind=kind,
749
+ confidence=confidence,
750
+ resolution=resolution or FactResolution(),
751
+ evidence=[
752
+ Evidence(
753
+ kind="django_framework",
754
+ path=file_record.path,
755
+ start_line=getattr(node, "lineno", None),
756
+ end_line=getattr(
757
+ node,
758
+ "end_lineno",
759
+ getattr(node, "lineno", None),
760
+ ),
761
+ column=getattr(node, "col_offset", None),
762
+ detail=detail,
763
+ )
764
+ ],
765
+ )