codegraph-engine 2.2.1__py3-none-any.whl → 3.0.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 (82) hide show
  1. codegraph/__init__.py +1 -1
  2. codegraph/agent_brain.py +172 -2
  3. codegraph/agent_capabilities.py +564 -6
  4. codegraph/agent_rules.py +1 -1
  5. codegraph/api_drift.py +455 -0
  6. codegraph/async_queue/__init__.py +50 -0
  7. codegraph/async_queue/detectors/__init__.py +48 -0
  8. codegraph/async_queue/detectors/celery.py +160 -0
  9. codegraph/async_queue/detectors/kafka.py +168 -0
  10. codegraph/async_queue/detectors/rabbitmq.py +116 -0
  11. codegraph/async_queue/detectors/redis.py +130 -0
  12. codegraph/async_queue/detectors/sqs.py +135 -0
  13. codegraph/async_queue/models.py +161 -0
  14. codegraph/async_queue/otel_models.py +74 -0
  15. codegraph/async_queue/otel_parser.py +337 -0
  16. codegraph/async_queue/reconciler.py +427 -0
  17. codegraph/async_queue/scanner.py +291 -0
  18. codegraph/async_queue/traversal.py +447 -0
  19. codegraph/branch_comparison.py +197 -0
  20. codegraph/change_impact.py +547 -0
  21. codegraph/cli.py +992 -3
  22. codegraph/cli_output.py +17 -2
  23. codegraph/context.py +38 -11
  24. codegraph/context_freshness.py +351 -0
  25. codegraph/database/__init__.py +14 -0
  26. codegraph/database/extractor.py +712 -17
  27. codegraph/database/interrogation.py +388 -2
  28. codegraph/database/schema_drift.py +862 -0
  29. codegraph/epistemic.py +7 -0
  30. codegraph/evidence_contract.py +21 -0
  31. codegraph/frameworks.py +171 -6
  32. codegraph/frameworks_nestjs.py +502 -0
  33. codegraph/frameworks_nextjs.py +337 -0
  34. codegraph/frameworks_react.py +457 -0
  35. codegraph/freshness.py +2 -0
  36. codegraph/frontend_html.py +109 -0
  37. codegraph/git.py +2 -0
  38. codegraph/git_state.py +545 -0
  39. codegraph/indexing/__init__.py +7 -1
  40. codegraph/indexing/indexer.py +291 -38
  41. codegraph/indexing/models.py +6 -0
  42. codegraph/indexing/parser.py +239 -0
  43. codegraph/indexing/pool.py +136 -0
  44. codegraph/indexing/scanner.py +6 -0
  45. codegraph/indexing/templates.py +418 -0
  46. codegraph/interrogation.py +128 -20
  47. codegraph/mcp/server.py +258 -27
  48. codegraph/models.py +7 -0
  49. codegraph/monorepo.py +3 -0
  50. codegraph/optimizer.py +42 -5
  51. codegraph/process_lifecycle.py +31 -13
  52. codegraph/refactor/__init__.py +36 -0
  53. codegraph/refactor/models.py +114 -0
  54. codegraph/refactor/renamer.py +844 -0
  55. codegraph/refactor/transactions.py +403 -0
  56. codegraph/resolver.py +1202 -159
  57. codegraph/retrieval_policy.py +6 -0
  58. codegraph/route_topology.py +172 -0
  59. codegraph/runtime/__init__.py +19 -0
  60. codegraph/runtime/collector.py +155 -0
  61. codegraph/runtime/db_watcher.py +191 -0
  62. codegraph/runtime/ingestor.py +30 -10
  63. codegraph/runtime/port_inspector.py +304 -0
  64. codegraph/runtime/runner.py +155 -6
  65. codegraph/runtime/trace_linker.py +175 -0
  66. codegraph/search/hybrid.py +26 -0
  67. codegraph/semantic_conflicts.py +270 -0
  68. codegraph/semantic_decorators.py +34 -3
  69. codegraph/structural_diff.py +542 -0
  70. codegraph/styles.py +144 -0
  71. codegraph/symbol_history.py +277 -0
  72. codegraph/tsconfig.py +236 -0
  73. codegraph/ui/__init__.py +6 -0
  74. codegraph/ui/server.py +715 -0
  75. codegraph/watcher.py +323 -0
  76. {codegraph_engine-2.2.1.dist-info → codegraph_engine-3.0.0.dist-info}/METADATA +257 -39
  77. codegraph_engine-3.0.0.dist-info/RECORD +129 -0
  78. codegraph_engine-2.2.1.dist-info/RECORD +0 -87
  79. {codegraph_engine-2.2.1.dist-info → codegraph_engine-3.0.0.dist-info}/WHEEL +0 -0
  80. {codegraph_engine-2.2.1.dist-info → codegraph_engine-3.0.0.dist-info}/entry_points.txt +0 -0
  81. {codegraph_engine-2.2.1.dist-info → codegraph_engine-3.0.0.dist-info}/licenses/LICENSE +0 -0
  82. {codegraph_engine-2.2.1.dist-info → codegraph_engine-3.0.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,160 @@
1
+ """Celery asynchronous task and canvas detector.
2
+
3
+ Detects:
4
+ - Workers / Consumers: @app.task, @celery.task, @shared_task, @celery_app.task
5
+ - Publishers / Producers: task.delay(), task.apply_async(), app.send_task()
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import ast
10
+ from typing import Any
11
+
12
+ from codegraph.async_queue.models import (
13
+ AsyncConsumerRecord,
14
+ AsyncMessagingSystem,
15
+ AsyncProducerRecord,
16
+ )
17
+
18
+ _CELERY_TASK_DECORATORS = {
19
+ "task",
20
+ "shared_task",
21
+ }
22
+
23
+
24
+ def detect_celery(
25
+ tree: ast.AST,
26
+ content: str,
27
+ file_path: str,
28
+ module_name: str,
29
+ ) -> tuple[list[AsyncProducerRecord], list[AsyncConsumerRecord]]:
30
+ producers: list[AsyncProducerRecord] = []
31
+ consumers: list[AsyncConsumerRecord] = []
32
+ lines = content.splitlines()
33
+
34
+ def get_line_snippet(lineno: int) -> str:
35
+ if 1 <= lineno <= len(lines):
36
+ return lines[lineno - 1].strip()
37
+ return ""
38
+
39
+ # Map of function names that are Celery tasks in this file
40
+ local_tasks: dict[str, str] = {} # fn_name -> task_name
41
+
42
+ # 1. Detect consumers (task definitions)
43
+ for node in ast.walk(tree):
44
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
45
+ for dec in node.decorator_list:
46
+ dec_func = dec.func if isinstance(dec, ast.Call) else dec
47
+ dec_id = getattr(dec_func, "id", "")
48
+ dec_attr = getattr(dec_func, "attr", "")
49
+
50
+ if dec_id in _CELERY_TASK_DECORATORS or dec_attr in _CELERY_TASK_DECORATORS:
51
+ # Found a Celery task definition
52
+ task_name = f"{module_name}.{node.name}"
53
+ metadata: dict[str, Any] = {}
54
+
55
+ if isinstance(dec, ast.Call):
56
+ for kw in dec.keywords:
57
+ if kw.arg == "name" and isinstance(kw.value, ast.Constant) and isinstance(kw.value.value, str):
58
+ task_name = kw.value.value
59
+ elif kw.arg == "queue" and isinstance(kw.value, ast.Constant) and isinstance(kw.value.value, str):
60
+ metadata["queue"] = kw.value.value
61
+ elif kw.arg == "max_retries" and isinstance(kw.value, ast.Constant):
62
+ metadata["max_retries"] = kw.value.value
63
+ elif kw.arg == "autoretry_for":
64
+ metadata["autoretry"] = True
65
+ elif kw.arg == "retry_backoff":
66
+ metadata["retry_backoff"] = True
67
+
68
+ local_tasks[node.name] = task_name
69
+ canonical_id = f"{module_name}.{node.name}"
70
+ queue_id = f"celery:{task_name}"
71
+
72
+ consumers.append(
73
+ AsyncConsumerRecord(
74
+ system=AsyncMessagingSystem.CELERY,
75
+ destination=task_name,
76
+ queue_id=queue_id,
77
+ handler_canonical_id=canonical_id,
78
+ file_path=file_path,
79
+ line=node.lineno,
80
+ handler_snippet=get_line_snippet(node.lineno),
81
+ confidence="HIGH",
82
+ metadata=metadata,
83
+ )
84
+ )
85
+
86
+ # 2. Detect producers (task invocations: .delay, .apply_async, app.send_task)
87
+ class CallVisitor(ast.NodeVisitor):
88
+ def __init__(self) -> None:
89
+ self.current_function: str = f"{module_name}.<module>"
90
+
91
+ def visit_FunctionDef(self, fn: ast.FunctionDef) -> None:
92
+ prev = self.current_function
93
+ self.current_function = f"{module_name}.{fn.name}"
94
+ self.generic_visit(fn)
95
+ self.current_function = prev
96
+
97
+ def visit_AsyncFunctionDef(self, fn: ast.AsyncFunctionDef) -> None:
98
+ prev = self.current_function
99
+ self.current_function = f"{module_name}.{fn.name}"
100
+ self.generic_visit(fn)
101
+ self.current_function = prev
102
+
103
+ def visit_Call(self, node: ast.Call) -> None:
104
+ if isinstance(node.func, ast.Attribute):
105
+ method_name = node.func.attr
106
+ # Case A: task.delay(...) or task.apply_async(...)
107
+ if method_name in ("delay", "apply_async"):
108
+ caller_val = node.func.value
109
+ task_target_name = ""
110
+ if isinstance(caller_val, ast.Name):
111
+ task_target_name = caller_val.id
112
+ elif isinstance(caller_val, ast.Attribute):
113
+ task_target_name = caller_val.attr
114
+
115
+ if task_target_name:
116
+ resolved_dest = local_tasks.get(task_target_name, task_target_name)
117
+ meta: dict[str, Any] = {"method": method_name}
118
+ if method_name == "apply_async":
119
+ for kw in node.keywords:
120
+ if kw.arg in ("countdown", "eta", "queue", "retry"):
121
+ meta[kw.arg] = True
122
+
123
+ producers.append(
124
+ AsyncProducerRecord(
125
+ system=AsyncMessagingSystem.CELERY,
126
+ destination=resolved_dest,
127
+ queue_id=f"celery:{resolved_dest}",
128
+ caller_canonical_id=self.current_function,
129
+ file_path=file_path,
130
+ line=node.lineno,
131
+ call_snippet=get_line_snippet(node.lineno),
132
+ confidence="HIGH",
133
+ metadata=meta,
134
+ )
135
+ )
136
+
137
+ # Case B: app.send_task("task_name", ...)
138
+ elif method_name == "send_task" and node.args:
139
+ first_arg = node.args[0]
140
+ if isinstance(first_arg, ast.Constant) and isinstance(first_arg.value, str):
141
+ dest = first_arg.value
142
+ producers.append(
143
+ AsyncProducerRecord(
144
+ system=AsyncMessagingSystem.CELERY,
145
+ destination=dest,
146
+ queue_id=f"celery:{dest}",
147
+ caller_canonical_id=self.current_function,
148
+ file_path=file_path,
149
+ line=node.lineno,
150
+ call_snippet=get_line_snippet(node.lineno),
151
+ confidence="HIGH",
152
+ metadata={"method": "send_task"},
153
+ )
154
+ )
155
+
156
+ self.generic_visit(node)
157
+
158
+ CallVisitor().visit(tree)
159
+
160
+ return producers, consumers
@@ -0,0 +1,168 @@
1
+ """Apache Kafka producer and consumer detector.
2
+
3
+ Detects:
4
+ - Publishers / Producers: producer.send(topic, ...), producer.produce(topic, ...)
5
+ - Consumers / Workers: @consumer(topic), @subscriber(topic), KafkaConsumer(topic), consumer.subscribe([topic])
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import ast
10
+ from typing import Any
11
+
12
+ from codegraph.async_queue.models import (
13
+ AsyncConsumerRecord,
14
+ AsyncMessagingSystem,
15
+ AsyncProducerRecord,
16
+ )
17
+
18
+ _KAFKA_CONSUMER_DECORATORS = {
19
+ "consumer",
20
+ "kafka_consumer",
21
+ "subscriber",
22
+ "consume",
23
+ "agent",
24
+ }
25
+
26
+
27
+ def detect_kafka(
28
+ tree: ast.AST,
29
+ content: str,
30
+ file_path: str,
31
+ module_name: str,
32
+ ) -> tuple[list[AsyncProducerRecord], list[AsyncConsumerRecord]]:
33
+ producers: list[AsyncProducerRecord] = []
34
+ consumers: list[AsyncConsumerRecord] = []
35
+ lines = content.splitlines()
36
+
37
+ def get_line_snippet(lineno: int) -> str:
38
+ if 1 <= lineno <= len(lines):
39
+ return lines[lineno - 1].strip()
40
+ return ""
41
+
42
+ # 1. Detect consumers via decorators or subscriber calls
43
+ for node in ast.walk(tree):
44
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
45
+ for dec in node.decorator_list:
46
+ dec_func = dec.func if isinstance(dec, ast.Call) else dec
47
+ dec_name = getattr(dec_func, "id", "") or getattr(dec_func, "attr", "")
48
+
49
+ if dec_name.lower() in _KAFKA_CONSUMER_DECORATORS:
50
+ topic = ""
51
+ if isinstance(dec, ast.Call):
52
+ if dec.args and isinstance(dec.args[0], ast.Constant) and isinstance(dec.args[0].value, str):
53
+ topic = dec.args[0].value
54
+ for kw in dec.keywords:
55
+ if kw.arg in ("topic", "topics") and isinstance(kw.value, ast.Constant) and isinstance(kw.value.value, str):
56
+ topic = kw.value.value
57
+
58
+ if topic:
59
+ consumers.append(
60
+ AsyncConsumerRecord(
61
+ system=AsyncMessagingSystem.KAFKA,
62
+ destination=topic,
63
+ queue_id=f"kafka:{topic}",
64
+ handler_canonical_id=f"{module_name}.{node.name}",
65
+ file_path=file_path,
66
+ line=node.lineno,
67
+ handler_snippet=get_line_snippet(node.lineno),
68
+ confidence="HIGH",
69
+ metadata={"decorator": dec_name},
70
+ )
71
+ )
72
+
73
+ # 2. Detect producers & programmatic consumers inside function scopes
74
+ class KafkaCallVisitor(ast.NodeVisitor):
75
+ def __init__(self) -> None:
76
+ self.current_function: str = f"{module_name}.<module>"
77
+
78
+ def visit_FunctionDef(self, fn: ast.FunctionDef) -> None:
79
+ prev = self.current_function
80
+ self.current_function = f"{module_name}.{fn.name}"
81
+ self.generic_visit(fn)
82
+ self.current_function = prev
83
+
84
+ def visit_AsyncFunctionDef(self, fn: ast.AsyncFunctionDef) -> None:
85
+ prev = self.current_function
86
+ self.current_function = f"{module_name}.{fn.name}"
87
+ self.generic_visit(fn)
88
+ self.current_function = prev
89
+
90
+ def visit_Call(self, node: ast.Call) -> None:
91
+ # Check for producer.send(topic, ...) or producer.produce(topic, ...)
92
+ if isinstance(node.func, ast.Attribute) and node.func.attr in ("send", "produce"):
93
+ topic = ""
94
+ meta: dict[str, Any] = {"method": node.func.attr}
95
+ if node.args and isinstance(node.args[0], ast.Constant) and isinstance(node.args[0].value, str):
96
+ topic = node.args[0].value
97
+ for kw in node.keywords:
98
+ if kw.arg == "topic" and isinstance(kw.value, ast.Constant) and isinstance(kw.value.value, str):
99
+ topic = kw.value.value
100
+ elif kw.arg in ("key", "partition", "headers"):
101
+ meta[kw.arg] = True
102
+
103
+ if topic:
104
+ producers.append(
105
+ AsyncProducerRecord(
106
+ system=AsyncMessagingSystem.KAFKA,
107
+ destination=topic,
108
+ queue_id=f"kafka:{topic}",
109
+ caller_canonical_id=self.current_function,
110
+ file_path=file_path,
111
+ line=node.lineno,
112
+ call_snippet=get_line_snippet(node.lineno),
113
+ confidence="HIGH",
114
+ metadata=meta,
115
+ )
116
+ )
117
+
118
+ # Check for consumer.subscribe([topic, ...])
119
+ elif isinstance(node.func, ast.Attribute) and node.func.attr == "subscribe":
120
+ topic_list: list[str] = []
121
+ if node.args and isinstance(node.args[0], (ast.List, ast.Tuple)):
122
+ for elt in node.args[0].elts:
123
+ if isinstance(elt, ast.Constant) and isinstance(elt.value, str):
124
+ topic_list.append(elt.value)
125
+ for kw in node.keywords:
126
+ if kw.arg == "topics" and isinstance(kw.value, (ast.List, ast.Tuple)):
127
+ for elt in kw.value.elts:
128
+ if isinstance(elt, ast.Constant) and isinstance(elt.value, str):
129
+ topic_list.append(elt.value)
130
+
131
+ for t in topic_list:
132
+ consumers.append(
133
+ AsyncConsumerRecord(
134
+ system=AsyncMessagingSystem.KAFKA,
135
+ destination=t,
136
+ queue_id=f"kafka:{t}",
137
+ handler_canonical_id=self.current_function,
138
+ file_path=file_path,
139
+ line=node.lineno,
140
+ handler_snippet=get_line_snippet(node.lineno),
141
+ confidence="HIGH",
142
+ metadata={"method": "subscribe"},
143
+ )
144
+ )
145
+
146
+ # Check for KafkaConsumer(topic, ...)
147
+ elif isinstance(node.func, ast.Name) and node.func.id == "KafkaConsumer":
148
+ if node.args and isinstance(node.args[0], ast.Constant) and isinstance(node.args[0].value, str):
149
+ t = node.args[0].value
150
+ consumers.append(
151
+ AsyncConsumerRecord(
152
+ system=AsyncMessagingSystem.KAFKA,
153
+ destination=t,
154
+ queue_id=f"kafka:{t}",
155
+ handler_canonical_id=self.current_function,
156
+ file_path=file_path,
157
+ line=node.lineno,
158
+ handler_snippet=get_line_snippet(node.lineno),
159
+ confidence="HIGH",
160
+ metadata={"method": "KafkaConsumer"},
161
+ )
162
+ )
163
+
164
+ self.generic_visit(node)
165
+
166
+ KafkaCallVisitor().visit(tree)
167
+
168
+ return producers, consumers
@@ -0,0 +1,116 @@
1
+ """RabbitMQ / AMQP / Pika message producer and consumer detector.
2
+
3
+ Detects:
4
+ - Publishers / Producers: channel.basic_publish(exchange=..., routing_key=...)
5
+ - Consumers / Workers: channel.basic_consume(queue=..., on_message_callback=...)
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import ast
10
+ from typing import Any
11
+
12
+ from codegraph.async_queue.models import (
13
+ AsyncConsumerRecord,
14
+ AsyncMessagingSystem,
15
+ AsyncProducerRecord,
16
+ )
17
+
18
+
19
+ def detect_rabbitmq(
20
+ tree: ast.AST,
21
+ content: str,
22
+ file_path: str,
23
+ module_name: str,
24
+ ) -> tuple[list[AsyncProducerRecord], list[AsyncConsumerRecord]]:
25
+ producers: list[AsyncProducerRecord] = []
26
+ consumers: list[AsyncConsumerRecord] = []
27
+ lines = content.splitlines()
28
+
29
+ def get_line_snippet(lineno: int) -> str:
30
+ if 1 <= lineno <= len(lines):
31
+ return lines[lineno - 1].strip()
32
+ return ""
33
+
34
+ class RabbitMQVisitor(ast.NodeVisitor):
35
+ def __init__(self) -> None:
36
+ self.current_function: str = f"{module_name}.<module>"
37
+
38
+ def visit_FunctionDef(self, fn: ast.FunctionDef) -> None:
39
+ prev = self.current_function
40
+ self.current_function = f"{module_name}.{fn.name}"
41
+ self.generic_visit(fn)
42
+ self.current_function = prev
43
+
44
+ def visit_AsyncFunctionDef(self, fn: ast.AsyncFunctionDef) -> None:
45
+ prev = self.current_function
46
+ self.current_function = f"{module_name}.{fn.name}"
47
+ self.generic_visit(fn)
48
+ self.current_function = prev
49
+
50
+ def visit_Call(self, node: ast.Call) -> None:
51
+ if isinstance(node.func, ast.Attribute):
52
+ attr = node.func.attr
53
+ # Producer: basic_publish
54
+ if attr in ("basic_publish", "publish"):
55
+ routing_key = ""
56
+ exchange = ""
57
+ meta: dict[str, Any] = {"method": attr}
58
+ for kw in node.keywords:
59
+ if kw.arg == "routing_key" and isinstance(kw.value, ast.Constant) and isinstance(kw.value.value, str):
60
+ routing_key = kw.value.value
61
+ elif kw.arg == "exchange" and isinstance(kw.value, ast.Constant) and isinstance(kw.value.value, str):
62
+ exchange = kw.value.value
63
+
64
+ dest = routing_key or exchange
65
+ if dest:
66
+ if exchange:
67
+ meta["exchange"] = exchange
68
+ if routing_key:
69
+ meta["routing_key"] = routing_key
70
+
71
+ producers.append(
72
+ AsyncProducerRecord(
73
+ system=AsyncMessagingSystem.RABBITMQ,
74
+ destination=dest,
75
+ queue_id=f"rabbitmq:{dest}",
76
+ caller_canonical_id=self.current_function,
77
+ file_path=file_path,
78
+ line=node.lineno,
79
+ call_snippet=get_line_snippet(node.lineno),
80
+ confidence="HIGH",
81
+ metadata=meta,
82
+ )
83
+ )
84
+
85
+ # Consumer: basic_consume
86
+ elif attr in ("basic_consume", "consume"):
87
+ queue_name = ""
88
+ callback_fn_name = ""
89
+ for kw in node.keywords:
90
+ if kw.arg == "queue" and isinstance(kw.value, ast.Constant) and isinstance(kw.value.value, str):
91
+ queue_name = kw.value.value
92
+ elif kw.arg == "on_message_callback":
93
+ if isinstance(kw.value, ast.Name):
94
+ callback_fn_name = kw.value.id
95
+
96
+ if queue_name:
97
+ handler_id = f"{module_name}.{callback_fn_name}" if callback_fn_name else self.current_function
98
+ consumers.append(
99
+ AsyncConsumerRecord(
100
+ system=AsyncMessagingSystem.RABBITMQ,
101
+ destination=queue_name,
102
+ queue_id=f"rabbitmq:{queue_name}",
103
+ handler_canonical_id=handler_id,
104
+ file_path=file_path,
105
+ line=node.lineno,
106
+ handler_snippet=get_line_snippet(node.lineno),
107
+ confidence="HIGH",
108
+ metadata={"callback": callback_fn_name} if callback_fn_name else {},
109
+ )
110
+ )
111
+
112
+ self.generic_visit(node)
113
+
114
+ RabbitMQVisitor().visit(tree)
115
+
116
+ return producers, consumers
@@ -0,0 +1,130 @@
1
+ """Redis Pub/Sub and Streams async message detector.
2
+
3
+ Detects:
4
+ - Publishers / Producers: r.publish(channel, message), r.xadd(stream, fields)
5
+ - Consumers / Workers: pubsub.subscribe(channel), r.xread({stream: id})
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import ast
10
+ from typing import Any
11
+
12
+ from codegraph.async_queue.models import (
13
+ AsyncConsumerRecord,
14
+ AsyncMessagingSystem,
15
+ AsyncProducerRecord,
16
+ )
17
+
18
+
19
+ def detect_redis(
20
+ tree: ast.AST,
21
+ content: str,
22
+ file_path: str,
23
+ module_name: str,
24
+ ) -> tuple[list[AsyncProducerRecord], list[AsyncConsumerRecord]]:
25
+ producers: list[AsyncProducerRecord] = []
26
+ consumers: list[AsyncConsumerRecord] = []
27
+ lines = content.splitlines()
28
+
29
+ def get_line_snippet(lineno: int) -> str:
30
+ if 1 <= lineno <= len(lines):
31
+ return lines[lineno - 1].strip()
32
+ return ""
33
+
34
+ class RedisVisitor(ast.NodeVisitor):
35
+ def __init__(self) -> None:
36
+ self.current_function: str = f"{module_name}.<module>"
37
+
38
+ def visit_FunctionDef(self, fn: ast.FunctionDef) -> None:
39
+ prev = self.current_function
40
+ self.current_function = f"{module_name}.{fn.name}"
41
+ self.generic_visit(fn)
42
+ self.current_function = prev
43
+
44
+ def visit_AsyncFunctionDef(self, fn: ast.AsyncFunctionDef) -> None:
45
+ prev = self.current_function
46
+ self.current_function = f"{module_name}.{fn.name}"
47
+ self.generic_visit(fn)
48
+ self.current_function = prev
49
+
50
+ def visit_Call(self, node: ast.Call) -> None:
51
+ if isinstance(node.func, ast.Attribute):
52
+ attr = node.func.attr
53
+ # Producer: r.publish("channel", ...), r.xadd("stream", ...)
54
+ if attr in ("publish", "xadd"):
55
+ channel_or_stream = ""
56
+ if node.args and isinstance(node.args[0], ast.Constant) and isinstance(node.args[0].value, str):
57
+ channel_or_stream = node.args[0].value
58
+ for kw in node.keywords:
59
+ if kw.arg in ("channel", "name") and isinstance(kw.value, ast.Constant) and isinstance(kw.value.value, str):
60
+ channel_or_stream = kw.value.value
61
+
62
+ if channel_or_stream:
63
+ meta: dict[str, Any] = {"method": attr, "type": "stream" if attr == "xadd" else "pubsub"}
64
+ producers.append(
65
+ AsyncProducerRecord(
66
+ system=AsyncMessagingSystem.REDIS,
67
+ destination=channel_or_stream,
68
+ queue_id=f"redis:{channel_or_stream}",
69
+ caller_canonical_id=self.current_function,
70
+ file_path=file_path,
71
+ line=node.lineno,
72
+ call_snippet=get_line_snippet(node.lineno),
73
+ confidence="HIGH",
74
+ metadata=meta,
75
+ )
76
+ )
77
+
78
+ # Consumer: pubsub.subscribe("channel"), r.xread({"stream": ...})
79
+ elif attr == "subscribe":
80
+ channels: list[str] = []
81
+ for arg in node.args:
82
+ if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
83
+ channels.append(arg.value)
84
+ elif isinstance(arg, (ast.List, ast.Tuple)):
85
+ for elt in arg.elts:
86
+ if isinstance(elt, ast.Constant) and isinstance(elt.value, str):
87
+ channels.append(elt.value)
88
+
89
+ for ch in channels:
90
+ consumers.append(
91
+ AsyncConsumerRecord(
92
+ system=AsyncMessagingSystem.REDIS,
93
+ destination=ch,
94
+ queue_id=f"redis:{ch}",
95
+ handler_canonical_id=self.current_function,
96
+ file_path=file_path,
97
+ line=node.lineno,
98
+ handler_snippet=get_line_snippet(node.lineno),
99
+ confidence="HIGH",
100
+ metadata={"type": "pubsub"},
101
+ )
102
+ )
103
+
104
+ elif attr == "xread":
105
+ streams: list[str] = []
106
+ if node.args and isinstance(node.args[0], ast.Dict):
107
+ for k in node.args[0].keys:
108
+ if isinstance(k, ast.Constant) and isinstance(k.value, str):
109
+ streams.append(k.value)
110
+
111
+ for st in streams:
112
+ consumers.append(
113
+ AsyncConsumerRecord(
114
+ system=AsyncMessagingSystem.REDIS,
115
+ destination=st,
116
+ queue_id=f"redis:{st}",
117
+ handler_canonical_id=self.current_function,
118
+ file_path=file_path,
119
+ line=node.lineno,
120
+ handler_snippet=get_line_snippet(node.lineno),
121
+ confidence="HIGH",
122
+ metadata={"type": "stream"},
123
+ )
124
+ )
125
+
126
+ self.generic_visit(node)
127
+
128
+ RedisVisitor().visit(tree)
129
+
130
+ return producers, consumers