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,436 @@
1
+ """Shared helpers for framework adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass, field
8
+ from typing import Any, Iterable
9
+
10
+ from arcgraph.core.ids import class_id, function_id, method_id, module_id
11
+ from arcgraph.core.schemas import (
12
+ BuildWarning,
13
+ Confidence,
14
+ Edge,
15
+ Evidence,
16
+ FileRecord,
17
+ Node,
18
+ )
19
+
20
+
21
+ @dataclass
22
+ class AdapterAnalysis:
23
+ nodes: list[Node] = field(default_factory=list)
24
+ edges: list[Edge] = field(default_factory=list)
25
+ warnings: list[BuildWarning] = field(default_factory=list)
26
+ diagnostics: list[BuildWarning] = field(default_factory=list)
27
+ metrics: dict[str, Any] = field(default_factory=dict)
28
+
29
+
30
+ class SemanticAdapter:
31
+ """Stable extension contract for framework and DSL-specific extraction."""
32
+
33
+ name = "semantic_adapter"
34
+ capabilities: tuple[str, ...] = ()
35
+ required_facts: tuple[str, ...] = ()
36
+
37
+ def detect(
38
+ self,
39
+ files: list[FileRecord],
40
+ parsed_files: dict[str, ast.Module],
41
+ nodes: list[Node],
42
+ module_names: set[str],
43
+ ) -> bool:
44
+ del files, parsed_files, nodes, module_names
45
+ return True
46
+
47
+ def analyze(
48
+ self,
49
+ files: list[FileRecord],
50
+ parsed_files: dict[str, ast.Module],
51
+ nodes: list[Node],
52
+ module_names: set[str],
53
+ ) -> AdapterAnalysis:
54
+ raise NotImplementedError
55
+
56
+
57
+ class AdapterRegistry:
58
+ """Run semantic adapters with incremental graph context and failure isolation."""
59
+
60
+ def __init__(
61
+ self,
62
+ adapters: Iterable[SemanticAdapter],
63
+ *,
64
+ dedupe_nodes: Callable[[list[Node]], list[Node]] | None = None,
65
+ ) -> None:
66
+ self._adapters = tuple(adapters)
67
+ self._dedupe_nodes = dedupe_nodes or self._stable_dedupe_nodes
68
+
69
+ @property
70
+ def adapters(self) -> tuple[SemanticAdapter, ...]:
71
+ return self._adapters
72
+
73
+ def analyze(
74
+ self,
75
+ files: list[FileRecord],
76
+ parsed_files: dict[str, ast.Module],
77
+ nodes: list[Node],
78
+ module_names: set[str],
79
+ ) -> AdapterAnalysis:
80
+ aggregate = AdapterAnalysis(metrics={"adapters": {}})
81
+ context = self._dedupe_nodes(list(nodes))
82
+ adapter_metrics: dict[str, dict[str, Any]] = aggregate.metrics["adapters"]
83
+
84
+ for adapter in self._adapters:
85
+ name = adapter.name
86
+ try:
87
+ if not adapter.detect(files, parsed_files, context, module_names):
88
+ adapter_metrics[name] = {
89
+ "status": "skipped",
90
+ "nodes": 0,
91
+ "edges": 0,
92
+ "warnings": 0,
93
+ }
94
+ continue
95
+ analysis = adapter.analyze(files, parsed_files, context, module_names)
96
+ except Exception as exc:
97
+ aggregate.warnings.append(
98
+ BuildWarning(
99
+ kind="adapter_error",
100
+ message=f"{name}: {exc.__class__.__name__}: {exc}",
101
+ )
102
+ )
103
+ adapter_metrics[name] = {
104
+ "status": "error",
105
+ "nodes": 0,
106
+ "edges": 0,
107
+ "warnings": 1,
108
+ }
109
+ continue
110
+
111
+ aggregate.nodes.extend(analysis.nodes)
112
+ aggregate.edges.extend(analysis.edges)
113
+ aggregate.warnings.extend(analysis.warnings)
114
+ aggregate.warnings.extend(analysis.diagnostics)
115
+ unresolved_registrations = sum(
116
+ 1
117
+ for node in analysis.nodes
118
+ if node.properties.get("resolution_status") == "unresolved"
119
+ )
120
+ metrics = {
121
+ "discovered_registrations": (
122
+ len(analysis.nodes) - unresolved_registrations
123
+ ),
124
+ "resolved_handlers": self._resolved_handler_count(analysis.edges),
125
+ "unresolved_registrations": unresolved_registrations,
126
+ **analysis.metrics,
127
+ }
128
+ adapter_metrics[name] = {
129
+ "status": "available",
130
+ "nodes": len(analysis.nodes),
131
+ "edges": len(analysis.edges),
132
+ "warnings": len(analysis.warnings) + len(analysis.diagnostics),
133
+ **metrics,
134
+ }
135
+ context = self._dedupe_nodes([*context, *analysis.nodes])
136
+
137
+ return aggregate
138
+
139
+ @staticmethod
140
+ def _resolved_handler_count(edges: list[Edge]) -> int:
141
+ return sum(
142
+ 1
143
+ for edge in edges
144
+ if edge.kind
145
+ in {
146
+ "invokes",
147
+ "provides",
148
+ "injects",
149
+ "calls",
150
+ "maps_to",
151
+ "reads",
152
+ "writes",
153
+ "enqueues",
154
+ "consumes",
155
+ "declares",
156
+ "configures",
157
+ "logs",
158
+ }
159
+ and edge.resolution.status == "resolved"
160
+ and edge.confidence != "unresolved"
161
+ )
162
+
163
+ @staticmethod
164
+ def _stable_dedupe_nodes(nodes: list[Node]) -> list[Node]:
165
+ deduped: dict[str, Node] = {}
166
+ for node in nodes:
167
+ deduped.setdefault(node.id, node)
168
+ return list(deduped.values())
169
+
170
+
171
+ @dataclass
172
+ class ImportAliases:
173
+ aliases: dict[str, str] = field(default_factory=dict)
174
+ modules: dict[str, str] = field(default_factory=dict)
175
+
176
+
177
+ class NodeIndex:
178
+ def __init__(self, nodes: Iterable[Node]) -> None:
179
+ self.by_id = {node.id: node for node in nodes}
180
+ self.classes_by_name: dict[str, list[Node]] = {}
181
+ self.functions_by_name: dict[str, list[Node]] = {}
182
+ self.methods_by_class_and_name: dict[tuple[str, str], Node] = {}
183
+ for node in nodes:
184
+ if node.kind == "class":
185
+ self.classes_by_name.setdefault(node.name, []).append(node)
186
+ elif node.kind == "function":
187
+ self.functions_by_name.setdefault(node.name, []).append(node)
188
+ elif node.kind == "method" and node.qualname:
189
+ class_qualname = node.qualname.rsplit(".", 1)[0]
190
+ self.methods_by_class_and_name[(class_qualname, node.name)] = node
191
+
192
+ def function_id_for(
193
+ self, name: str, file_record: FileRecord, imports: ImportAliases
194
+ ) -> str | None:
195
+ qualname = resolve_name(name, file_record, imports)
196
+ candidates = [function_id(qualname)]
197
+ if "." not in name:
198
+ candidates.append(function_id(f"{file_record.module}.{name}"))
199
+ for candidate in candidates:
200
+ if candidate in self.by_id:
201
+ return candidate
202
+ same_name = self.functions_by_name.get(name.rsplit(".", 1)[-1], [])
203
+ if len(same_name) == 1:
204
+ return same_name[0].id
205
+ return None
206
+
207
+ def class_for_annotation(
208
+ self, annotation: str | None, file_record: FileRecord, imports: ImportAliases
209
+ ) -> Node | None:
210
+ if not annotation:
211
+ return None
212
+ name = clean_annotation_name(annotation)
213
+ if not name:
214
+ return None
215
+ qualname = resolve_name(name, file_record, imports)
216
+ candidate = self.by_id.get(class_id(qualname))
217
+ if candidate:
218
+ return candidate
219
+ matches = self.classes_by_name.get(name.rsplit(".", 1)[-1], [])
220
+ if len(matches) == 1:
221
+ return matches[0]
222
+ return None
223
+
224
+ def method_for_class(self, class_node: Node, method_name: str) -> Node | None:
225
+ if not class_node.qualname:
226
+ return None
227
+ return self.methods_by_class_and_name.get((class_node.qualname, method_name))
228
+
229
+
230
+ def import_aliases(
231
+ file_record: FileRecord, tree: ast.Module, module_names: set[str]
232
+ ) -> ImportAliases:
233
+ aliases: dict[str, str] = {}
234
+ modules: dict[str, str] = {}
235
+ for node in tree.body:
236
+ if isinstance(node, ast.Import):
237
+ for alias in node.names:
238
+ local = alias.asname or alias.name.split(".", 1)[0]
239
+ aliases[local] = alias.name
240
+ modules[local] = alias.name
241
+ elif isinstance(node, ast.ImportFrom):
242
+ base = resolve_import_from_base(file_record, node)
243
+ if not base:
244
+ continue
245
+ for alias in node.names:
246
+ if alias.name == "*":
247
+ continue
248
+ local = alias.asname or alias.name
249
+ candidate = f"{base}.{alias.name}"
250
+ aliases[local] = candidate
251
+ modules[local] = candidate if candidate in module_names else base
252
+ return ImportAliases(aliases=aliases, modules=modules)
253
+
254
+
255
+ def resolve_name(name: str, file_record: FileRecord, imports: ImportAliases) -> str:
256
+ if not name:
257
+ return name
258
+ parts = name.split(".")
259
+ if parts[0] in imports.aliases:
260
+ return ".".join([imports.aliases[parts[0]], *parts[1:]])
261
+ if "." in name:
262
+ return name
263
+ return f"{file_record.module}.{name}"
264
+
265
+
266
+ def resolve_alias(name: str | None, imports: ImportAliases) -> str | None:
267
+ if not name:
268
+ return None
269
+ parts = name.split(".")
270
+ if parts[0] in imports.aliases:
271
+ return ".".join([imports.aliases[parts[0]], *parts[1:]])
272
+ return name
273
+
274
+
275
+ def resolve_module_name(name: str, imports: ImportAliases) -> str | None:
276
+ parts = name.split(".")
277
+ if parts[0] in imports.modules:
278
+ resolved = imports.modules[parts[0]]
279
+ return ".".join([resolved, *parts[1:-1]]) if len(parts) > 1 else resolved
280
+ return None
281
+
282
+
283
+ def resolve_import_from_base(
284
+ file_record: FileRecord, node: ast.ImportFrom
285
+ ) -> str | None:
286
+ if node.level == 0:
287
+ return node.module
288
+
289
+ module_parts = file_record.module.split(".")
290
+ package_parts = module_parts if file_record.is_package else module_parts[:-1]
291
+ keep_count = len(package_parts) - node.level + 1
292
+ if keep_count < 0:
293
+ return None
294
+
295
+ base_parts = package_parts[:keep_count]
296
+ if node.module:
297
+ base_parts.extend(node.module.split("."))
298
+ return ".".join(part for part in base_parts if part)
299
+
300
+
301
+ def iter_symbol_defs(
302
+ tree: ast.Module,
303
+ ) -> Iterable[tuple[ast.FunctionDef | ast.AsyncFunctionDef, str, Callable[[str], str]]]:
304
+ for stmt in tree.body:
305
+ if isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef)):
306
+ yield stmt, stmt.name, function_id
307
+ elif isinstance(stmt, ast.ClassDef):
308
+ class_qualname = stmt.name
309
+ for child in stmt.body:
310
+ if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
311
+ yield child, f"{class_qualname}.{child.name}", method_id
312
+
313
+
314
+ def symbol_id_for_def(
315
+ file_record: FileRecord, qualified_name: str, id_factory: Callable[[str], str]
316
+ ) -> str:
317
+ return id_factory(f"{file_record.module}.{qualified_name}")
318
+
319
+
320
+ def call_name(node: ast.AST) -> str | None:
321
+ if isinstance(node, ast.Name):
322
+ return node.id
323
+ if isinstance(node, ast.Attribute):
324
+ base = call_name(node.value)
325
+ return f"{base}.{node.attr}" if base else node.attr
326
+ return None
327
+
328
+
329
+ def literal_str(node: ast.AST | None) -> str | None:
330
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
331
+ return node.value
332
+ return None
333
+
334
+
335
+ def literal_bool(node: ast.AST | None) -> bool | None:
336
+ if isinstance(node, ast.Constant) and isinstance(node.value, bool):
337
+ return node.value
338
+ return None
339
+
340
+
341
+ def literal_str_list(node: ast.AST | None) -> list[str]:
342
+ if isinstance(node, (ast.List, ast.Tuple, ast.Set)):
343
+ return [value for item in node.elts if (value := literal_str(item))]
344
+ value = literal_str(node)
345
+ return [value] if value else []
346
+
347
+
348
+ def keyword(call: ast.Call, name: str) -> ast.AST | None:
349
+ for item in call.keywords:
350
+ if item.arg == name:
351
+ return item.value
352
+ return None
353
+
354
+
355
+ def make_adapter_edge(
356
+ source: str,
357
+ target: str,
358
+ kind: str,
359
+ confidence: Confidence,
360
+ evidence_kind: str,
361
+ file_record: FileRecord,
362
+ node: ast.AST,
363
+ detail: str,
364
+ *,
365
+ semantic_role: str | None = None,
366
+ ) -> Edge:
367
+ return Edge(
368
+ source=source,
369
+ target=target,
370
+ kind=kind,
371
+ confidence=confidence,
372
+ semantic_role=semantic_role or f"{kind}:{evidence_kind}",
373
+ evidence=[
374
+ Evidence(
375
+ kind=evidence_kind,
376
+ path=file_record.path,
377
+ start_line=getattr(node, "lineno", None),
378
+ end_line=getattr(node, "end_lineno", getattr(node, "lineno", None)),
379
+ column=getattr(node, "col_offset", None),
380
+ detail=detail,
381
+ )
382
+ ],
383
+ )
384
+
385
+
386
+ def annotation_name(node: ast.AST | None) -> str | None:
387
+ if node is None:
388
+ return None
389
+ if isinstance(node, ast.Name):
390
+ return node.id
391
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
392
+ return node.value
393
+ if isinstance(node, ast.Attribute):
394
+ base = annotation_name(node.value)
395
+ return f"{base}.{node.attr}" if base else node.attr
396
+ if isinstance(node, ast.Subscript):
397
+ root = annotation_name(node.value)
398
+ if root and root.rsplit(".", 1)[-1] == "Annotated":
399
+ if isinstance(node.slice, ast.Tuple) and node.slice.elts:
400
+ return annotation_name(node.slice.elts[0])
401
+ return annotation_name(node.value)
402
+ if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr):
403
+ return annotation_name(node.left) or annotation_name(node.right)
404
+ return unparse(node)
405
+
406
+
407
+ def clean_annotation_name(name: str) -> str:
408
+ cleaned = name.strip("\"'")
409
+ if "|" in cleaned:
410
+ cleaned = cleaned.split("|", 1)[0].strip()
411
+ if cleaned.startswith("Optional[") and cleaned.endswith("]"):
412
+ cleaned = cleaned.removeprefix("Optional[").removesuffix("]")
413
+ return cleaned.strip("\"'")
414
+
415
+
416
+ def unparse(node: ast.AST) -> str:
417
+ try:
418
+ return ast.unparse(node)
419
+ except Exception:
420
+ return node.__class__.__name__
421
+
422
+
423
+ def combine_paths(prefix: str, path: str) -> str:
424
+ if not prefix:
425
+ combined = path or "/"
426
+ elif not path:
427
+ combined = prefix
428
+ else:
429
+ combined = f"{prefix.rstrip('/')}/{path.lstrip('/')}"
430
+ if not combined.startswith("/"):
431
+ combined = f"/{combined}"
432
+ return combined.replace("//", "/")
433
+
434
+
435
+ def module_node_id(file_record: FileRecord) -> str:
436
+ return module_id(file_record.module)