agent-runtime-map 0.8.0

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.
@@ -0,0 +1,219 @@
1
+ """Extracts structural facts from Python sources for Agent Runtime Map.
2
+
3
+ This parses with the standard library `ast` module and never executes the code it
4
+ reads: `ast.parse` builds a tree, it does not import or run anything. It also makes
5
+ no product judgements. It reports what is written — definitions, calls, imports,
6
+ decorators, string constants — and the TypeScript side decides what any of it means,
7
+ so the classification rules stay in one language rather than drifting across two.
8
+
9
+ Input: a JSON array of absolute file paths on stdin.
10
+ Output: a JSON object of facts on stdout.
11
+ """
12
+
13
+ import ast
14
+ import json
15
+ import sys
16
+
17
+
18
+ def literal(node):
19
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
20
+ return node.value
21
+ if isinstance(node, ast.JoinedStr):
22
+ parts = [p.value for p in node.values if isinstance(p, ast.Constant) and isinstance(p.value, str)]
23
+ return "".join(parts) if parts else None
24
+ return None
25
+
26
+
27
+ def dotted_name(node):
28
+ """`a.b.c` and `a.b.c()` both reduce to the text a reader would recognise."""
29
+ if isinstance(node, ast.Name):
30
+ return node.id
31
+ if isinstance(node, ast.Attribute):
32
+ base = dotted_name(node.value)
33
+ return f"{base}.{node.attr}" if base else node.attr
34
+ if isinstance(node, ast.Call):
35
+ return dotted_name(node.func)
36
+ return None
37
+
38
+
39
+ def keyword_map(call):
40
+ """Named arguments, which is how every Agent framework configures a construct."""
41
+ out = {}
42
+ for kw in call.keywords:
43
+ if kw.arg is None:
44
+ continue
45
+ text = literal(kw.value)
46
+ if text is not None:
47
+ out[kw.arg] = {"kind": "string", "value": text[:4000]}
48
+ else:
49
+ names = [n for n in (dotted_name(e) for e in flatten(kw.value)) if n]
50
+ out[kw.arg] = {"kind": "names", "value": names[:24]}
51
+ return out
52
+
53
+
54
+ def flatten(node):
55
+ if isinstance(node, (ast.List, ast.Tuple, ast.Set)):
56
+ return list(node.elts)
57
+ if isinstance(node, ast.Dict):
58
+ return [v for v in node.values]
59
+ return [node]
60
+
61
+
62
+ def end_line(node):
63
+ return getattr(node, "end_lineno", None) or node.lineno
64
+
65
+
66
+ class FileVisitor(ast.NodeVisitor):
67
+ def __init__(self, path):
68
+ self.path = path
69
+ self.functions = []
70
+ self.classes = []
71
+ self.assignments = []
72
+ self.imports = []
73
+ self.calls = []
74
+ self._scope = []
75
+
76
+ # A scope stack is what lets a call be attributed to the definition it sits in.
77
+ def _push(self, name, kind, node, enclosing_class):
78
+ decorators = [d for d in (dotted_name(x) for x in node.decorator_list) if d]
79
+ record = {
80
+ "name": name,
81
+ "kind": kind,
82
+ "line": node.lineno,
83
+ "endLine": end_line(node),
84
+ "enclosingClass": enclosing_class,
85
+ "decorators": decorators,
86
+ "isAsync": isinstance(node, ast.AsyncFunctionDef),
87
+ "docstring": (ast.get_docstring(node) or "")[:2000],
88
+ "parameters": [a.arg for a in node.args.args],
89
+ "returns": dotted_name(node.returns) if node.returns else None,
90
+ "branches": 0,
91
+ "loops": 0,
92
+ "catches": 0,
93
+ }
94
+ for child in ast.walk(node):
95
+ if isinstance(child, (ast.If, ast.IfExp, ast.Match)):
96
+ record["branches"] += 1
97
+ elif isinstance(child, (ast.For, ast.AsyncFor, ast.While)):
98
+ record["loops"] += 1
99
+ elif isinstance(child, ast.ExceptHandler):
100
+ record["catches"] += 1
101
+ self.functions.append(record)
102
+ return record
103
+
104
+ def _visit_function(self, node):
105
+ enclosing = self._scope[-1]["name"] if self._scope and self._scope[-1]["kind"] == "class" else None
106
+ record = self._push(node.name, "function", node, enclosing)
107
+ self._scope.append({"name": node.name, "kind": "function", "record": record})
108
+ self.generic_visit(node)
109
+ self._scope.pop()
110
+
111
+ visit_FunctionDef = _visit_function
112
+ visit_AsyncFunctionDef = _visit_function
113
+
114
+ def visit_ClassDef(self, node):
115
+ self.classes.append({
116
+ "name": node.name,
117
+ "line": node.lineno,
118
+ "endLine": end_line(node),
119
+ "bases": [b for b in (dotted_name(x) for x in node.bases) if b],
120
+ "decorators": [d for d in (dotted_name(x) for x in node.decorator_list) if d],
121
+ "docstring": (ast.get_docstring(node) or "")[:2000],
122
+ })
123
+ self._scope.append({"name": node.name, "kind": "class", "record": None})
124
+ self.generic_visit(node)
125
+ self._scope.pop()
126
+
127
+ def visit_Assign(self, node):
128
+ if len(node.targets) == 1 and isinstance(node.targets[0], ast.Name):
129
+ self._record_assignment(node.targets[0].id, node.value, node)
130
+ self.generic_visit(node)
131
+
132
+ def visit_AnnAssign(self, node):
133
+ if isinstance(node.target, ast.Name) and node.value is not None:
134
+ self._record_assignment(node.target.id, node.value, node)
135
+ self.generic_visit(node)
136
+
137
+ def _record_assignment(self, name, value, node):
138
+ text = literal(value)
139
+ call = value if isinstance(value, ast.Call) else None
140
+ if isinstance(value, ast.Await) and isinstance(value.value, ast.Call):
141
+ call = value.value
142
+ self.assignments.append({
143
+ "name": name,
144
+ "line": node.lineno,
145
+ "endLine": end_line(node),
146
+ "scope": self._scope[-1]["name"] if self._scope else None,
147
+ "text": text[:4000] if text else None,
148
+ "factory": dotted_name(call.func) if call else None,
149
+ "options": keyword_map(call) if call else {},
150
+ "elements": [n for n in (dotted_name(e) for e in flatten(value)) if n] if isinstance(value, (ast.List, ast.Tuple)) else [],
151
+ })
152
+
153
+ def visit_Import(self, node):
154
+ for alias in node.names:
155
+ self.imports.append({"module": alias.name, "name": None, "alias": alias.asname, "line": node.lineno})
156
+ self.generic_visit(node)
157
+
158
+ def visit_ImportFrom(self, node):
159
+ for alias in node.names:
160
+ self.imports.append({
161
+ "module": node.module or "",
162
+ "name": alias.name,
163
+ "alias": alias.asname,
164
+ "level": node.level,
165
+ "line": node.lineno,
166
+ })
167
+ self.generic_visit(node)
168
+
169
+ def visit_Call(self, node):
170
+ callee = dotted_name(node.func)
171
+ if callee:
172
+ enclosing = None
173
+ for frame in reversed(self._scope):
174
+ if frame["kind"] == "function":
175
+ enclosing = frame["name"]
176
+ break
177
+ self.calls.append({
178
+ "callee": callee,
179
+ "line": node.lineno,
180
+ "enclosingFunction": enclosing,
181
+ "enclosingClass": next((f["name"] for f in reversed(self._scope) if f["kind"] == "class"), None),
182
+ "stringArguments": [a for a in (literal(x) for x in node.args) if a is not None][:4],
183
+ "nameArguments": [n for n in (dotted_name(a) for a in node.args) if n][:8],
184
+ "options": keyword_map(node),
185
+ })
186
+ self.generic_visit(node)
187
+
188
+
189
+ def analyze(path):
190
+ try:
191
+ with open(path, "r", encoding="utf-8", errors="replace") as handle:
192
+ source = handle.read()
193
+ except OSError as error:
194
+ return {"path": path, "error": f"unreadable: {error}"}
195
+ try:
196
+ tree = ast.parse(source, filename=path)
197
+ except SyntaxError as error:
198
+ # A file the interpreter itself cannot parse is reported, not guessed at.
199
+ return {"path": path, "error": f"syntax error on line {error.lineno}"}
200
+ visitor = FileVisitor(path)
201
+ visitor.visit(tree)
202
+ return {
203
+ "path": path,
204
+ "docstring": (ast.get_docstring(tree) or "")[:2000],
205
+ "functions": visitor.functions,
206
+ "classes": visitor.classes,
207
+ "assignments": visitor.assignments,
208
+ "imports": visitor.imports,
209
+ "calls": visitor.calls,
210
+ }
211
+
212
+
213
+ def main():
214
+ paths = json.load(sys.stdin)
215
+ json.dump({"pythonVersion": sys.version.split()[0], "files": [analyze(p) for p in paths]}, sys.stdout)
216
+
217
+
218
+ if __name__ == "__main__":
219
+ main()