agentforge-graph 0.3.2__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.
- agentforge_graph/__init__.py +6 -0
- agentforge_graph/chunking/__init__.py +12 -0
- agentforge_graph/chunking/cast.py +159 -0
- agentforge_graph/chunking/chunk.py +19 -0
- agentforge_graph/chunking/tokens.py +15 -0
- agentforge_graph/cli.py +607 -0
- agentforge_graph/config.py +259 -0
- agentforge_graph/core/__init__.py +54 -0
- agentforge_graph/core/conformance.py +270 -0
- agentforge_graph/core/contracts.py +163 -0
- agentforge_graph/core/kinds.py +68 -0
- agentforge_graph/core/models.py +134 -0
- agentforge_graph/core/provenance.py +62 -0
- agentforge_graph/core/symbols.py +116 -0
- agentforge_graph/embed/__init__.py +28 -0
- agentforge_graph/embed/base.py +22 -0
- agentforge_graph/embed/bedrock.py +85 -0
- agentforge_graph/embed/fake.py +34 -0
- agentforge_graph/embed/openai.py +67 -0
- agentforge_graph/embed/pipeline.py +184 -0
- agentforge_graph/embed/registry.py +66 -0
- agentforge_graph/embed/report.py +15 -0
- agentforge_graph/enrich/__init__.py +70 -0
- agentforge_graph/enrich/anthropic.py +38 -0
- agentforge_graph/enrich/anthropic_client.py +109 -0
- agentforge_graph/enrich/bedrock.py +24 -0
- agentforge_graph/enrich/bedrock_client.py +115 -0
- agentforge_graph/enrich/bedrock_summarizer.py +23 -0
- agentforge_graph/enrich/claude.py +172 -0
- agentforge_graph/enrich/enricher.py +108 -0
- agentforge_graph/enrich/governs.py +173 -0
- agentforge_graph/enrich/governs_enricher.py +152 -0
- agentforge_graph/enrich/heuristics.py +224 -0
- agentforge_graph/enrich/judge.py +63 -0
- agentforge_graph/enrich/registry.py +133 -0
- agentforge_graph/enrich/report.py +60 -0
- agentforge_graph/enrich/summarizer.py +62 -0
- agentforge_graph/enrich/summary_enricher.py +211 -0
- agentforge_graph/enrich/taxonomy.py +38 -0
- agentforge_graph/frameworks/__init__.py +29 -0
- agentforge_graph/frameworks/base.py +75 -0
- agentforge_graph/frameworks/detect.py +124 -0
- agentforge_graph/frameworks/extractor.py +63 -0
- agentforge_graph/frameworks/orm.py +93 -0
- agentforge_graph/frameworks/packs/_js_ast.py +56 -0
- agentforge_graph/frameworks/packs/_python_ast.py +157 -0
- agentforge_graph/frameworks/packs/django/__init__.py +240 -0
- agentforge_graph/frameworks/packs/django/models.scm +7 -0
- agentforge_graph/frameworks/packs/express/__init__.py +133 -0
- agentforge_graph/frameworks/packs/express/routes.scm +8 -0
- agentforge_graph/frameworks/packs/fastapi/__init__.py +210 -0
- agentforge_graph/frameworks/packs/fastapi/depends.scm +6 -0
- agentforge_graph/frameworks/packs/fastapi/routes.scm +10 -0
- agentforge_graph/frameworks/packs/flask/__init__.py +143 -0
- agentforge_graph/frameworks/packs/flask/routes.scm +11 -0
- agentforge_graph/frameworks/packs/nestjs/__init__.py +205 -0
- agentforge_graph/frameworks/packs/nestjs/routes.scm +6 -0
- agentforge_graph/frameworks/packs/spring/__init__.py +267 -0
- agentforge_graph/frameworks/packs/spring/routes.scm +6 -0
- agentforge_graph/frameworks/packs/sqlalchemy/__init__.py +250 -0
- agentforge_graph/frameworks/packs/sqlalchemy/models.scm +7 -0
- agentforge_graph/frameworks/registry.py +44 -0
- agentforge_graph/ingest/__init__.py +30 -0
- agentforge_graph/ingest/codegraph.py +847 -0
- agentforge_graph/ingest/extractor.py +353 -0
- agentforge_graph/ingest/incremental/__init__.py +25 -0
- agentforge_graph/ingest/incremental/detect.py +118 -0
- agentforge_graph/ingest/incremental/dirty.py +61 -0
- agentforge_graph/ingest/incremental/indexer.py +218 -0
- agentforge_graph/ingest/incremental/meta.py +72 -0
- agentforge_graph/ingest/incremental/ports.py +39 -0
- agentforge_graph/ingest/pack.py +160 -0
- agentforge_graph/ingest/packs/__init__.py +34 -0
- agentforge_graph/ingest/packs/cpp/__init__.py +35 -0
- agentforge_graph/ingest/packs/cpp/references.scm +15 -0
- agentforge_graph/ingest/packs/cpp/structure.scm +49 -0
- agentforge_graph/ingest/packs/csharp/__init__.py +35 -0
- agentforge_graph/ingest/packs/csharp/references.scm +12 -0
- agentforge_graph/ingest/packs/csharp/structure.scm +45 -0
- agentforge_graph/ingest/packs/go/__init__.py +38 -0
- agentforge_graph/ingest/packs/go/references.scm +12 -0
- agentforge_graph/ingest/packs/go/structure.scm +64 -0
- agentforge_graph/ingest/packs/java/__init__.py +35 -0
- agentforge_graph/ingest/packs/java/references.scm +12 -0
- agentforge_graph/ingest/packs/java/structure.scm +38 -0
- agentforge_graph/ingest/packs/javascript/__init__.py +34 -0
- agentforge_graph/ingest/packs/javascript/references.scm +11 -0
- agentforge_graph/ingest/packs/javascript/structure.scm +166 -0
- agentforge_graph/ingest/packs/php/__init__.py +35 -0
- agentforge_graph/ingest/packs/php/references.scm +15 -0
- agentforge_graph/ingest/packs/php/structure.scm +44 -0
- agentforge_graph/ingest/packs/python/__init__.py +25 -0
- agentforge_graph/ingest/packs/python/references.scm +14 -0
- agentforge_graph/ingest/packs/python/structure.scm +57 -0
- agentforge_graph/ingest/packs/ruby/__init__.py +37 -0
- agentforge_graph/ingest/packs/ruby/references.scm +12 -0
- agentforge_graph/ingest/packs/ruby/structure.scm +37 -0
- agentforge_graph/ingest/packs/rust/__init__.py +39 -0
- agentforge_graph/ingest/packs/rust/references.scm +12 -0
- agentforge_graph/ingest/packs/rust/structure.scm +46 -0
- agentforge_graph/ingest/packs/typescript/__init__.py +31 -0
- agentforge_graph/ingest/packs/typescript/references.scm +11 -0
- agentforge_graph/ingest/packs/typescript/structure.scm +99 -0
- agentforge_graph/ingest/pipeline.py +134 -0
- agentforge_graph/ingest/report.py +84 -0
- agentforge_graph/ingest/resolver.py +467 -0
- agentforge_graph/ingest/source.py +79 -0
- agentforge_graph/knowledge/__init__.py +28 -0
- agentforge_graph/knowledge/adr.py +136 -0
- agentforge_graph/knowledge/commits.py +152 -0
- agentforge_graph/knowledge/ingest.py +312 -0
- agentforge_graph/knowledge/mentions.py +71 -0
- agentforge_graph/knowledge/report.py +32 -0
- agentforge_graph/main.py +21 -0
- agentforge_graph/providers.py +36 -0
- agentforge_graph/repomap/__init__.py +14 -0
- agentforge_graph/repomap/rank.py +161 -0
- agentforge_graph/repomap/render.py +55 -0
- agentforge_graph/repomap/repomap.py +66 -0
- agentforge_graph/retrieve/__init__.py +21 -0
- agentforge_graph/retrieve/pack.py +76 -0
- agentforge_graph/retrieve/rerank.py +251 -0
- agentforge_graph/retrieve/retriever.py +286 -0
- agentforge_graph/retrieve/scoring.py +36 -0
- agentforge_graph/serve/__init__.py +19 -0
- agentforge_graph/serve/engine.py +204 -0
- agentforge_graph/serve/http_runner.py +133 -0
- agentforge_graph/serve/server.py +110 -0
- agentforge_graph/serve/tools.py +307 -0
- agentforge_graph/store/__init__.py +32 -0
- agentforge_graph/store/_rowmap.py +102 -0
- agentforge_graph/store/errors.py +22 -0
- agentforge_graph/store/facade.py +89 -0
- agentforge_graph/store/kuzu_store.py +380 -0
- agentforge_graph/store/lance_store.py +146 -0
- agentforge_graph/store/neo4j_store.py +294 -0
- agentforge_graph/store/pgvector_store.py +170 -0
- agentforge_graph/store/registry.py +45 -0
- agentforge_graph/temporal/__init__.py +36 -0
- agentforge_graph/temporal/backfill.py +338 -0
- agentforge_graph/temporal/events.py +82 -0
- agentforge_graph/temporal/index.py +190 -0
- agentforge_graph/temporal/mining.py +190 -0
- agentforge_graph/temporal/recorder.py +114 -0
- agentforge_graph/temporal/store.py +282 -0
- agentforge_graph-0.3.2.dist-info/METADATA +291 -0
- agentforge_graph-0.3.2.dist-info/RECORD +151 -0
- agentforge_graph-0.3.2.dist-info/WHEEL +4 -0
- agentforge_graph-0.3.2.dist-info/entry_points.txt +3 -0
- agentforge_graph-0.3.2.dist-info/licenses/LICENSE +202 -0
- agentforge_graph-0.3.2.dist-info/licenses/NOTICE +14 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Spring framework pack (feat-011) — MVC controller routes (Java).
|
|
2
|
+
|
|
3
|
+
Extracts Spring web endpoints into ``Route`` nodes + ``HANDLED_BY`` edges to the
|
|
4
|
+
handler method. A class is a route source only when it is a controller
|
|
5
|
+
(``@RestController`` / ``@Controller``, or carries a class-level
|
|
6
|
+
``@RequestMapping``) — so a plain Java class never mints routes (ADR-0004). Each
|
|
7
|
+
method annotated with ``@GetMapping`` / ``@PostMapping`` / … (or
|
|
8
|
+
``@RequestMapping(method=RequestMethod.X)``) becomes a ``Route`` whose path is
|
|
9
|
+
the class-level base path joined with the method path, and whose handler is the
|
|
10
|
+
``Class#method`` symbol. Intra-file (annotation + method share a file).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from functools import cache
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from tree_sitter import Language, Parser, Query, QueryCursor
|
|
19
|
+
from tree_sitter import Node as TSNode
|
|
20
|
+
from tree_sitter_language_pack import get_language
|
|
21
|
+
|
|
22
|
+
from agentforge_graph.core import (
|
|
23
|
+
Descriptor,
|
|
24
|
+
Edge,
|
|
25
|
+
EdgeKind,
|
|
26
|
+
NodeKind,
|
|
27
|
+
Provenance,
|
|
28
|
+
SourceFile,
|
|
29
|
+
SymbolID,
|
|
30
|
+
)
|
|
31
|
+
from agentforge_graph.core import Node as GraphNode
|
|
32
|
+
from agentforge_graph.frameworks.base import FrameworkFacts, FrameworkPack
|
|
33
|
+
|
|
34
|
+
_HERE = Path(__file__).parent
|
|
35
|
+
# mapping annotation -> HTTP method
|
|
36
|
+
_MAPPING = {
|
|
37
|
+
"GetMapping": "GET",
|
|
38
|
+
"PostMapping": "POST",
|
|
39
|
+
"PutMapping": "PUT",
|
|
40
|
+
"DeleteMapping": "DELETE",
|
|
41
|
+
"PatchMapping": "PATCH",
|
|
42
|
+
}
|
|
43
|
+
_CONTROLLER = {"RestController", "Controller"}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@cache
|
|
47
|
+
def _language() -> Language:
|
|
48
|
+
return get_language("java")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@cache
|
|
52
|
+
def _query_text() -> str:
|
|
53
|
+
return (_HERE / "routes.scm").read_text(encoding="utf-8")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _text(node: TSNode, src: bytes) -> str:
|
|
57
|
+
return src[node.start_byte : node.end_byte].decode("utf-8", errors="replace")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _string(node: TSNode, src: bytes) -> str:
|
|
61
|
+
s = _text(node, src)
|
|
62
|
+
if len(s) >= 2 and s[0] in "\"'" and s[-1] == s[0]:
|
|
63
|
+
return s[1:-1]
|
|
64
|
+
return s
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _annotations(node: TSNode) -> list[TSNode]:
|
|
68
|
+
"""The annotation/marker_annotation nodes on a class or method — directly or
|
|
69
|
+
inside its ``modifiers`` child."""
|
|
70
|
+
out: list[TSNode] = []
|
|
71
|
+
for c in node.named_children:
|
|
72
|
+
if c.type in ("annotation", "marker_annotation"):
|
|
73
|
+
out.append(c)
|
|
74
|
+
elif c.type == "modifiers":
|
|
75
|
+
out.extend(m for m in c.named_children if m.type in ("annotation", "marker_annotation"))
|
|
76
|
+
return out
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _anno_name(anno: TSNode, src: bytes) -> str:
|
|
80
|
+
name = anno.child_by_field_name("name")
|
|
81
|
+
return _text(name, src) if name is not None else ""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _anno_path(anno: TSNode, src: bytes) -> str:
|
|
85
|
+
"""The path string of a mapping annotation: a positional
|
|
86
|
+
``@GetMapping("/x")`` or a ``value=``/``path=`` element; "" when absent."""
|
|
87
|
+
args = anno.child_by_field_name("arguments")
|
|
88
|
+
if args is None:
|
|
89
|
+
return ""
|
|
90
|
+
for arg in args.named_children:
|
|
91
|
+
if arg.type == "string_literal":
|
|
92
|
+
return _string(arg, src)
|
|
93
|
+
if arg.type == "element_value_pair":
|
|
94
|
+
key = arg.child_by_field_name("key")
|
|
95
|
+
value = arg.child_by_field_name("value")
|
|
96
|
+
if (
|
|
97
|
+
key is not None
|
|
98
|
+
and _text(key, src) in ("value", "path")
|
|
99
|
+
and value is not None
|
|
100
|
+
and value.type == "string_literal"
|
|
101
|
+
):
|
|
102
|
+
return _string(value, src)
|
|
103
|
+
return ""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _request_method(anno: TSNode, src: bytes) -> str:
|
|
107
|
+
"""The HTTP verb from ``@RequestMapping(method=RequestMethod.X)`` (the tail of
|
|
108
|
+
the field access), or "ALL" when unspecified (Spring matches any method)."""
|
|
109
|
+
args = anno.child_by_field_name("arguments")
|
|
110
|
+
if args is None:
|
|
111
|
+
return "ALL"
|
|
112
|
+
for arg in args.named_children:
|
|
113
|
+
if arg.type != "element_value_pair":
|
|
114
|
+
continue
|
|
115
|
+
key = arg.child_by_field_name("key")
|
|
116
|
+
value = arg.child_by_field_name("value")
|
|
117
|
+
if key is None or _text(key, src) != "method" or value is None:
|
|
118
|
+
continue
|
|
119
|
+
# RequestMethod.PUT -> PUT (the field_access tail)
|
|
120
|
+
if value.type == "field_access":
|
|
121
|
+
field = value.child_by_field_name("field")
|
|
122
|
+
return _text(field, src).upper() if field is not None else "ALL"
|
|
123
|
+
return _text(value, src).rsplit(".", 1)[-1].upper()
|
|
124
|
+
return "ALL"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _join(base: str, path: str) -> str:
|
|
128
|
+
"""Join a class base path with a method path into a single ``/``-separated
|
|
129
|
+
pattern (``/api`` + ``/users`` -> ``/api/users``)."""
|
|
130
|
+
b = base.rstrip("/")
|
|
131
|
+
p = path
|
|
132
|
+
if p and not p.startswith("/"):
|
|
133
|
+
p = "/" + p
|
|
134
|
+
return (b + p) or "/"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class SpringPack(FrameworkPack):
|
|
138
|
+
name = "spring"
|
|
139
|
+
language = "java"
|
|
140
|
+
language_slug = "java" # SymbolID slug — must match the Java language pack
|
|
141
|
+
version = "1"
|
|
142
|
+
dep_names = ("spring-web", "spring-boot-starter-web", "spring-webmvc")
|
|
143
|
+
import_markers = ("org.springframework.web", "import org.springframework")
|
|
144
|
+
|
|
145
|
+
def extract(self, file: SourceFile, repo: str, commit: str) -> FrameworkFacts:
|
|
146
|
+
src = file.text.encode("utf-8")
|
|
147
|
+
lang = _language()
|
|
148
|
+
root = Parser(lang).parse(src).root_node
|
|
149
|
+
query = Query(lang, _query_text())
|
|
150
|
+
prov = Provenance.parsed(f"pack:{self.name}@{self.version}", commit)
|
|
151
|
+
facts = FrameworkFacts()
|
|
152
|
+
seen: set[str] = set()
|
|
153
|
+
|
|
154
|
+
for _pattern, caps in QueryCursor(query).matches(root):
|
|
155
|
+
class_caps = caps.get("decl")
|
|
156
|
+
name_caps = caps.get("class")
|
|
157
|
+
if not (class_caps and name_caps):
|
|
158
|
+
continue
|
|
159
|
+
self._extract_controller(
|
|
160
|
+
class_caps[0], _text(name_caps[0], src), src, repo, file, prov, facts, seen
|
|
161
|
+
)
|
|
162
|
+
return facts
|
|
163
|
+
|
|
164
|
+
def _extract_controller(
|
|
165
|
+
self,
|
|
166
|
+
class_node: TSNode,
|
|
167
|
+
class_name: str,
|
|
168
|
+
src: bytes,
|
|
169
|
+
repo: str,
|
|
170
|
+
file: SourceFile,
|
|
171
|
+
prov: Provenance,
|
|
172
|
+
facts: FrameworkFacts,
|
|
173
|
+
seen: set[str],
|
|
174
|
+
) -> None:
|
|
175
|
+
class_annos = _annotations(class_node)
|
|
176
|
+
names = {_anno_name(a, src) for a in class_annos}
|
|
177
|
+
base_path = ""
|
|
178
|
+
for a in class_annos:
|
|
179
|
+
if _anno_name(a, src) == "RequestMapping":
|
|
180
|
+
base_path = _anno_path(a, src)
|
|
181
|
+
is_controller = bool(names & _CONTROLLER) or "RequestMapping" in names
|
|
182
|
+
if not is_controller:
|
|
183
|
+
return # not a controller -> not a route source (ADR-0004)
|
|
184
|
+
|
|
185
|
+
body = class_node.child_by_field_name("body")
|
|
186
|
+
if body is None:
|
|
187
|
+
return
|
|
188
|
+
for member in body.named_children:
|
|
189
|
+
if member.type != "method_declaration":
|
|
190
|
+
continue
|
|
191
|
+
name_node = member.child_by_field_name("name")
|
|
192
|
+
if name_node is None:
|
|
193
|
+
continue
|
|
194
|
+
for anno in _annotations(member):
|
|
195
|
+
verb_path = self._mapping_for(anno, src)
|
|
196
|
+
if verb_path is None:
|
|
197
|
+
continue
|
|
198
|
+
method, method_path = verb_path
|
|
199
|
+
self._emit_route(
|
|
200
|
+
method,
|
|
201
|
+
_join(base_path, method_path),
|
|
202
|
+
class_name,
|
|
203
|
+
_text(name_node, src),
|
|
204
|
+
member,
|
|
205
|
+
repo,
|
|
206
|
+
file,
|
|
207
|
+
prov,
|
|
208
|
+
facts,
|
|
209
|
+
seen,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
def _mapping_for(self, anno: TSNode, src: bytes) -> tuple[str, str] | None:
|
|
213
|
+
"""``(http_method, path)`` for a mapping annotation, or None when the
|
|
214
|
+
annotation is not a Spring request mapping."""
|
|
215
|
+
anno_name = _anno_name(anno, src)
|
|
216
|
+
if anno_name in _MAPPING:
|
|
217
|
+
return _MAPPING[anno_name], _anno_path(anno, src)
|
|
218
|
+
if anno_name == "RequestMapping":
|
|
219
|
+
return _request_method(anno, src), _anno_path(anno, src)
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
def _emit_route(
|
|
223
|
+
self,
|
|
224
|
+
method: str,
|
|
225
|
+
path: str,
|
|
226
|
+
class_name: str,
|
|
227
|
+
method_name: str,
|
|
228
|
+
member: TSNode,
|
|
229
|
+
repo: str,
|
|
230
|
+
file: SourceFile,
|
|
231
|
+
prov: Provenance,
|
|
232
|
+
facts: FrameworkFacts,
|
|
233
|
+
seen: set[str],
|
|
234
|
+
) -> None:
|
|
235
|
+
handler_id = SymbolID.for_symbol(
|
|
236
|
+
self.language_slug,
|
|
237
|
+
repo,
|
|
238
|
+
file.path,
|
|
239
|
+
Descriptor.type(class_name) + Descriptor.method(method_name),
|
|
240
|
+
)
|
|
241
|
+
route_id = SymbolID.for_symbol(
|
|
242
|
+
self.language_slug, repo, file.path, f"route({method} {path})."
|
|
243
|
+
)
|
|
244
|
+
if route_id in seen:
|
|
245
|
+
return
|
|
246
|
+
seen.add(route_id)
|
|
247
|
+
facts.nodes.append(
|
|
248
|
+
GraphNode(
|
|
249
|
+
id=route_id,
|
|
250
|
+
kind=NodeKind.ROUTE,
|
|
251
|
+
name=f"{method} {path}",
|
|
252
|
+
span=(member.start_point[0] + 1, member.end_point[0] + 1),
|
|
253
|
+
attrs={
|
|
254
|
+
"method": method,
|
|
255
|
+
"path": path,
|
|
256
|
+
"framework": self.name,
|
|
257
|
+
"handler": handler_id,
|
|
258
|
+
},
|
|
259
|
+
provenance=prov,
|
|
260
|
+
)
|
|
261
|
+
)
|
|
262
|
+
facts.edges.append(
|
|
263
|
+
Edge(src=route_id, dst=handler_id, kind=EdgeKind.HANDLED_BY, provenance=prov)
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
SPRING_PACK = SpringPack()
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
; Spring MVC controllers (feat-011). Capture every class; the pack inspects each
|
|
2
|
+
; class's annotations (is it a @RestController/@Controller, what is its base
|
|
3
|
+
; @RequestMapping path?) and its methods' mapping annotations in code, so the
|
|
4
|
+
; path/method derivation and the controller guard stay precise.
|
|
5
|
+
(class_declaration
|
|
6
|
+
name: (identifier) @class) @decl
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""SQLAlchemy framework pack (feat-011) — declarative ORM models.
|
|
2
|
+
|
|
3
|
+
Extracts declarative model classes into ``DataModel`` nodes + ``HAS_FIELD``
|
|
4
|
+
edges to each mapped column (a ``Variable`` field node carrying its column
|
|
5
|
+
type). A class is treated as a model only when its body carries the static
|
|
6
|
+
evidence SQLAlchemy declarative mapping requires — a ``__tablename__`` string
|
|
7
|
+
or at least one ``Column(...)`` / ``mapped_column(...)`` field — so plain
|
|
8
|
+
classes in a SQLAlchemy app never mint false models (ADR-0004).
|
|
9
|
+
|
|
10
|
+
Both the classic (``name = Column(Integer)``) and 2.0-style
|
|
11
|
+
(``name: Mapped[int] = mapped_column()``) field forms are recognised. Intra-
|
|
12
|
+
file: model, fields, and `HAS_FIELD` edges all live in the file's
|
|
13
|
+
``FileSubgraph`` and ride feat-004 incrementality. ``relationship("X")`` /
|
|
14
|
+
``ForeignKey("t.c")`` string targets are recorded on the model node in pass-1
|
|
15
|
+
and stitched into cross-file ``RELATES_TO`` edges in pass-2 (``resolve``) — a
|
|
16
|
+
unique-match-only resolution against the whole-repo model set (ADR-0004).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from functools import cache
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from tree_sitter import Node as TSNode
|
|
25
|
+
from tree_sitter import Parser, Query, QueryCursor
|
|
26
|
+
|
|
27
|
+
from agentforge_graph.core import (
|
|
28
|
+
Descriptor,
|
|
29
|
+
Edge,
|
|
30
|
+
EdgeKind,
|
|
31
|
+
GraphStore,
|
|
32
|
+
NodeKind,
|
|
33
|
+
Provenance,
|
|
34
|
+
SourceFile,
|
|
35
|
+
SymbolID,
|
|
36
|
+
)
|
|
37
|
+
from agentforge_graph.core import Node as GraphNode
|
|
38
|
+
from agentforge_graph.frameworks.base import FrameworkFacts, FrameworkPack
|
|
39
|
+
from agentforge_graph.frameworks.orm import (
|
|
40
|
+
ModelIndex,
|
|
41
|
+
framework_models,
|
|
42
|
+
relations_to_edges,
|
|
43
|
+
)
|
|
44
|
+
from agentforge_graph.frameworks.packs._python_ast import (
|
|
45
|
+
callee_name,
|
|
46
|
+
class_body,
|
|
47
|
+
first_string_arg,
|
|
48
|
+
iter_class_assignments,
|
|
49
|
+
python_language,
|
|
50
|
+
strip_quotes,
|
|
51
|
+
text,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
_HERE = Path(__file__).parent
|
|
55
|
+
_COLUMN_CALLS = {"Column", "mapped_column"}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@cache
|
|
59
|
+
def _query_text() -> str:
|
|
60
|
+
return (_HERE / "models.scm").read_text(encoding="utf-8")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _positional_type(call: TSNode, src: bytes) -> str:
|
|
64
|
+
"""The column's SQL type from the first positional arg: ``Integer`` for
|
|
65
|
+
``Column(Integer)`` / ``Column(String(50))``. Keyword args are skipped."""
|
|
66
|
+
args = call.child_by_field_name("arguments")
|
|
67
|
+
if args is None:
|
|
68
|
+
return ""
|
|
69
|
+
for arg in args.named_children:
|
|
70
|
+
if arg.type == "keyword_argument":
|
|
71
|
+
continue
|
|
72
|
+
if arg.type == "call": # String(50) -> String
|
|
73
|
+
return callee_name(arg, src)
|
|
74
|
+
if arg.type in ("identifier", "attribute"):
|
|
75
|
+
return callee_name(arg, src) if arg.type == "attribute" else text(arg, src)
|
|
76
|
+
return ""
|
|
77
|
+
return ""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _foreign_key_target(call: TSNode, src: bytes) -> str:
|
|
81
|
+
"""A ``ForeignKey("table.col")`` target nested in a ``Column(...)`` arg list
|
|
82
|
+
(``author_id = Column(Integer, ForeignKey("users.id"))``), or ""."""
|
|
83
|
+
args = call.child_by_field_name("arguments")
|
|
84
|
+
if args is None:
|
|
85
|
+
return ""
|
|
86
|
+
for arg in args.named_children:
|
|
87
|
+
if arg.type == "call" and callee_name(arg, src) == "ForeignKey":
|
|
88
|
+
return first_string_arg(arg, src)
|
|
89
|
+
return ""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _mapped_type(assignment: TSNode, src: bytes) -> str:
|
|
93
|
+
"""The inner type of a ``Mapped[X]`` annotation (``int`` for
|
|
94
|
+
``id: Mapped[int]``), or "" when there is no such annotation."""
|
|
95
|
+
type_node = assignment.child_by_field_name("type")
|
|
96
|
+
if type_node is None:
|
|
97
|
+
return ""
|
|
98
|
+
generic = type_node.named_children[0] if type_node.named_children else None
|
|
99
|
+
if generic is None or generic.type != "generic_type":
|
|
100
|
+
return ""
|
|
101
|
+
base = generic.named_children[0] if generic.named_children else None
|
|
102
|
+
if base is None or text(base, src) != "Mapped":
|
|
103
|
+
return ""
|
|
104
|
+
# `Mapped[int]` -> generic_type with a `type_parameter` holding `(type (identifier))`
|
|
105
|
+
targs = next((c for c in generic.named_children if c.type == "type_parameter"), None)
|
|
106
|
+
if targs is None or not targs.named_children:
|
|
107
|
+
return ""
|
|
108
|
+
inner = targs.named_children[0]
|
|
109
|
+
leaf = inner.named_children[0] if inner.type == "type" and inner.named_children else inner
|
|
110
|
+
return text(leaf, src)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class SQLAlchemyPack(FrameworkPack):
|
|
114
|
+
name = "sqlalchemy"
|
|
115
|
+
language = "python"
|
|
116
|
+
language_slug = "py" # SymbolID slug — must match the Python language pack
|
|
117
|
+
version = "1"
|
|
118
|
+
dep_names = ("sqlalchemy",)
|
|
119
|
+
import_markers = ("import sqlalchemy", "from sqlalchemy")
|
|
120
|
+
|
|
121
|
+
def extract(self, file: SourceFile, repo: str, commit: str) -> FrameworkFacts:
|
|
122
|
+
src = file.text.encode("utf-8")
|
|
123
|
+
root = Parser(python_language()).parse(src).root_node
|
|
124
|
+
query = Query(python_language(), _query_text())
|
|
125
|
+
prov = Provenance.parsed(f"pack:{self.name}@{self.version}", commit)
|
|
126
|
+
facts = FrameworkFacts()
|
|
127
|
+
|
|
128
|
+
for _pattern, caps in QueryCursor(query).matches(root):
|
|
129
|
+
class_caps = caps.get("model")
|
|
130
|
+
name_caps = caps.get("name")
|
|
131
|
+
if not (class_caps and name_caps):
|
|
132
|
+
continue
|
|
133
|
+
self._extract_model(
|
|
134
|
+
class_caps[0], text(name_caps[0], src), src, repo, file, prov, facts
|
|
135
|
+
)
|
|
136
|
+
return facts
|
|
137
|
+
|
|
138
|
+
def _extract_model(
|
|
139
|
+
self,
|
|
140
|
+
class_node: TSNode,
|
|
141
|
+
class_name: str,
|
|
142
|
+
src: bytes,
|
|
143
|
+
repo: str,
|
|
144
|
+
file: SourceFile,
|
|
145
|
+
prov: Provenance,
|
|
146
|
+
facts: FrameworkFacts,
|
|
147
|
+
) -> None:
|
|
148
|
+
body = class_body(class_node)
|
|
149
|
+
if body is None:
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
table: str | None = None
|
|
153
|
+
fields: list[tuple[str, str, TSNode]] = [] # (name, column_type, assignment node)
|
|
154
|
+
# pending RELATES_TO targets resolved cross-file in pass-2 (resolve()):
|
|
155
|
+
# relationship("Post") -> class name; ForeignKey("users.id") -> table.col
|
|
156
|
+
relations: list[dict[str, str]] = []
|
|
157
|
+
for field_name, assign, right in iter_class_assignments(body, src):
|
|
158
|
+
if field_name == "__tablename__" and right.type == "string":
|
|
159
|
+
table = strip_quotes(text(right, src))
|
|
160
|
+
continue
|
|
161
|
+
if right.type != "call":
|
|
162
|
+
continue
|
|
163
|
+
callee = callee_name(right, src)
|
|
164
|
+
if callee in _COLUMN_CALLS:
|
|
165
|
+
col_type = _positional_type(right, src) or _mapped_type(assign, src)
|
|
166
|
+
fields.append((field_name, col_type, assign))
|
|
167
|
+
fk = _foreign_key_target(right, src) # Column(.., ForeignKey("t.c"))
|
|
168
|
+
if fk:
|
|
169
|
+
relations.append({"field": field_name, "target": fk, "kind": "fk"})
|
|
170
|
+
elif callee == "relationship":
|
|
171
|
+
target = first_string_arg(right, src)
|
|
172
|
+
if target:
|
|
173
|
+
relations.append(
|
|
174
|
+
{"field": field_name, "target": target, "kind": "relationship"}
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
# Conservative: a class is a model only with declarative evidence.
|
|
178
|
+
if table is None and not fields:
|
|
179
|
+
return
|
|
180
|
+
|
|
181
|
+
model_id = SymbolID.for_symbol(self.language_slug, repo, file.path, f"model({class_name}).")
|
|
182
|
+
class_id = SymbolID.for_symbol(
|
|
183
|
+
self.language_slug, repo, file.path, Descriptor.type(class_name)
|
|
184
|
+
)
|
|
185
|
+
attrs: dict[str, object] = {
|
|
186
|
+
"framework": self.name,
|
|
187
|
+
"class": class_id,
|
|
188
|
+
"model_class": class_name, # cross-file RELATES_TO target lookup (pass-2)
|
|
189
|
+
}
|
|
190
|
+
if table is not None:
|
|
191
|
+
attrs["table"] = table
|
|
192
|
+
if relations:
|
|
193
|
+
attrs["relations"] = relations
|
|
194
|
+
facts.nodes.append(
|
|
195
|
+
GraphNode(
|
|
196
|
+
id=model_id,
|
|
197
|
+
kind=NodeKind.DATA_MODEL,
|
|
198
|
+
name=table or class_name,
|
|
199
|
+
span=(class_node.start_point[0] + 1, class_node.end_point[0] + 1),
|
|
200
|
+
attrs=attrs,
|
|
201
|
+
provenance=prov,
|
|
202
|
+
)
|
|
203
|
+
)
|
|
204
|
+
for field_name, col_type, assign in fields:
|
|
205
|
+
field_id = SymbolID.for_symbol(
|
|
206
|
+
self.language_slug,
|
|
207
|
+
repo,
|
|
208
|
+
file.path,
|
|
209
|
+
Descriptor.type(class_name) + Descriptor.term(field_name),
|
|
210
|
+
)
|
|
211
|
+
facts.nodes.append(
|
|
212
|
+
GraphNode(
|
|
213
|
+
id=field_id,
|
|
214
|
+
kind=NodeKind.VARIABLE,
|
|
215
|
+
name=field_name,
|
|
216
|
+
span=(assign.start_point[0] + 1, assign.end_point[0] + 1),
|
|
217
|
+
attrs={"column_type": col_type, "framework": self.name},
|
|
218
|
+
provenance=prov,
|
|
219
|
+
)
|
|
220
|
+
)
|
|
221
|
+
facts.edges.append(
|
|
222
|
+
Edge(src=model_id, dst=field_id, kind=EdgeKind.HAS_FIELD, provenance=prov)
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
async def resolve(self, store: GraphStore, commit: str = "") -> list[Edge]:
|
|
226
|
+
"""Pass-2: stitch the ``relationship``/``ForeignKey`` string targets
|
|
227
|
+
recorded in pass-1 into ``RELATES_TO`` edges. A ``relationship("Post")``
|
|
228
|
+
resolves to the model whose class is ``Post``; a ``ForeignKey("users.id")``
|
|
229
|
+
to the model whose table is ``users``. Ambiguous class names (same name
|
|
230
|
+
in two files) are left unresolved (ADR-0004 — never guess)."""
|
|
231
|
+
models = await framework_models(store, self.name)
|
|
232
|
+
index = ModelIndex(models)
|
|
233
|
+
prov = Provenance.resolved(f"pack:{self.name}@{self.version}", commit)
|
|
234
|
+
return relations_to_edges(models, index, _resolve_target, prov)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _resolve_target(rel: dict[str, str], index: ModelIndex) -> str | None:
|
|
238
|
+
"""Resolve one SQLAlchemy relation to a target model id (unique match only)."""
|
|
239
|
+
target = str(rel.get("target", ""))
|
|
240
|
+
if not target:
|
|
241
|
+
return None
|
|
242
|
+
if rel.get("kind") == "fk":
|
|
243
|
+
# "users.id" / "schema.users.id" -> the table segment (second-to-last)
|
|
244
|
+
parts = target.split(".")
|
|
245
|
+
return index.unique_table(parts[-2] if len(parts) >= 2 else parts[0])
|
|
246
|
+
# relationship("Post") / "module.Post" -> bare class name
|
|
247
|
+
return index.unique_class(target.rsplit(".", 1)[-1])
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
SQLALCHEMY_PACK = SQLAlchemyPack()
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
; SQLAlchemy declarative models (feat-011). Capture every class; the pack then
|
|
2
|
+
; inspects each class body in code to decide whether it is a DataModel (has a
|
|
3
|
+
; ``__tablename__`` or ``Column(...)``/``mapped_column(...)`` fields). Body
|
|
4
|
+
; analysis is done in Python rather than the query so direct-child scoping
|
|
5
|
+
; (class-level fields only, never nested locals) stays precise.
|
|
6
|
+
(class_definition
|
|
7
|
+
name: (identifier) @name) @model
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Framework-pack registry (feat-011). Mirrors the language ``PackRegistry``:
|
|
2
|
+
a flat list of built-in packs, resolvable by name or by the language they ride.
|
|
3
|
+
Third-party packs register out-of-tree via an entry point later."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from .base import FrameworkPack
|
|
8
|
+
from .packs.django import DJANGO_PACK
|
|
9
|
+
from .packs.express import EXPRESS_PACK
|
|
10
|
+
from .packs.fastapi import FASTAPI_PACK
|
|
11
|
+
from .packs.flask import FLASK_PACK
|
|
12
|
+
from .packs.nestjs import NESTJS_PACK
|
|
13
|
+
from .packs.spring import SPRING_PACK
|
|
14
|
+
from .packs.sqlalchemy import SQLALCHEMY_PACK
|
|
15
|
+
|
|
16
|
+
BUILTIN_FRAMEWORK_PACKS: list[FrameworkPack] = [
|
|
17
|
+
FASTAPI_PACK,
|
|
18
|
+
SQLALCHEMY_PACK,
|
|
19
|
+
DJANGO_PACK,
|
|
20
|
+
FLASK_PACK,
|
|
21
|
+
EXPRESS_PACK,
|
|
22
|
+
SPRING_PACK,
|
|
23
|
+
NESTJS_PACK,
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class FrameworkRegistry:
|
|
28
|
+
def __init__(self, packs: list[FrameworkPack]) -> None:
|
|
29
|
+
self._packs = list(packs)
|
|
30
|
+
self._by_name = {p.name: p for p in packs}
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def packs(self) -> list[FrameworkPack]:
|
|
34
|
+
return list(self._packs)
|
|
35
|
+
|
|
36
|
+
def by_name(self, name: str) -> FrameworkPack | None:
|
|
37
|
+
return self._by_name.get(name)
|
|
38
|
+
|
|
39
|
+
def for_language(self, language: str) -> list[FrameworkPack]:
|
|
40
|
+
return [p for p in self._packs if p.language == language]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def builtin_framework_registry() -> FrameworkRegistry:
|
|
44
|
+
return FrameworkRegistry(BUILTIN_FRAMEWORK_PACKS)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""agentforge_graph.ingest — the tree-sitter ingestion pipeline (feat-002).
|
|
2
|
+
|
|
3
|
+
Parses a repo with tree-sitter (no build config — ADR-0002) into the
|
|
4
|
+
feat-001 graph and writes it through the feat-003 store. Two passes:
|
|
5
|
+
file-isolated *extract* then graph-only *resolve*. Imports nothing from
|
|
6
|
+
``agentforge`` (ADR-0001).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from .codegraph import CodeGraph
|
|
12
|
+
from .extractor import TreeSitterExtractor
|
|
13
|
+
from .pack import DescriptorRules, LanguagePack, PackRegistry
|
|
14
|
+
from .pipeline import IngestPipeline
|
|
15
|
+
from .report import IndexReport, ResolveStats
|
|
16
|
+
from .resolver import ImportResolver
|
|
17
|
+
from .source import RepoSource
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"CodeGraph",
|
|
21
|
+
"RepoSource",
|
|
22
|
+
"LanguagePack",
|
|
23
|
+
"DescriptorRules",
|
|
24
|
+
"PackRegistry",
|
|
25
|
+
"TreeSitterExtractor",
|
|
26
|
+
"ImportResolver",
|
|
27
|
+
"IngestPipeline",
|
|
28
|
+
"IndexReport",
|
|
29
|
+
"ResolveStats",
|
|
30
|
+
]
|