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.
Files changed (151) hide show
  1. agentforge_graph/__init__.py +6 -0
  2. agentforge_graph/chunking/__init__.py +12 -0
  3. agentforge_graph/chunking/cast.py +159 -0
  4. agentforge_graph/chunking/chunk.py +19 -0
  5. agentforge_graph/chunking/tokens.py +15 -0
  6. agentforge_graph/cli.py +607 -0
  7. agentforge_graph/config.py +259 -0
  8. agentforge_graph/core/__init__.py +54 -0
  9. agentforge_graph/core/conformance.py +270 -0
  10. agentforge_graph/core/contracts.py +163 -0
  11. agentforge_graph/core/kinds.py +68 -0
  12. agentforge_graph/core/models.py +134 -0
  13. agentforge_graph/core/provenance.py +62 -0
  14. agentforge_graph/core/symbols.py +116 -0
  15. agentforge_graph/embed/__init__.py +28 -0
  16. agentforge_graph/embed/base.py +22 -0
  17. agentforge_graph/embed/bedrock.py +85 -0
  18. agentforge_graph/embed/fake.py +34 -0
  19. agentforge_graph/embed/openai.py +67 -0
  20. agentforge_graph/embed/pipeline.py +184 -0
  21. agentforge_graph/embed/registry.py +66 -0
  22. agentforge_graph/embed/report.py +15 -0
  23. agentforge_graph/enrich/__init__.py +70 -0
  24. agentforge_graph/enrich/anthropic.py +38 -0
  25. agentforge_graph/enrich/anthropic_client.py +109 -0
  26. agentforge_graph/enrich/bedrock.py +24 -0
  27. agentforge_graph/enrich/bedrock_client.py +115 -0
  28. agentforge_graph/enrich/bedrock_summarizer.py +23 -0
  29. agentforge_graph/enrich/claude.py +172 -0
  30. agentforge_graph/enrich/enricher.py +108 -0
  31. agentforge_graph/enrich/governs.py +173 -0
  32. agentforge_graph/enrich/governs_enricher.py +152 -0
  33. agentforge_graph/enrich/heuristics.py +224 -0
  34. agentforge_graph/enrich/judge.py +63 -0
  35. agentforge_graph/enrich/registry.py +133 -0
  36. agentforge_graph/enrich/report.py +60 -0
  37. agentforge_graph/enrich/summarizer.py +62 -0
  38. agentforge_graph/enrich/summary_enricher.py +211 -0
  39. agentforge_graph/enrich/taxonomy.py +38 -0
  40. agentforge_graph/frameworks/__init__.py +29 -0
  41. agentforge_graph/frameworks/base.py +75 -0
  42. agentforge_graph/frameworks/detect.py +124 -0
  43. agentforge_graph/frameworks/extractor.py +63 -0
  44. agentforge_graph/frameworks/orm.py +93 -0
  45. agentforge_graph/frameworks/packs/_js_ast.py +56 -0
  46. agentforge_graph/frameworks/packs/_python_ast.py +157 -0
  47. agentforge_graph/frameworks/packs/django/__init__.py +240 -0
  48. agentforge_graph/frameworks/packs/django/models.scm +7 -0
  49. agentforge_graph/frameworks/packs/express/__init__.py +133 -0
  50. agentforge_graph/frameworks/packs/express/routes.scm +8 -0
  51. agentforge_graph/frameworks/packs/fastapi/__init__.py +210 -0
  52. agentforge_graph/frameworks/packs/fastapi/depends.scm +6 -0
  53. agentforge_graph/frameworks/packs/fastapi/routes.scm +10 -0
  54. agentforge_graph/frameworks/packs/flask/__init__.py +143 -0
  55. agentforge_graph/frameworks/packs/flask/routes.scm +11 -0
  56. agentforge_graph/frameworks/packs/nestjs/__init__.py +205 -0
  57. agentforge_graph/frameworks/packs/nestjs/routes.scm +6 -0
  58. agentforge_graph/frameworks/packs/spring/__init__.py +267 -0
  59. agentforge_graph/frameworks/packs/spring/routes.scm +6 -0
  60. agentforge_graph/frameworks/packs/sqlalchemy/__init__.py +250 -0
  61. agentforge_graph/frameworks/packs/sqlalchemy/models.scm +7 -0
  62. agentforge_graph/frameworks/registry.py +44 -0
  63. agentforge_graph/ingest/__init__.py +30 -0
  64. agentforge_graph/ingest/codegraph.py +847 -0
  65. agentforge_graph/ingest/extractor.py +353 -0
  66. agentforge_graph/ingest/incremental/__init__.py +25 -0
  67. agentforge_graph/ingest/incremental/detect.py +118 -0
  68. agentforge_graph/ingest/incremental/dirty.py +61 -0
  69. agentforge_graph/ingest/incremental/indexer.py +218 -0
  70. agentforge_graph/ingest/incremental/meta.py +72 -0
  71. agentforge_graph/ingest/incremental/ports.py +39 -0
  72. agentforge_graph/ingest/pack.py +160 -0
  73. agentforge_graph/ingest/packs/__init__.py +34 -0
  74. agentforge_graph/ingest/packs/cpp/__init__.py +35 -0
  75. agentforge_graph/ingest/packs/cpp/references.scm +15 -0
  76. agentforge_graph/ingest/packs/cpp/structure.scm +49 -0
  77. agentforge_graph/ingest/packs/csharp/__init__.py +35 -0
  78. agentforge_graph/ingest/packs/csharp/references.scm +12 -0
  79. agentforge_graph/ingest/packs/csharp/structure.scm +45 -0
  80. agentforge_graph/ingest/packs/go/__init__.py +38 -0
  81. agentforge_graph/ingest/packs/go/references.scm +12 -0
  82. agentforge_graph/ingest/packs/go/structure.scm +64 -0
  83. agentforge_graph/ingest/packs/java/__init__.py +35 -0
  84. agentforge_graph/ingest/packs/java/references.scm +12 -0
  85. agentforge_graph/ingest/packs/java/structure.scm +38 -0
  86. agentforge_graph/ingest/packs/javascript/__init__.py +34 -0
  87. agentforge_graph/ingest/packs/javascript/references.scm +11 -0
  88. agentforge_graph/ingest/packs/javascript/structure.scm +166 -0
  89. agentforge_graph/ingest/packs/php/__init__.py +35 -0
  90. agentforge_graph/ingest/packs/php/references.scm +15 -0
  91. agentforge_graph/ingest/packs/php/structure.scm +44 -0
  92. agentforge_graph/ingest/packs/python/__init__.py +25 -0
  93. agentforge_graph/ingest/packs/python/references.scm +14 -0
  94. agentforge_graph/ingest/packs/python/structure.scm +57 -0
  95. agentforge_graph/ingest/packs/ruby/__init__.py +37 -0
  96. agentforge_graph/ingest/packs/ruby/references.scm +12 -0
  97. agentforge_graph/ingest/packs/ruby/structure.scm +37 -0
  98. agentforge_graph/ingest/packs/rust/__init__.py +39 -0
  99. agentforge_graph/ingest/packs/rust/references.scm +12 -0
  100. agentforge_graph/ingest/packs/rust/structure.scm +46 -0
  101. agentforge_graph/ingest/packs/typescript/__init__.py +31 -0
  102. agentforge_graph/ingest/packs/typescript/references.scm +11 -0
  103. agentforge_graph/ingest/packs/typescript/structure.scm +99 -0
  104. agentforge_graph/ingest/pipeline.py +134 -0
  105. agentforge_graph/ingest/report.py +84 -0
  106. agentforge_graph/ingest/resolver.py +467 -0
  107. agentforge_graph/ingest/source.py +79 -0
  108. agentforge_graph/knowledge/__init__.py +28 -0
  109. agentforge_graph/knowledge/adr.py +136 -0
  110. agentforge_graph/knowledge/commits.py +152 -0
  111. agentforge_graph/knowledge/ingest.py +312 -0
  112. agentforge_graph/knowledge/mentions.py +71 -0
  113. agentforge_graph/knowledge/report.py +32 -0
  114. agentforge_graph/main.py +21 -0
  115. agentforge_graph/providers.py +36 -0
  116. agentforge_graph/repomap/__init__.py +14 -0
  117. agentforge_graph/repomap/rank.py +161 -0
  118. agentforge_graph/repomap/render.py +55 -0
  119. agentforge_graph/repomap/repomap.py +66 -0
  120. agentforge_graph/retrieve/__init__.py +21 -0
  121. agentforge_graph/retrieve/pack.py +76 -0
  122. agentforge_graph/retrieve/rerank.py +251 -0
  123. agentforge_graph/retrieve/retriever.py +286 -0
  124. agentforge_graph/retrieve/scoring.py +36 -0
  125. agentforge_graph/serve/__init__.py +19 -0
  126. agentforge_graph/serve/engine.py +204 -0
  127. agentforge_graph/serve/http_runner.py +133 -0
  128. agentforge_graph/serve/server.py +110 -0
  129. agentforge_graph/serve/tools.py +307 -0
  130. agentforge_graph/store/__init__.py +32 -0
  131. agentforge_graph/store/_rowmap.py +102 -0
  132. agentforge_graph/store/errors.py +22 -0
  133. agentforge_graph/store/facade.py +89 -0
  134. agentforge_graph/store/kuzu_store.py +380 -0
  135. agentforge_graph/store/lance_store.py +146 -0
  136. agentforge_graph/store/neo4j_store.py +294 -0
  137. agentforge_graph/store/pgvector_store.py +170 -0
  138. agentforge_graph/store/registry.py +45 -0
  139. agentforge_graph/temporal/__init__.py +36 -0
  140. agentforge_graph/temporal/backfill.py +338 -0
  141. agentforge_graph/temporal/events.py +82 -0
  142. agentforge_graph/temporal/index.py +190 -0
  143. agentforge_graph/temporal/mining.py +190 -0
  144. agentforge_graph/temporal/recorder.py +114 -0
  145. agentforge_graph/temporal/store.py +282 -0
  146. agentforge_graph-0.3.2.dist-info/METADATA +291 -0
  147. agentforge_graph-0.3.2.dist-info/RECORD +151 -0
  148. agentforge_graph-0.3.2.dist-info/WHEEL +4 -0
  149. agentforge_graph-0.3.2.dist-info/entry_points.txt +3 -0
  150. agentforge_graph-0.3.2.dist-info/licenses/LICENSE +202 -0
  151. 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
+ ]