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.
- arcgraph/__init__.py +41 -0
- arcgraph/_build_provenance.json +10 -0
- arcgraph/adapters/__init__.py +6 -0
- arcgraph/adapters/arq_adapter.py +369 -0
- arcgraph/adapters/celery_adapter.py +440 -0
- arcgraph/adapters/common.py +436 -0
- arcgraph/adapters/django_adapter.py +765 -0
- arcgraph/adapters/fastapi_adapter.py +415 -0
- arcgraph/adapters/logging_adapter.py +335 -0
- arcgraph/adapters/mcp_adapter.py +100 -0
- arcgraph/adapters/pydantic_adapter.py +290 -0
- arcgraph/adapters/pytest_adapter.py +221 -0
- arcgraph/adapters/pytest_collection.py +96 -0
- arcgraph/adapters/pytest_fixtures.py +371 -0
- arcgraph/adapters/pytest_types.py +251 -0
- arcgraph/adapters/registry.py +34 -0
- arcgraph/adapters/service_container_adapter.py +548 -0
- arcgraph/adapters/sqlalchemy_adapter.py +446 -0
- arcgraph/adapters/typer_click_adapter.py +292 -0
- arcgraph/analyzers/__init__.py +1 -0
- arcgraph/analyzers/bindings.py +782 -0
- arcgraph/analyzers/calls/__init__.py +6 -0
- arcgraph/analyzers/calls/analyzer.py +2921 -0
- arcgraph/analyzers/calls/constants.py +702 -0
- arcgraph/analyzers/calls/context.py +298 -0
- arcgraph/analyzers/calls/lexical.py +237 -0
- arcgraph/analyzers/coverage.py +826 -0
- arcgraph/analyzers/exports.py +46 -0
- arcgraph/analyzers/har_trace.py +323 -0
- arcgraph/analyzers/imports.py +190 -0
- arcgraph/analyzers/otel_trace.py +352 -0
- arcgraph/analyzers/precise_references.py +1199 -0
- arcgraph/analyzers/python_scopes.py +56 -0
- arcgraph/analyzers/runtime_trace.py +516 -0
- arcgraph/analyzers/similarity.py +430 -0
- arcgraph/analyzers/symbols.py +363 -0
- arcgraph/analyzers/type_unions.py +89 -0
- arcgraph/analyzers/types.py +2293 -0
- arcgraph/assets/__init__.py +1 -0
- arcgraph/assets/workbench/__init__.py +1 -0
- arcgraph/assets/workbench/index.html +2398 -0
- arcgraph/assets/workbench/vendor/LICENSE.d3.txt +15 -0
- arcgraph/assets/workbench/vendor/d3.v7.min.js +2 -0
- arcgraph/change/__init__.py +10 -0
- arcgraph/change/baseline.py +145 -0
- arcgraph/change/changed_regions.py +728 -0
- arcgraph/change/contracts.py +927 -0
- arcgraph/change/errors.py +88 -0
- arcgraph/change/evidence.py +289 -0
- arcgraph/change/graph_delta.py +1422 -0
- arcgraph/change/identities.py +1277 -0
- arcgraph/change/paths.py +234 -0
- arcgraph/change/pins.py +426 -0
- arcgraph/change/planner.py +504 -0
- arcgraph/change/protected_surfaces.py +328 -0
- arcgraph/change/provider.py +46 -0
- arcgraph/change/service.py +1030 -0
- arcgraph/change/source_provider.py +311 -0
- arcgraph/change/store.py +1138 -0
- arcgraph/change/trusted_runners.py +154 -0
- arcgraph/change/verdicts.py +141 -0
- arcgraph/change/verification_plan.py +113 -0
- arcgraph/change/verifier.py +579 -0
- arcgraph/core/__init__.py +1 -0
- arcgraph/core/assurance.py +343 -0
- arcgraph/core/binding_metrics.py +75 -0
- arcgraph/core/cleanup.py +701 -0
- arcgraph/core/confidence_profiles.py +108 -0
- arcgraph/core/evidence_manifest.py +862 -0
- arcgraph/core/expression_kinds.py +11 -0
- arcgraph/core/force_graph_export.py +1125 -0
- arcgraph/core/freshness.py +94 -0
- arcgraph/core/graph_store.py +1099 -0
- arcgraph/core/ids.py +206 -0
- arcgraph/core/language_tiers.py +150 -0
- arcgraph/core/merge.py +649 -0
- arcgraph/core/metadata.py +30 -0
- arcgraph/core/operation_lock.py +218 -0
- arcgraph/core/passes.py +142 -0
- arcgraph/core/payload_policy.py +708 -0
- arcgraph/core/precise_references.py +124 -0
- arcgraph/core/query_engine.py +3078 -0
- arcgraph/core/recovery.py +18 -0
- arcgraph/core/scanner.py +1454 -0
- arcgraph/core/schemas.py +700 -0
- arcgraph/core/semantic.py +263 -0
- arcgraph/core/semantic_metrics.py +619 -0
- arcgraph/core/sharing_retry.py +84 -0
- arcgraph/core/structural.py +260 -0
- arcgraph/core/target_resolver.py +486 -0
- arcgraph/core/type_metrics.py +67 -0
- arcgraph/core/unresolved_classification.py +471 -0
- arcgraph/core/utils.py +100 -0
- arcgraph/core/visual_contract.py +240 -0
- arcgraph/core/visual_slice_engine.py +933 -0
- arcgraph/interfaces/__init__.py +1 -0
- arcgraph/interfaces/agent_capabilities.py +493 -0
- arcgraph/interfaces/agent_help.py +313 -0
- arcgraph/interfaces/benchmark.py +548 -0
- arcgraph/interfaces/ci.py +1992 -0
- arcgraph/interfaces/cli.py +2799 -0
- arcgraph/interfaces/cli_change.py +690 -0
- arcgraph/interfaces/cli_feedback.py +176 -0
- arcgraph/interfaces/cli_ops.py +69 -0
- arcgraph/interfaces/cli_support.py +113 -0
- arcgraph/interfaces/cli_trial.py +42 -0
- arcgraph/interfaces/cli_visual.py +601 -0
- arcgraph/interfaces/cli_workspace.py +300 -0
- arcgraph/interfaces/client_setup.py +481 -0
- arcgraph/interfaces/docs.py +1255 -0
- arcgraph/interfaces/human_output.py +409 -0
- arcgraph/interfaces/local_state.py +577 -0
- arcgraph/interfaces/mcp_server.py +457 -0
- arcgraph/interfaces/mcp_tools.py +1209 -0
- arcgraph/interfaces/metrics.py +571 -0
- arcgraph/interfaces/ops.py +330 -0
- arcgraph/interfaces/precision.py +1670 -0
- arcgraph/interfaces/project_probe.py +75 -0
- arcgraph/interfaces/reports.py +1121 -0
- arcgraph/interfaces/stdio_encoding.py +29 -0
- arcgraph/interfaces/trace.py +592 -0
- arcgraph/interfaces/trial_feedback.py +519 -0
- arcgraph/interfaces/trial_setup.py +535 -0
- arcgraph/interfaces/visual_server.py +354 -0
- arcgraph/interfaces/visual_slice.py +168 -0
- arcgraph/interfaces/visual_smoke.py +666 -0
- arcgraph/interfaces/workbench.py +576 -0
- arcgraph/interfaces/workspace.py +1513 -0
- arcgraph/pipeline/__init__.py +1 -0
- arcgraph/pipeline/contracts.py +122 -0
- arcgraph/pipeline/external_frontend.py +451 -0
- arcgraph/pipeline/external_l3_frontends.py +368 -0
- arcgraph/pipeline/external_protocol.py +358 -0
- arcgraph/pipeline/frontends.py +437 -0
- arcgraph/pipeline/indexer.py +661 -0
- arcgraph/pipeline/openapi_frontend.py +708 -0
- arcgraph/pipeline/python_frontend.py +298 -0
- arcgraph/pipeline/reindexer.py +1333 -0
- arcgraph/pipeline/scip_frontend.py +726 -0
- arcgraph/pipeline/typescript_extractor/declarations.mjs +183 -0
- arcgraph/pipeline/typescript_extractor/edges.mjs +2794 -0
- arcgraph/pipeline/typescript_extractor/graph_primitives.mjs +189 -0
- arcgraph/pipeline/typescript_extractor/module_resolution.mjs +877 -0
- arcgraph/pipeline/typescript_extractor/runtime.mjs +56 -0
- arcgraph/pipeline/typescript_extractor/similarity.mjs +619 -0
- arcgraph/pipeline/typescript_extractor/similarity_runner.mjs +19 -0
- arcgraph/pipeline/typescript_extractor/sources.mjs +23 -0
- arcgraph/pipeline/typescript_extractor.mjs +177 -0
- arcgraph/pipeline/typescript_frameworks.py +947 -0
- arcgraph/pipeline/typescript_frontend.py +656 -0
- arcgraph/pipeline/typescript_references.mjs +167 -0
- arcgraph/providers/__init__.py +27 -0
- arcgraph/providers/context_provider.py +1258 -0
- arcgraph/providers/flow_payload.py +117 -0
- arcgraph/providers/memory_connector.py +216 -0
- arcgraph/providers/orchestrator.py +475 -0
- arcgraph/providers/relationship_payload.py +102 -0
- arcgraph/providers/risk_provider.py +843 -0
- arcgraph/providers/why_provider.py +349 -0
- arcgraph/version_info.py +246 -0
- arcgraph-0.1.0.dist-info/METADATA +676 -0
- arcgraph-0.1.0.dist-info/RECORD +165 -0
- arcgraph-0.1.0.dist-info/WHEEL +4 -0
- arcgraph-0.1.0.dist-info/entry_points.txt +2 -0
- arcgraph-0.1.0.dist-info/licenses/LICENSE +203 -0
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
"""Celery task and queue 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
|
+
SemanticAdapter,
|
|
11
|
+
call_name,
|
|
12
|
+
import_aliases,
|
|
13
|
+
keyword,
|
|
14
|
+
literal_str,
|
|
15
|
+
module_node_id,
|
|
16
|
+
resolve_alias,
|
|
17
|
+
)
|
|
18
|
+
from arcgraph.core.ids import function_id, queue_id, worker_id
|
|
19
|
+
from arcgraph.core.schemas import Edge, Evidence, FactResolution, FileRecord, Node
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CeleryAdapter(SemanticAdapter):
|
|
23
|
+
"""Extract Celery tasks, queues, and enqueue edges."""
|
|
24
|
+
|
|
25
|
+
name = "celery"
|
|
26
|
+
capabilities = ("entrypoint:worker_task", "resource:queue", "edge:enqueues")
|
|
27
|
+
required_facts = ("function",)
|
|
28
|
+
|
|
29
|
+
_TASK_DECORATORS = {"task", "shared_task"}
|
|
30
|
+
_ENQUEUE_METHODS = {"delay", "apply_async", "s", "si", "signature"}
|
|
31
|
+
_SEND_FUNCTIONS = {"send_task"}
|
|
32
|
+
|
|
33
|
+
# ── detect ──────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
def detect(
|
|
36
|
+
self,
|
|
37
|
+
files: list[FileRecord],
|
|
38
|
+
parsed_files: dict[str, ast.Module],
|
|
39
|
+
nodes: list[Node],
|
|
40
|
+
module_names: set[str],
|
|
41
|
+
) -> bool:
|
|
42
|
+
del nodes, module_names
|
|
43
|
+
for file_record in files:
|
|
44
|
+
tree = parsed_files.get(file_record.path)
|
|
45
|
+
if tree is None:
|
|
46
|
+
continue
|
|
47
|
+
for stmt in tree.body:
|
|
48
|
+
if isinstance(stmt, ast.ImportFrom) and stmt.module:
|
|
49
|
+
if stmt.module.startswith("celery"):
|
|
50
|
+
return True
|
|
51
|
+
if isinstance(stmt, ast.Import):
|
|
52
|
+
if any(a.name.startswith("celery") for a in stmt.names):
|
|
53
|
+
return True
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
# ── analyze ─────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
def analyze(
|
|
59
|
+
self,
|
|
60
|
+
files: list[FileRecord],
|
|
61
|
+
parsed_files: dict[str, ast.Module],
|
|
62
|
+
nodes: list[Node],
|
|
63
|
+
module_names: set[str],
|
|
64
|
+
) -> AdapterAnalysis:
|
|
65
|
+
del nodes
|
|
66
|
+
analysis = AdapterAnalysis()
|
|
67
|
+
|
|
68
|
+
# Pass 1: find all Celery app instances
|
|
69
|
+
app_names: dict[str, set[str]] = {} # file path -> set of app variable names
|
|
70
|
+
for file_record in files:
|
|
71
|
+
tree = parsed_files.get(file_record.path)
|
|
72
|
+
if tree is None:
|
|
73
|
+
continue
|
|
74
|
+
app_names[file_record.path] = self._find_celery_apps(tree)
|
|
75
|
+
|
|
76
|
+
# Pass 2: extract @app.task / @shared_task decorated functions
|
|
77
|
+
task_registry: dict[str, str] = {} # task_name -> worker node id
|
|
78
|
+
for file_record in files:
|
|
79
|
+
tree = parsed_files.get(file_record.path)
|
|
80
|
+
if tree is None:
|
|
81
|
+
continue
|
|
82
|
+
imports = import_aliases(file_record, tree, module_names)
|
|
83
|
+
apps = app_names.get(file_record.path, set())
|
|
84
|
+
self._extract_tasks(
|
|
85
|
+
file_record,
|
|
86
|
+
tree,
|
|
87
|
+
imports,
|
|
88
|
+
apps,
|
|
89
|
+
task_registry,
|
|
90
|
+
analysis,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# Pass 3: find enqueue calls (task.delay(), task.apply_async(), send_task())
|
|
94
|
+
for file_record in files:
|
|
95
|
+
tree = parsed_files.get(file_record.path)
|
|
96
|
+
if tree is None:
|
|
97
|
+
continue
|
|
98
|
+
imports = import_aliases(file_record, tree, module_names)
|
|
99
|
+
apps = app_names.get(file_record.path, set())
|
|
100
|
+
self._extract_enqueue_edges(
|
|
101
|
+
file_record,
|
|
102
|
+
tree,
|
|
103
|
+
imports,
|
|
104
|
+
apps,
|
|
105
|
+
task_registry,
|
|
106
|
+
analysis,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
analysis.nodes.sort(key=lambda n: n.id)
|
|
110
|
+
analysis.edges.sort(key=lambda e: (e.source, e.target, e.kind))
|
|
111
|
+
return analysis
|
|
112
|
+
|
|
113
|
+
# ── celery apps ─────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
@staticmethod
|
|
116
|
+
def _find_celery_apps(tree: ast.Module) -> set[str]:
|
|
117
|
+
"""Find variable names assigned a Celery() instance."""
|
|
118
|
+
apps: set[str] = set()
|
|
119
|
+
for stmt in tree.body:
|
|
120
|
+
if not isinstance(stmt, ast.Assign):
|
|
121
|
+
continue
|
|
122
|
+
if not isinstance(stmt.value, ast.Call):
|
|
123
|
+
continue
|
|
124
|
+
func = call_name(stmt.value.func)
|
|
125
|
+
if func and func.rsplit(".", 1)[-1] == "Celery":
|
|
126
|
+
for target in stmt.targets:
|
|
127
|
+
if isinstance(target, ast.Name):
|
|
128
|
+
apps.add(target.id)
|
|
129
|
+
return apps
|
|
130
|
+
|
|
131
|
+
# ── task extraction ─────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
def _extract_tasks(
|
|
134
|
+
self,
|
|
135
|
+
file_record: FileRecord,
|
|
136
|
+
tree: ast.Module,
|
|
137
|
+
imports: ImportAliases,
|
|
138
|
+
apps: set[str],
|
|
139
|
+
task_registry: dict[str, str],
|
|
140
|
+
analysis: AdapterAnalysis,
|
|
141
|
+
) -> None:
|
|
142
|
+
for stmt in tree.body:
|
|
143
|
+
if not isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
144
|
+
continue
|
|
145
|
+
task_info = self._task_decorator_info(stmt, imports, apps)
|
|
146
|
+
if task_info is None:
|
|
147
|
+
continue
|
|
148
|
+
|
|
149
|
+
task_name, queue_name, bind, decorator_node = task_info
|
|
150
|
+
|
|
151
|
+
# Default task name follows Celery convention: module.function_name
|
|
152
|
+
if not task_name:
|
|
153
|
+
task_name = f"{file_record.module}.{stmt.name}"
|
|
154
|
+
|
|
155
|
+
# Default queue
|
|
156
|
+
if not queue_name:
|
|
157
|
+
queue_name = "celery"
|
|
158
|
+
|
|
159
|
+
q_id = queue_id(f"celery:{queue_name}")
|
|
160
|
+
w_id = worker_id(f"celery:{task_name}")
|
|
161
|
+
handler_id = function_id(f"{file_record.module}.{stmt.name}")
|
|
162
|
+
|
|
163
|
+
task_registry[task_name] = w_id
|
|
164
|
+
# Also register by short name for .delay() resolution
|
|
165
|
+
task_registry[stmt.name] = w_id
|
|
166
|
+
|
|
167
|
+
# Ensure queue node exists (dedup handled by AdapterRegistry)
|
|
168
|
+
analysis.nodes.append(
|
|
169
|
+
Node(
|
|
170
|
+
id=q_id,
|
|
171
|
+
kind="queue",
|
|
172
|
+
name=queue_name,
|
|
173
|
+
qualname=f"celery:{queue_name}",
|
|
174
|
+
path=file_record.path,
|
|
175
|
+
start_line=stmt.lineno,
|
|
176
|
+
end_line=getattr(stmt, "end_lineno", stmt.lineno),
|
|
177
|
+
properties={"backend": "celery"},
|
|
178
|
+
)
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
analysis.nodes.append(
|
|
182
|
+
Node(
|
|
183
|
+
id=w_id,
|
|
184
|
+
kind="worker_task",
|
|
185
|
+
name=stmt.name,
|
|
186
|
+
qualname=task_name,
|
|
187
|
+
path=file_record.path,
|
|
188
|
+
start_line=stmt.lineno,
|
|
189
|
+
end_line=getattr(stmt, "end_lineno", stmt.lineno),
|
|
190
|
+
properties={
|
|
191
|
+
"queue_name": queue_name,
|
|
192
|
+
"handler": handler_id,
|
|
193
|
+
"bind": bind,
|
|
194
|
+
"celery_task_name": task_name,
|
|
195
|
+
},
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
# worker_task -> handler function
|
|
200
|
+
analysis.edges.append(
|
|
201
|
+
self._edge(
|
|
202
|
+
w_id,
|
|
203
|
+
handler_id,
|
|
204
|
+
"invokes",
|
|
205
|
+
file_record,
|
|
206
|
+
decorator_node,
|
|
207
|
+
f"@task {task_name} -> {stmt.name}",
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
# worker_task -> queue
|
|
211
|
+
analysis.edges.append(
|
|
212
|
+
self._edge(
|
|
213
|
+
w_id,
|
|
214
|
+
q_id,
|
|
215
|
+
"consumes",
|
|
216
|
+
file_record,
|
|
217
|
+
decorator_node,
|
|
218
|
+
f"task {task_name} consumes queue {queue_name}",
|
|
219
|
+
)
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
def _task_decorator_info(
|
|
223
|
+
self,
|
|
224
|
+
stmt: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
225
|
+
imports: ImportAliases,
|
|
226
|
+
apps: set[str],
|
|
227
|
+
) -> tuple[str | None, str | None, bool, ast.AST] | None:
|
|
228
|
+
"""Check if function has @app.task or @shared_task. Returns (name, queue, bind, node)."""
|
|
229
|
+
for decorator in stmt.decorator_list:
|
|
230
|
+
# @shared_task or @app.task (no-call form)
|
|
231
|
+
if isinstance(decorator, ast.Name):
|
|
232
|
+
resolved = resolve_alias(decorator.id, imports) or decorator.id
|
|
233
|
+
short = resolved.rsplit(".", 1)[-1]
|
|
234
|
+
if short in self._TASK_DECORATORS:
|
|
235
|
+
return None, None, False, decorator
|
|
236
|
+
|
|
237
|
+
# @app.task or @shared_task (as attribute, no call)
|
|
238
|
+
if isinstance(decorator, ast.Attribute):
|
|
239
|
+
if (
|
|
240
|
+
isinstance(decorator.value, ast.Name)
|
|
241
|
+
and decorator.value.id in apps
|
|
242
|
+
and decorator.attr == "task"
|
|
243
|
+
):
|
|
244
|
+
return None, None, False, decorator
|
|
245
|
+
|
|
246
|
+
# @shared_task(...) or @app.task(...)
|
|
247
|
+
if isinstance(decorator, ast.Call):
|
|
248
|
+
func = decorator.func
|
|
249
|
+
is_task = False
|
|
250
|
+
|
|
251
|
+
if isinstance(func, ast.Name):
|
|
252
|
+
resolved = resolve_alias(func.id, imports) or func.id
|
|
253
|
+
short = resolved.rsplit(".", 1)[-1]
|
|
254
|
+
is_task = short in self._TASK_DECORATORS
|
|
255
|
+
elif isinstance(func, ast.Attribute):
|
|
256
|
+
if (
|
|
257
|
+
isinstance(func.value, ast.Name)
|
|
258
|
+
and func.value.id in apps
|
|
259
|
+
and func.attr == "task"
|
|
260
|
+
):
|
|
261
|
+
is_task = True
|
|
262
|
+
elif func.attr == "shared_task":
|
|
263
|
+
is_task = True
|
|
264
|
+
|
|
265
|
+
if is_task:
|
|
266
|
+
task_name = literal_str(keyword(decorator, "name"))
|
|
267
|
+
queue_name = literal_str(keyword(decorator, "queue"))
|
|
268
|
+
bind = self._bool_keyword(decorator, "bind")
|
|
269
|
+
return task_name, queue_name, bind, decorator
|
|
270
|
+
|
|
271
|
+
return None
|
|
272
|
+
|
|
273
|
+
# ── enqueue edges ───────────────────────────────────────
|
|
274
|
+
|
|
275
|
+
def _extract_enqueue_edges(
|
|
276
|
+
self,
|
|
277
|
+
file_record: FileRecord,
|
|
278
|
+
tree: ast.Module,
|
|
279
|
+
imports: ImportAliases,
|
|
280
|
+
apps: set[str],
|
|
281
|
+
task_registry: dict[str, str],
|
|
282
|
+
analysis: AdapterAnalysis,
|
|
283
|
+
) -> None:
|
|
284
|
+
source_ranges = self._function_line_index(file_record, tree)
|
|
285
|
+
|
|
286
|
+
for node in ast.walk(tree):
|
|
287
|
+
if not isinstance(node, ast.Call):
|
|
288
|
+
continue
|
|
289
|
+
|
|
290
|
+
target_id: str | None = None
|
|
291
|
+
unresolved_external_task = False
|
|
292
|
+
|
|
293
|
+
# Pattern 1: task_func.delay(...) / task_func.apply_async(...)
|
|
294
|
+
if isinstance(node.func, ast.Attribute):
|
|
295
|
+
method = node.func.attr
|
|
296
|
+
if method in self._ENQUEUE_METHODS:
|
|
297
|
+
task_ref = call_name(node.func.value)
|
|
298
|
+
if task_ref:
|
|
299
|
+
target_id = task_registry.get(task_ref)
|
|
300
|
+
if target_id is None:
|
|
301
|
+
# Try resolving through imports
|
|
302
|
+
resolved = resolve_alias(task_ref, imports)
|
|
303
|
+
if resolved:
|
|
304
|
+
target_id = task_registry.get(resolved)
|
|
305
|
+
|
|
306
|
+
# Pattern 2: app.send_task("task.name", ...)
|
|
307
|
+
if target_id is None:
|
|
308
|
+
func = call_name(node.func)
|
|
309
|
+
else:
|
|
310
|
+
func = None
|
|
311
|
+
if func and node.args:
|
|
312
|
+
short = func.rsplit(".", 1)[-1]
|
|
313
|
+
if short in self._SEND_FUNCTIONS:
|
|
314
|
+
task_name = literal_str(node.args[0])
|
|
315
|
+
if task_name:
|
|
316
|
+
target_id = task_registry.get(task_name)
|
|
317
|
+
if target_id is None:
|
|
318
|
+
# Preserve the literal external reference without
|
|
319
|
+
# claiming that ArcGraph resolved its implementation.
|
|
320
|
+
target_id = worker_id(f"celery:{task_name}")
|
|
321
|
+
unresolved_external_task = True
|
|
322
|
+
if not any(
|
|
323
|
+
candidate.id == target_id
|
|
324
|
+
for candidate in analysis.nodes
|
|
325
|
+
):
|
|
326
|
+
analysis.nodes.append(
|
|
327
|
+
Node(
|
|
328
|
+
id=target_id,
|
|
329
|
+
kind="worker_task",
|
|
330
|
+
name=task_name,
|
|
331
|
+
qualname=task_name,
|
|
332
|
+
properties={
|
|
333
|
+
"adapter": self.name,
|
|
334
|
+
"external_reference": True,
|
|
335
|
+
"resolution_status": "unresolved",
|
|
336
|
+
"task_name": task_name,
|
|
337
|
+
},
|
|
338
|
+
)
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
if target_id is None:
|
|
342
|
+
continue
|
|
343
|
+
|
|
344
|
+
source = self._source_for_line(source_ranges, node.lineno)
|
|
345
|
+
if not source:
|
|
346
|
+
source = module_node_id(file_record)
|
|
347
|
+
|
|
348
|
+
analysis.edges.append(
|
|
349
|
+
self._edge(
|
|
350
|
+
source,
|
|
351
|
+
target_id,
|
|
352
|
+
"enqueues",
|
|
353
|
+
file_record,
|
|
354
|
+
node,
|
|
355
|
+
f"enqueue {call_name(node.func) or 'task'}",
|
|
356
|
+
confidence=(
|
|
357
|
+
"unresolved" if unresolved_external_task else "confirmed"
|
|
358
|
+
),
|
|
359
|
+
resolution=(
|
|
360
|
+
FactResolution(
|
|
361
|
+
status="unresolved",
|
|
362
|
+
strategy="celery_send_task_literal",
|
|
363
|
+
candidate_count=0,
|
|
364
|
+
detail="literal task name is outside the indexed registry",
|
|
365
|
+
)
|
|
366
|
+
if unresolved_external_task
|
|
367
|
+
else None
|
|
368
|
+
),
|
|
369
|
+
)
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
# ── helpers ──────────────────────────────────────────────
|
|
373
|
+
|
|
374
|
+
@staticmethod
|
|
375
|
+
def _bool_keyword(call: ast.Call, name: str) -> bool:
|
|
376
|
+
for kw in call.keywords:
|
|
377
|
+
if kw.arg == name and isinstance(kw.value, ast.Constant):
|
|
378
|
+
return bool(kw.value.value)
|
|
379
|
+
return False
|
|
380
|
+
|
|
381
|
+
@staticmethod
|
|
382
|
+
def _function_line_index(
|
|
383
|
+
file_record: FileRecord,
|
|
384
|
+
tree: ast.Module,
|
|
385
|
+
) -> list[tuple[int, int, str]]:
|
|
386
|
+
ranges: list[tuple[int, int, str]] = []
|
|
387
|
+
for stmt in tree.body:
|
|
388
|
+
if isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
389
|
+
ranges.append(
|
|
390
|
+
(
|
|
391
|
+
stmt.lineno,
|
|
392
|
+
getattr(stmt, "end_lineno", stmt.lineno),
|
|
393
|
+
function_id(f"{file_record.module}.{stmt.name}"),
|
|
394
|
+
)
|
|
395
|
+
)
|
|
396
|
+
return ranges
|
|
397
|
+
|
|
398
|
+
@staticmethod
|
|
399
|
+
def _source_for_line(
|
|
400
|
+
ranges: list[tuple[int, int, str]],
|
|
401
|
+
line: int,
|
|
402
|
+
) -> str | None:
|
|
403
|
+
for start, end, node_id in ranges:
|
|
404
|
+
if start <= line <= end:
|
|
405
|
+
return node_id
|
|
406
|
+
return None
|
|
407
|
+
|
|
408
|
+
@staticmethod
|
|
409
|
+
def _edge(
|
|
410
|
+
source: str,
|
|
411
|
+
target: str,
|
|
412
|
+
kind: str,
|
|
413
|
+
file_record: FileRecord,
|
|
414
|
+
node: ast.AST,
|
|
415
|
+
detail: str,
|
|
416
|
+
*,
|
|
417
|
+
confidence: str = "confirmed",
|
|
418
|
+
resolution: FactResolution | None = None,
|
|
419
|
+
) -> Edge:
|
|
420
|
+
return Edge(
|
|
421
|
+
source=source,
|
|
422
|
+
target=target,
|
|
423
|
+
kind=kind,
|
|
424
|
+
confidence=confidence,
|
|
425
|
+
resolution=resolution or FactResolution(),
|
|
426
|
+
evidence=[
|
|
427
|
+
Evidence(
|
|
428
|
+
kind="celery_task",
|
|
429
|
+
path=file_record.path,
|
|
430
|
+
start_line=getattr(node, "lineno", None),
|
|
431
|
+
end_line=getattr(
|
|
432
|
+
node,
|
|
433
|
+
"end_lineno",
|
|
434
|
+
getattr(node, "lineno", None),
|
|
435
|
+
),
|
|
436
|
+
column=getattr(node, "col_offset", None),
|
|
437
|
+
detail=detail,
|
|
438
|
+
)
|
|
439
|
+
],
|
|
440
|
+
)
|