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
arcgraph/__init__.py ADDED
@@ -0,0 +1,41 @@
1
+ """ArcGraph package."""
2
+
3
+ import importlib.metadata
4
+ from pathlib import Path
5
+
6
+ import tomllib
7
+
8
+ from arcgraph.core.schemas import READ_SCHEMA_VERSION, SCHEMA_VERSION
9
+
10
+ _DISTRIBUTION_NAME = "arcgraph"
11
+ _UNKNOWN_VERSION = "0.0.0+unknown"
12
+
13
+
14
+ def _version_from_pyproject() -> str | None:
15
+ pyproject_path = Path(__file__).resolve().parents[1] / "pyproject.toml"
16
+ if not pyproject_path.exists():
17
+ return None
18
+ try:
19
+ pyproject = tomllib.loads(pyproject_path.read_text(encoding="utf-8"))
20
+ except (OSError, tomllib.TOMLDecodeError):
21
+ return None
22
+ project = pyproject.get("project")
23
+ if not isinstance(project, dict):
24
+ return None
25
+ version = project.get("version")
26
+ return version if isinstance(version, str) else None
27
+
28
+
29
+ def _package_version() -> str:
30
+ source_version = _version_from_pyproject()
31
+ if source_version is not None:
32
+ return source_version
33
+ try:
34
+ return importlib.metadata.version(_DISTRIBUTION_NAME)
35
+ except importlib.metadata.PackageNotFoundError:
36
+ return _UNKNOWN_VERSION
37
+
38
+
39
+ __version__ = _package_version()
40
+
41
+ __all__ = ["READ_SCHEMA_VERSION", "SCHEMA_VERSION", "__version__"]
@@ -0,0 +1,10 @@
1
+ {
2
+ "build_version": "0.1.0",
3
+ "schema_version": "1.0.0",
4
+ "source": {
5
+ "commit_sha": "4a6748b1ddee23755dae1f91647b90efcda0175f",
6
+ "tree_sha": "05932e8d28f87e64fcab400d39792ebcc0cc677f",
7
+ "working_tree_clean": true,
8
+ "working_tree_status_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
9
+ }
10
+ }
@@ -0,0 +1,6 @@
1
+ """Framework adapter package.
2
+
3
+ Adapters add framework and DSL-specific semantic facts on top of the language
4
+ frontends, including FastAPI, Django, MCP, ARQ, Celery, service containers,
5
+ SQLAlchemy, logging, Pydantic, pytest, and Typer/Click.
6
+ """
@@ -0,0 +1,369 @@
1
+ """ARQ worker extraction for ArcGraph."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+
7
+ from arcgraph.adapters.common import (
8
+ AdapterAnalysis,
9
+ SemanticAdapter,
10
+ call_name,
11
+ keyword,
12
+ literal_str,
13
+ module_node_id,
14
+ )
15
+ from arcgraph.core.ids import function_id, queue_id, worker_id
16
+ from arcgraph.core.schemas import Edge, Evidence, FactResolution, FileRecord, Node
17
+
18
+
19
+ class ARQAdapter(SemanticAdapter):
20
+ name = "arq"
21
+ capabilities = ("entrypoint:worker_task", "resource:queue", "edge:enqueues")
22
+ required_facts = ("function",)
23
+
24
+ def analyze(
25
+ self,
26
+ files: list[FileRecord],
27
+ parsed_files: dict[str, ast.Module],
28
+ nodes: list[Node],
29
+ module_names: set[str],
30
+ ) -> AdapterAnalysis:
31
+ del module_names
32
+ analysis = AdapterAnalysis()
33
+ concrete_node_ids = {node.id for node in nodes}
34
+ emitted_node_ids = set(concrete_node_ids)
35
+ worker_targets: dict[tuple[str, str], str] = {}
36
+ for file_record in files:
37
+ tree = parsed_files.get(file_record.path)
38
+ if tree is None:
39
+ continue
40
+ constants = self._string_constants(tree)
41
+ for stmt in tree.body:
42
+ if isinstance(stmt, ast.ClassDef) and stmt.name == "WorkerSettings":
43
+ self._analyze_worker_settings(
44
+ file_record,
45
+ stmt,
46
+ constants,
47
+ analysis,
48
+ worker_targets,
49
+ concrete_node_ids,
50
+ emitted_node_ids,
51
+ )
52
+
53
+ for file_record in files:
54
+ tree = parsed_files.get(file_record.path)
55
+ if tree is None:
56
+ continue
57
+ self._enqueue_edges(
58
+ file_record,
59
+ tree,
60
+ self._string_constants(tree),
61
+ worker_targets,
62
+ analysis,
63
+ emitted_node_ids,
64
+ )
65
+
66
+ analysis.nodes.sort(key=lambda node: node.id)
67
+ analysis.edges.sort(key=lambda edge: (edge.source, edge.target, edge.kind))
68
+ return analysis
69
+
70
+ def _analyze_worker_settings(
71
+ self,
72
+ file_record: FileRecord,
73
+ stmt: ast.ClassDef,
74
+ constants: dict[str, str],
75
+ analysis: AdapterAnalysis,
76
+ worker_targets: dict[tuple[str, str], str],
77
+ concrete_node_ids: set[str],
78
+ emitted_node_ids: set[str],
79
+ ) -> None:
80
+ queue_name = "arq:default"
81
+ functions: list[tuple[str, ast.AST, bool]] = []
82
+ for item in stmt.body:
83
+ if not isinstance(item, ast.Assign):
84
+ continue
85
+ target_names = [
86
+ target.id for target in item.targets if isinstance(target, ast.Name)
87
+ ]
88
+ if "queue_name" in target_names:
89
+ queue_name = self._string_value(item.value, constants) or queue_name
90
+ elif "functions" in target_names:
91
+ functions.extend(
92
+ (name, item, False) for name in self._function_names(item.value)
93
+ )
94
+ elif "cron_jobs" in target_names:
95
+ functions.extend(
96
+ (name, item, True) for name in self._cron_function_names(item.value)
97
+ )
98
+
99
+ queue_node_id = queue_id(queue_name)
100
+ analysis.nodes.append(
101
+ Node(
102
+ id=queue_node_id,
103
+ kind="queue",
104
+ name=queue_name,
105
+ qualname=queue_name,
106
+ path=file_record.path,
107
+ start_line=stmt.lineno,
108
+ end_line=getattr(stmt, "end_lineno", stmt.lineno),
109
+ properties={"backend": "arq"},
110
+ )
111
+ )
112
+ unique_functions: dict[str, tuple[ast.AST, bool]] = {}
113
+ for function_name, evidence_node, scheduled in functions:
114
+ existing = unique_functions.get(function_name)
115
+ if existing is None or (scheduled and not existing[1]):
116
+ unique_functions[function_name] = (evidence_node, scheduled)
117
+
118
+ for function_name, (evidence_node, scheduled) in unique_functions.items():
119
+ task_id = worker_id(f"{queue_name}:{function_name}")
120
+ handler_id = function_id(f"{file_record.module}.{function_name}")
121
+ worker_targets[(queue_name, function_name)] = task_id
122
+ handler_resolved = handler_id in concrete_node_ids
123
+ if not handler_resolved and handler_id not in emitted_node_ids:
124
+ analysis.nodes.append(
125
+ Node(
126
+ id=handler_id,
127
+ kind="function",
128
+ name=function_name.rsplit(".", 1)[-1],
129
+ qualname=f"{file_record.module}.{function_name}",
130
+ properties={
131
+ "adapter": self.name,
132
+ "external_reference": True,
133
+ "resolution_status": "unresolved",
134
+ },
135
+ )
136
+ )
137
+ emitted_node_ids.add(handler_id)
138
+ analysis.nodes.append(
139
+ Node(
140
+ id=task_id,
141
+ kind="worker_task",
142
+ name=function_name,
143
+ qualname=f"{queue_name}:{function_name}",
144
+ path=file_record.path,
145
+ start_line=getattr(evidence_node, "lineno", stmt.lineno),
146
+ end_line=getattr(evidence_node, "end_lineno", stmt.lineno),
147
+ properties={
148
+ "queue_name": queue_name,
149
+ "handler": handler_id,
150
+ "scheduled": scheduled,
151
+ },
152
+ )
153
+ )
154
+ analysis.edges.append(
155
+ self._edge(
156
+ task_id,
157
+ handler_id,
158
+ "invokes",
159
+ file_record,
160
+ evidence_node,
161
+ f"WorkerSettings.{'cron_jobs' if scheduled else 'functions'} -> {function_name}",
162
+ confidence="confirmed" if handler_resolved else "unresolved",
163
+ resolution=(
164
+ None
165
+ if handler_resolved
166
+ else FactResolution(
167
+ status="unresolved",
168
+ strategy="arq_worker_settings_literal",
169
+ candidate_count=0,
170
+ detail="worker handler is outside the indexed function registry",
171
+ )
172
+ ),
173
+ )
174
+ )
175
+ analysis.edges.append(
176
+ self._edge(
177
+ task_id,
178
+ queue_node_id,
179
+ "consumes",
180
+ file_record,
181
+ evidence_node,
182
+ f"WorkerSettings.queue_name = {queue_name}",
183
+ )
184
+ )
185
+
186
+ def _enqueue_edges(
187
+ self,
188
+ file_record: FileRecord,
189
+ tree: ast.Module,
190
+ constants: dict[str, str],
191
+ worker_targets: dict[tuple[str, str], str],
192
+ analysis: AdapterAnalysis,
193
+ node_ids: set[str],
194
+ ) -> None:
195
+ source_stack = self._function_line_index(file_record, tree)
196
+ for node in ast.walk(tree):
197
+ if not isinstance(node, ast.Call):
198
+ continue
199
+ name = call_name(node.func)
200
+ if not (
201
+ (name is not None and name.endswith(".enqueue_job"))
202
+ or (
203
+ isinstance(node.func, ast.Attribute)
204
+ and node.func.attr == "enqueue_job"
205
+ )
206
+ ):
207
+ continue
208
+ if not node.args:
209
+ continue
210
+ task_name = self._string_value(node.args[0], constants)
211
+ if not task_name:
212
+ continue
213
+ queue_name = self._string_value(keyword(node, "_queue_name"), constants)
214
+ target = worker_targets.get((queue_name, task_name)) if queue_name else None
215
+ resolved_target = target is not None
216
+ if target is None and queue_name:
217
+ target = worker_id(f"{queue_name}:{task_name}")
218
+ elif target is None:
219
+ matches = {
220
+ target_id
221
+ for (_, worker_name), target_id in worker_targets.items()
222
+ if worker_name == task_name
223
+ }
224
+ target = next(iter(matches)) if len(matches) == 1 else None
225
+ resolved_target = target is not None
226
+ if target is None:
227
+ target = worker_id(f"arq:external:{queue_name or '*'}:{task_name}")
228
+ if not resolved_target and target not in node_ids:
229
+ analysis.nodes.append(
230
+ Node(
231
+ id=target,
232
+ kind="worker_task",
233
+ name=task_name,
234
+ qualname=f"{queue_name or '*'}:{task_name}",
235
+ properties={
236
+ "adapter": self.name,
237
+ "external_reference": True,
238
+ "resolution_status": "unresolved",
239
+ "queue_name": queue_name,
240
+ },
241
+ )
242
+ )
243
+ node_ids.add(target)
244
+ source = self._source_for_line(source_stack, node.lineno)
245
+ source = source or module_node_id(file_record)
246
+ if source:
247
+ analysis.edges.append(
248
+ self._edge(
249
+ source,
250
+ target,
251
+ "enqueues",
252
+ file_record,
253
+ node,
254
+ f"enqueue_job({task_name})",
255
+ confidence="confirmed" if resolved_target else "unresolved",
256
+ resolution=(
257
+ None
258
+ if resolved_target
259
+ else FactResolution(
260
+ status="unresolved",
261
+ strategy="arq_enqueue_job_literal",
262
+ candidate_count=0,
263
+ detail=(
264
+ "literal task name is outside the indexed "
265
+ "ARQ worker registry"
266
+ ),
267
+ )
268
+ ),
269
+ )
270
+ )
271
+
272
+ @staticmethod
273
+ def _string_constants(tree: ast.Module) -> dict[str, str]:
274
+ constants: dict[str, str] = {}
275
+ for stmt in tree.body:
276
+ if not isinstance(stmt, ast.Assign):
277
+ continue
278
+ value = literal_str(stmt.value)
279
+ if value is None:
280
+ continue
281
+ for target in stmt.targets:
282
+ if isinstance(target, ast.Name):
283
+ constants[target.id] = value
284
+ return constants
285
+
286
+ def _string_value(
287
+ self, node: ast.AST | None, constants: dict[str, str]
288
+ ) -> str | None:
289
+ value = literal_str(node)
290
+ if value is not None:
291
+ return value
292
+ if isinstance(node, ast.Name):
293
+ return constants.get(node.id)
294
+ return None
295
+
296
+ @staticmethod
297
+ def _function_names(node: ast.AST) -> list[str]:
298
+ if not isinstance(node, (ast.List, ast.Tuple)):
299
+ return []
300
+ return [name for item in node.elts if (name := call_name(item))]
301
+
302
+ @staticmethod
303
+ def _cron_function_names(node: ast.AST) -> list[str]:
304
+ if not isinstance(node, (ast.List, ast.Tuple)):
305
+ return []
306
+ names: list[str] = []
307
+ for item in node.elts:
308
+ if (
309
+ isinstance(item, ast.Call)
310
+ and call_name(item.func) == "cron"
311
+ and item.args
312
+ ):
313
+ name = call_name(item.args[0])
314
+ if name:
315
+ names.append(name)
316
+ return names
317
+
318
+ @staticmethod
319
+ def _function_line_index(
320
+ file_record: FileRecord, tree: ast.Module
321
+ ) -> list[tuple[int, int, str]]:
322
+ ranges: list[tuple[int, int, str]] = []
323
+ for stmt in tree.body:
324
+ if isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef)):
325
+ ranges.append(
326
+ (
327
+ stmt.lineno,
328
+ getattr(stmt, "end_lineno", stmt.lineno),
329
+ function_id(f"{file_record.module}.{stmt.name}"),
330
+ )
331
+ )
332
+ return ranges
333
+
334
+ @staticmethod
335
+ def _source_for_line(ranges: list[tuple[int, int, str]], line: int) -> str | None:
336
+ for start, end, node_id in ranges:
337
+ if start <= line <= end:
338
+ return node_id
339
+ return None
340
+
341
+ @staticmethod
342
+ def _edge(
343
+ source: str,
344
+ target: str,
345
+ kind: str,
346
+ file_record: FileRecord,
347
+ node: ast.AST,
348
+ detail: str,
349
+ *,
350
+ confidence: str = "confirmed",
351
+ resolution: FactResolution | None = None,
352
+ ) -> Edge:
353
+ return Edge(
354
+ source=source,
355
+ target=target,
356
+ kind=kind,
357
+ confidence=confidence, # type: ignore[arg-type]
358
+ resolution=resolution or FactResolution(),
359
+ evidence=[
360
+ Evidence(
361
+ kind="arq_worker",
362
+ path=file_record.path,
363
+ start_line=getattr(node, "lineno", None),
364
+ end_line=getattr(node, "end_lineno", getattr(node, "lineno", None)),
365
+ column=getattr(node, "col_offset", None),
366
+ detail=detail,
367
+ )
368
+ ],
369
+ )