code-atlas-forge 1.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. code_atlas/__init__.py +6 -0
  2. code_atlas/__main__.py +6 -0
  3. code_atlas/api/__init__.py +5 -0
  4. code_atlas/api/handlers.py +1514 -0
  5. code_atlas/api/server.py +394 -0
  6. code_atlas/cli.py +2084 -0
  7. code_atlas/config.py +149 -0
  8. code_atlas/context/__init__.py +66 -0
  9. code_atlas/context/contracts.py +277 -0
  10. code_atlas/context/domain.py +760 -0
  11. code_atlas/context/engine.py +442 -0
  12. code_atlas/context/expand.py +133 -0
  13. code_atlas/context/renderers.py +71 -0
  14. code_atlas/context/tokenizer.py +37 -0
  15. code_atlas/contracts/__init__.py +38 -0
  16. code_atlas/contracts/enums.py +67 -0
  17. code_atlas/contracts/models.py +175 -0
  18. code_atlas/contracts/ports.py +70 -0
  19. code_atlas/discovery/__init__.py +25 -0
  20. code_atlas/discovery/gitignore.py +119 -0
  21. code_atlas/discovery/identity.py +52 -0
  22. code_atlas/discovery/models.py +204 -0
  23. code_atlas/discovery/scanner.py +495 -0
  24. code_atlas/domains/__init__.py +107 -0
  25. code_atlas/domains/api/__init__.py +28 -0
  26. code_atlas/domains/api/adapter.py +405 -0
  27. code_atlas/domains/api/common.py +308 -0
  28. code_atlas/domains/api/consumers.py +224 -0
  29. code_atlas/domains/api/emit.py +231 -0
  30. code_atlas/domains/api/openapi.py +398 -0
  31. code_atlas/domains/api/routes.py +582 -0
  32. code_atlas/domains/api/yaml_subset.py +683 -0
  33. code_atlas/domains/aws/__init__.py +28 -0
  34. code_atlas/domains/aws/adapter.py +382 -0
  35. code_atlas/domains/aws/common.py +261 -0
  36. code_atlas/domains/aws/configs.py +1164 -0
  37. code_atlas/domains/aws/emit.py +411 -0
  38. code_atlas/domains/aws/pyfiles.py +2220 -0
  39. code_atlas/domains/aws/sqlbridge.py +448 -0
  40. code_atlas/domains/cicd/__init__.py +50 -0
  41. code_atlas/domains/cicd/adapter.py +762 -0
  42. code_atlas/domains/cicd/classify.py +136 -0
  43. code_atlas/domains/cicd/workflow.py +504 -0
  44. code_atlas/domains/cicd/yamlmini.py +946 -0
  45. code_atlas/domains/cli_surface.py +739 -0
  46. code_atlas/domains/contracts.py +274 -0
  47. code_atlas/domains/crossresolve.py +1208 -0
  48. code_atlas/domains/evidence.py +57 -0
  49. code_atlas/domains/identity.py +149 -0
  50. code_atlas/domains/model.py +202 -0
  51. code_atlas/domains/registry.py +250 -0
  52. code_atlas/domains/resolution.py +65 -0
  53. code_atlas/domains/spark/__init__.py +31 -0
  54. code_atlas/domains/spark/adapter.py +146 -0
  55. code_atlas/domains/spark/emit.py +344 -0
  56. code_atlas/domains/spark/extract.py +108 -0
  57. code_atlas/domains/spark/sqllit.py +192 -0
  58. code_atlas/domains/spark/walk.py +1943 -0
  59. code_atlas/domains/sql/__init__.py +30 -0
  60. code_atlas/domains/sql/adapter.py +382 -0
  61. code_atlas/domains/sql/extract.py +1271 -0
  62. code_atlas/domains/sql/nodes.py +264 -0
  63. code_atlas/domains/sql/parser.py +1510 -0
  64. code_atlas/domains/sql/tokens.py +318 -0
  65. code_atlas/domains/summarize.py +126 -0
  66. code_atlas/domains/terraform/__init__.py +20 -0
  67. code_atlas/domains/terraform/adapter.py +127 -0
  68. code_atlas/domains/terraform/catalog.py +43 -0
  69. code_atlas/domains/terraform/extract.py +1277 -0
  70. code_atlas/domains/terraform/hcl.py +538 -0
  71. code_atlas/domains/terraform/refs.py +181 -0
  72. code_atlas/domains/terraform/tfjson.py +163 -0
  73. code_atlas/domains/terraform/tfvars.py +98 -0
  74. code_atlas/domains/validation.py +120 -0
  75. code_atlas/env_override.py +27 -0
  76. code_atlas/eval/__init__.py +26 -0
  77. code_atlas/eval/domain_eval.py +681 -0
  78. code_atlas/eval/ground_truth.py +688 -0
  79. code_atlas/eval/impact_truth.py +758 -0
  80. code_atlas/eval/metrics.py +178 -0
  81. code_atlas/eval/pack_value.py +610 -0
  82. code_atlas/federation/__init__.py +72 -0
  83. code_atlas/federation/federation.py +676 -0
  84. code_atlas/federation/model.py +355 -0
  85. code_atlas/federation/store.py +194 -0
  86. code_atlas/gitbin.py +48 -0
  87. code_atlas/graph/__init__.py +20 -0
  88. code_atlas/graph/builder.py +378 -0
  89. code_atlas/graph/identity.py +54 -0
  90. code_atlas/graph/queries.py +47 -0
  91. code_atlas/graph/validation.py +92 -0
  92. code_atlas/impact/__init__.py +37 -0
  93. code_atlas/impact/blast_radius.py +171 -0
  94. code_atlas/impact/contract_analyzer.py +104 -0
  95. code_atlas/impact/contracts.py +315 -0
  96. code_atlas/impact/diff_reader.py +142 -0
  97. code_atlas/impact/domain_traversal.py +768 -0
  98. code_atlas/impact/engine.py +392 -0
  99. code_atlas/impact/packs.py +128 -0
  100. code_atlas/impact/report.py +136 -0
  101. code_atlas/impact/revision_binding.py +220 -0
  102. code_atlas/impact/risk_classifier.py +83 -0
  103. code_atlas/impact/snapshot_resolver.py +136 -0
  104. code_atlas/impact/symbol_diff.py +210 -0
  105. code_atlas/impact/test_selector.py +166 -0
  106. code_atlas/indexing/__init__.py +5 -0
  107. code_atlas/indexing/cache.py +396 -0
  108. code_atlas/indexing/change_detector.py +278 -0
  109. code_atlas/indexing/coordinator.py +1032 -0
  110. code_atlas/indexing/dependency_planner.py +268 -0
  111. code_atlas/indexing/persist.py +436 -0
  112. code_atlas/indexing/reuse.py +85 -0
  113. code_atlas/install/__init__.py +7 -0
  114. code_atlas/install/cli.py +168 -0
  115. code_atlas/install/lifecycle.py +286 -0
  116. code_atlas/mcp.py +674 -0
  117. code_atlas/observability/__init__.py +19 -0
  118. code_atlas/observability/logging.py +199 -0
  119. code_atlas/parsers/__init__.py +43 -0
  120. code_atlas/parsers/contracts.py +209 -0
  121. code_atlas/parsers/engine.py +125 -0
  122. code_atlas/parsers/languages/__init__.py +1 -0
  123. code_atlas/parsers/languages/python.py +391 -0
  124. code_atlas/parsers/languages/tsx.py +28 -0
  125. code_atlas/parsers/languages/typescript.py +909 -0
  126. code_atlas/parsers/registry.py +51 -0
  127. code_atlas/parsers/tree_sitter_runtime.py +118 -0
  128. code_atlas/resolvers/__init__.py +33 -0
  129. code_atlas/resolvers/bootstrap.py +246 -0
  130. code_atlas/resolvers/capabilities.py +33 -0
  131. code_atlas/resolvers/contracts.py +133 -0
  132. code_atlas/resolvers/coordinator.py +96 -0
  133. code_atlas/resolvers/enrichment.py +318 -0
  134. code_atlas/resolvers/lsp/__init__.py +26 -0
  135. code_atlas/resolvers/lsp/adapter.py +256 -0
  136. code_atlas/resolvers/lsp/lifecycle.py +251 -0
  137. code_atlas/resolvers/lsp/spawn.py +92 -0
  138. code_atlas/resolvers/lsp/transport.py +240 -0
  139. code_atlas/resolvers/registry.py +33 -0
  140. code_atlas/resolvers/scip/__init__.py +49 -0
  141. code_atlas/resolvers/scip/decoder.py +216 -0
  142. code_atlas/resolvers/scip/importer.py +248 -0
  143. code_atlas/resolvers/scip/loader.py +90 -0
  144. code_atlas/resolvers/scip/mapper.py +271 -0
  145. code_atlas/resolvers/scip/provenance.py +211 -0
  146. code_atlas/resolvers/scip/resolver.py +289 -0
  147. code_atlas/resolvers/scip/validation.py +101 -0
  148. code_atlas/resolvers/syntactic/__init__.py +8 -0
  149. code_atlas/resolvers/syntactic/resolver.py +254 -0
  150. code_atlas/search/__init__.py +32 -0
  151. code_atlas/search/contracts.py +176 -0
  152. code_atlas/search/domain.py +561 -0
  153. code_atlas/search/engine.py +530 -0
  154. code_atlas/search/federated.py +276 -0
  155. code_atlas/search/graph.py +282 -0
  156. code_atlas/search/hybrid.py +151 -0
  157. code_atlas/search/intent.py +48 -0
  158. code_atlas/search/lexical.py +176 -0
  159. code_atlas/search/refs.py +359 -0
  160. code_atlas/search/structural.py +505 -0
  161. code_atlas/search/tokenizer.py +43 -0
  162. code_atlas/search/vector/__init__.py +35 -0
  163. code_atlas/search/vector/contracts.py +76 -0
  164. code_atlas/search/vector/embeddings.py +96 -0
  165. code_atlas/search/vector/index.py +169 -0
  166. code_atlas/search/vector/provider.py +50 -0
  167. code_atlas/storage/__init__.py +14 -0
  168. code_atlas/storage/db.py +47 -0
  169. code_atlas/storage/migrations.py +552 -0
  170. code_atlas/storage/store.py +1045 -0
  171. code_atlas/ui/__init__.py +29 -0
  172. code_atlas/ui/i18n.py +143 -0
  173. code_atlas/ui/kit.py +689 -0
  174. code_atlas/ui/wizard.py +317 -0
  175. code_atlas_forge-1.1.0.dist-info/METADATA +246 -0
  176. code_atlas_forge-1.1.0.dist-info/RECORD +181 -0
  177. code_atlas_forge-1.1.0.dist-info/WHEEL +5 -0
  178. code_atlas_forge-1.1.0.dist-info/entry_points.txt +2 -0
  179. code_atlas_forge-1.1.0.dist-info/licenses/LICENSE +202 -0
  180. code_atlas_forge-1.1.0.dist-info/licenses/NOTICE +17 -0
  181. code_atlas_forge-1.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,442 @@
1
+ """Context pack engine — seleção determinística sob budget (lf-0040).
2
+
3
+ Pipeline: resolver snapshot → candidatos (símbolos diretos via
4
+ structural+lexical, vizinhos via graph, arquivos, dependências,
5
+ evidências) → dedup por identidade → prioridade → encaixe no budget
6
+ com reservas → indicadores explícitos de truncamento.
7
+
8
+ Nenhum item é gerado por LLM: todo corpo vem de fatos persistidos
9
+ (qualified_name, signature, docstring, path, arestas) ou da query.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import dataclasses
15
+ import json
16
+ import sqlite3
17
+
18
+ from code_atlas.context.contracts import (
19
+ PACK_SCHEMA_VERSION,
20
+ PRIORITY_COMPLEMENT,
21
+ PRIORITY_CONTRACT,
22
+ PRIORITY_DEP,
23
+ PRIORITY_DIRECT,
24
+ Budget,
25
+ ContextItem,
26
+ ContextPack,
27
+ ContextRequest,
28
+ DomainPackRequest,
29
+ DomainSectionFactory,
30
+ ExpansionRef,
31
+ PackDiagnostics,
32
+ PackEvidence,
33
+ PackManifest,
34
+ )
35
+ from code_atlas.context.tokenizer import HeuristicTokenizer, TokenCounter
36
+ from code_atlas.search.contracts import SearchRequest
37
+ from code_atlas.search.engine import SearchEngine
38
+ from code_atlas.search.graph import GraphRetrieval, TraversalLimits
39
+ from code_atlas.search.intent import QueryIntent, classify
40
+ from code_atlas.search.structural import SearchFilters, StructuralSearch
41
+
42
+ # Reservas do envelope (fração do limite) — metadados+evidências+diags.
43
+ _RESERVE_META = 0.05
44
+ _MAX_BUDGET = 1_000_000 # clamp — budget absurdo não infla reservas
45
+ _RESERVE_EVID = 0.10
46
+ _RESERVE_DIAG = 0.05
47
+
48
+
49
+ class ContextEngine:
50
+ """Constrói ``ContextPack`` determinístico sobre a conexão."""
51
+
52
+ def __init__(
53
+ self, conn: sqlite3.Connection, counter: TokenCounter | None = None
54
+ ) -> None:
55
+ self._conn = conn
56
+ self._structural = StructuralSearch(conn)
57
+ self._graph = GraphRetrieval(conn)
58
+ self._counter = counter or HeuristicTokenizer()
59
+ # lf-0052: hybrid retrieval é a fonte primária de candidatos
60
+ # (construção lazy — nem todo consumidor constrói packs).
61
+ self._search: SearchEngine | None = None
62
+ self._pack_cache: dict[tuple[object, ...], ContextPack] = {}
63
+
64
+ @property
65
+ def _engine(self) -> SearchEngine: # lazy, evita import circular
66
+ if self._search is None:
67
+ self._search = SearchEngine(self._conn)
68
+ return self._search
69
+
70
+ # -------------------------------------------------------- público
71
+ def build(self, req: ContextRequest) -> ContextPack:
72
+ diags: list[str] = []
73
+ repo_id = self._structural.repo_for_root(req.repository_root)
74
+ if repo_id is None:
75
+ return self._empty(req, f"repositório não indexado: "
76
+ f"{req.repository_root}")
77
+ snap, err = self._structural.resolve_snapshot(
78
+ repo_id, req.snapshot_id)
79
+ if snap is None or snap.id is None:
80
+ return self._empty(req, err or "nenhum snapshot READY")
81
+ sid = int(snap.id or 0)
82
+
83
+ budget = self._budget(req)
84
+ ck = (sid, req.task, req.level, budget.limit, req.max_symbols)
85
+ cached = self._pack_cache.get(ck)
86
+ if cached is not None:
87
+ return cached
88
+ candidates = self._candidates(req, sid, diags)
89
+ items = self._dedup(self._estimate(candidates))
90
+ items.sort(key=lambda i: (i.priority, i.item_id))
91
+ selected, omitted = self._fit(items, budget.available_items)
92
+ essentials_cost = sum(
93
+ i.est_tokens for i in items if i.priority <= PRIORITY_DIRECT)
94
+ insufficient = len(selected) < len(
95
+ [i for i in items if i.priority <= PRIORITY_DIRECT])
96
+ pack = self._enforce_payload_budget(
97
+ req, sid, budget, selected, omitted, essentials_cost,
98
+ insufficient, diags, str(snap.status))
99
+ # cache por snapshot+tarefa — pack é imutável; FIFO 32 entradas
100
+ if len(self._pack_cache) >= 32:
101
+ self._pack_cache.pop(next(iter(self._pack_cache)))
102
+ self._pack_cache[ck] = pack
103
+ return pack
104
+
105
+ def _enforce_payload_budget(
106
+ self, req: ContextRequest | DomainPackRequest, sid: int,
107
+ budget: Budget,
108
+ selected: list[ContextItem], omitted: list[ContextItem],
109
+ essentials_cost: int, insufficient: bool,
110
+ diags: list[str], snap_status: str,
111
+ *, domain_factory: DomainSectionFactory | None = None,
112
+ ) -> ContextPack:
113
+ """lf-0053: o orçamento cobre o payload SERIALIZADO (envelope +
114
+ itens + metadados + diagnósticos), não só os corpos. Se estoura,
115
+ despeja itens de menor prioridade para ``omitted`` até caber.
116
+
117
+ lf-0121: ``domain_factory`` recebe os selecionados DE CADA
118
+ iteração — a seção de domínio é recriada junto com o envelope,
119
+ então o custo dela também é contado no payload."""
120
+ from code_atlas.context.renderers import render_json
121
+ sel, omit = list(selected), list(omitted)
122
+ enforced = False
123
+ while True:
124
+ pack = self._pack(req, sid, budget, sel, omit,
125
+ essentials_cost, insufficient,
126
+ diags, snap_status,
127
+ domain_factory=domain_factory)
128
+ est = self._counter.count(render_json(pack))
129
+ if est <= budget.limit or not sel:
130
+ if enforced:
131
+ diags = [*diags, "payload-budget-enforced"]
132
+ pack = self._pack(req, sid, budget, sel, omit,
133
+ essentials_cost, insufficient,
134
+ diags, snap_status,
135
+ domain_factory=domain_factory)
136
+ est = self._counter.count(render_json(pack))
137
+ if est > budget.limit:
138
+ pack = self._pack(req, sid, budget, sel, omit,
139
+ essentials_cost, True,
140
+ [*diags, "payload-over-budget"],
141
+ snap_status,
142
+ domain_factory=domain_factory)
143
+ est = self._counter.count(render_json(pack))
144
+ # grava a estimativa final no manifest
145
+ return dataclasses.replace(
146
+ pack, manifest=dataclasses.replace(
147
+ pack.manifest, tokens_payload=est))
148
+ # despeja o item de MENOR prioridade (maior número; desempate
149
+ # pelo item_id mais alto) — ordenação estável reversa
150
+ sel.sort(key=lambda i: (i.priority, i.item_id),
151
+ reverse=True)
152
+ omit.append(sel.pop(0))
153
+ sel.sort(key=lambda i: (i.priority, i.item_id))
154
+ if not insufficient and any(
155
+ i.priority <= PRIORITY_DIRECT for i in omit):
156
+ insufficient = True
157
+ enforced = True
158
+
159
+ # -------------------------------------------------------- internals
160
+ def _budget(self, req: ContextRequest | DomainPackRequest) -> Budget:
161
+ limit = max(64, min(req.budget_tokens, _MAX_BUDGET))
162
+ meta = int(limit * _RESERVE_META)
163
+ evid = int(limit * _RESERVE_EVID)
164
+ diag = int(limit * _RESERVE_DIAG)
165
+ return Budget(
166
+ limit=limit, reserved_meta=meta, reserved_evidence=evid,
167
+ reserved_diagnostics=diag,
168
+ available_items=max(32, limit - meta - evid - diag),
169
+ estimator=self._counter.counter_id)
170
+
171
+ def _candidates(
172
+ self, req: ContextRequest, sid: int, diags: list[str]
173
+ ) -> list[ContextItem]:
174
+ """lf-0052: hybrid retrieval é a fonte primária de candidatos
175
+ (structural+lexical+graph+vector fusionados via RRF). Fallback
176
+ declarado: structural substring quando hybrid não retorna nada
177
+ (ex.: FTS indisponível e sem match estrutural interno)."""
178
+ items: list[ContextItem] = []
179
+ intent = classify(req.task)
180
+ hits = self._engine.search(SearchRequest(
181
+ query=req.task, strategy="hybrid",
182
+ repository_root=req.repository_root, snapshot_id=sid,
183
+ limit=req.max_symbols)).items
184
+ source = "hybrid"
185
+ if not hits:
186
+ # fallback tipado — structural substring (comportamento v1)
187
+ source = "structural-fallback"
188
+ diags.append("candidate-source:structural-fallback")
189
+ repo_id = self._structural.repo_for_root(req.repository_root)
190
+ page = self._structural.search_symbols(
191
+ repo_id or 0, sid, req.task, SearchFilters(),
192
+ req.max_symbols, 0)
193
+ from code_atlas.search.contracts import SearchHit
194
+ hits = tuple(SearchHit(
195
+ occurrence_id=int(s.get("occurrence_id", 0) or 0),
196
+ qualified_name=str(s["qualified_name"]),
197
+ kind=str(s["kind"]), language=str(s["language"]),
198
+ path=str(s.get("path", "")), span=None, score=0.0,
199
+ strategy="structural",
200
+ matched_fields=("qualified_name",))
201
+ for s in page.items)
202
+ if not hits:
203
+ diags.append("candidate-source:empty")
204
+ occ = self._occ_rows(sid, [
205
+ h.occurrence_id for h in hits if h.occurrence_id],
206
+ by="occurrence")
207
+ for h in hits:
208
+ qn = h.qualified_name
209
+ row = occ.get(h.occurrence_id, {})
210
+ sig = str(row.get("signature") or "")
211
+ doc = str(row.get("docstring") or "")
212
+ body = "\n".join(p for p in (qn, sig, doc) if p)
213
+ has_contract = bool(sig or doc)
214
+ items.append(ContextItem(
215
+ item_id=f"sym:{qn}", kind="symbol",
216
+ priority=(PRIORITY_CONTRACT if has_contract
217
+ else PRIORITY_DIRECT),
218
+ title=qn, body=body, est_tokens=0,
219
+ symbol_key=qn, path=h.path or str(
220
+ row.get("relative_path", "")),
221
+ evidence=(
222
+ PackEvidence(kind="occurrence",
223
+ ref=str(h.occurrence_id)),
224
+ PackEvidence(kind="ranking",
225
+ ref=f"{source}:{h.score:.6f}"),
226
+ PackEvidence(kind="intent",
227
+ ref=intent.task_type)),
228
+ expandable=True))
229
+ depth = {"orientation": 0, "focus": 1, "deep": 3}[req.level]
230
+ seed_ids = [h.occurrence_id for h in hits if h.occurrence_id]
231
+ if depth:
232
+ items.extend(self._neighbor_items(
233
+ sid, seed_ids, depth, direction=intent.direction))
234
+ for p in sorted({i.path for i in items if i.path}):
235
+ items.append(ContextItem(
236
+ item_id=f"file:{p}", kind="file",
237
+ priority=PRIORITY_COMPLEMENT, title=p, body=p,
238
+ est_tokens=0, path=p))
239
+ items.extend(self._dependency_items(sid, seed_ids, intent))
240
+ return items
241
+
242
+ def _occ_rows(
243
+ self, sid: int, ids: list[int], *, by: str = "symbol"
244
+ ) -> dict[int, dict[str, object]]:
245
+ """Ocorrências + signature/docstring do FTS — batched via
246
+ json_each, sem N+1. ``by="symbol"`` chaveia por
247
+ ``logical_symbol_id``; ``by="occurrence"`` por ``o.id``."""
248
+ if not ids:
249
+ return {}
250
+ key_col = ("o.logical_symbol_id" if by == "symbol" else "o.id")
251
+ sql = (
252
+ "SELECT o.id, o.logical_symbol_id, s.qualified_name,"
253
+ " sf.relative_path, f.signature, f.docstring"
254
+ " FROM symbol_occurrences o"
255
+ " JOIN symbols s ON s.id = o.logical_symbol_id"
256
+ " JOIN source_files sf ON sf.id = o.source_file_id"
257
+ " LEFT JOIN fts_occurrences f ON f.rowid = o.id"
258
+ f" WHERE o.snapshot_id = ? AND {key_col} IN"
259
+ " (SELECT value FROM json_each(?))"
260
+ " ORDER BY o.id")
261
+ rows = self._conn.execute(sql, (sid, json.dumps(ids))).fetchall()
262
+ out: dict[int, dict[str, object]] = {}
263
+ kf = "logical_symbol_id" if by == "symbol" else "id"
264
+ for r in rows:
265
+ k = int(r[kf])
266
+ if k not in out:
267
+ out[k] = dict(r)
268
+ return out
269
+
270
+ def _neighbor_items(
271
+ self, sid: int, seed_ids: list[int], depth: int,
272
+ direction: str = "both",
273
+ ) -> list[ContextItem]:
274
+ out: list[ContextItem] = []
275
+ seed_ids = seed_ids[:20]
276
+ if not seed_ids:
277
+ return out
278
+ hits, _stats = self._graph.traverse(
279
+ sid, seed_ids, direction=direction,
280
+ limits=TraversalLimits(
281
+ depth=depth, max_nodes=40, max_results=40))
282
+ for h in hits:
283
+ out.append(ContextItem(
284
+ item_id=f"nb:{h.qualified_name}",
285
+ kind="symbol", priority=PRIORITY_COMPLEMENT,
286
+ title=h.qualified_name, body=h.qualified_name,
287
+ est_tokens=0, symbol_key=h.qualified_name,
288
+ path=h.path, expandable=True))
289
+ return out
290
+
291
+ def _dependency_items(
292
+ self, sid: int, seed_ids: list[int],
293
+ intent: QueryIntent | None = None,
294
+ ) -> list[ContextItem]:
295
+ # lf-0052: intents de investigação sobem dependências comprovadas
296
+ # para prioridade de contrato (call flow/deps são o foco).
297
+ dep_priority = PRIORITY_DEP
298
+ if intent is not None and intent.task_type in (
299
+ "find-consumers", "explain-dependencies",
300
+ "investigate-failure"):
301
+ dep_priority = PRIORITY_CONTRACT
302
+ if not seed_ids:
303
+ return []
304
+ rows = self._conn.execute(
305
+ "SELECT sa.qualified_name AS src, sb.qualified_name AS dst,"
306
+ " r.type AS edge_kind FROM relations r"
307
+ " JOIN symbol_occurrences a ON a.id = r.from_id"
308
+ " LEFT JOIN symbol_occurrences b ON b.id = r.to_id"
309
+ " JOIN symbols sa ON sa.id = a.logical_symbol_id"
310
+ " LEFT JOIN symbols sb ON sb.id = b.logical_symbol_id"
311
+ " WHERE r.snapshot_id = ? AND (r.from_id IN "
312
+ "(SELECT value FROM json_each(?)) OR r.to_id IN "
313
+ "(SELECT value FROM json_each(?))) LIMIT 100",
314
+ (sid, json.dumps(seed_ids), json.dumps(seed_ids)),
315
+ ).fetchall()
316
+ return [ContextItem(
317
+ item_id=f"dep:{r['src']}->{r['edge_kind']}->{r['dst']}",
318
+ kind="dependency", priority=dep_priority,
319
+ title=f"{r['src']} -[{r['edge_kind']}]-> {r['dst']}",
320
+ body=f"{r['src']} {r['edge_kind']} {r['dst']}",
321
+ est_tokens=0,
322
+ evidence=(PackEvidence(kind="relation",
323
+ ref=str(r["edge_kind"])),))
324
+ for r in rows if r["dst"]]
325
+
326
+ def _estimate(self, items: list[ContextItem]) -> list[ContextItem]:
327
+ return [i if i.est_tokens else ContextItem(
328
+ item_id=i.item_id, kind=i.kind, priority=i.priority,
329
+ title=i.title, body=i.body,
330
+ est_tokens=self._counter.count(i.body),
331
+ symbol_key=i.symbol_key, path=i.path,
332
+ evidence=i.evidence, expandable=i.expandable)
333
+ for i in items]
334
+
335
+ def _dedup(self, items: list[ContextItem]) -> list[ContextItem]:
336
+ seen: set[str] = set()
337
+ out: list[ContextItem] = []
338
+ for i in items:
339
+ key = f"{i.kind}:{i.item_id}"
340
+ if key in seen:
341
+ continue
342
+ seen.add(key)
343
+ out.append(i)
344
+ return out
345
+
346
+ def _fit(
347
+ self, items: list[ContextItem], available: int
348
+ ) -> tuple[list[ContextItem], list[ContextItem]]:
349
+ sel: list[ContextItem] = []
350
+ omit: list[ContextItem] = []
351
+ used = 0
352
+ for i in items:
353
+ if used + i.est_tokens <= available:
354
+ sel.append(i)
355
+ used += i.est_tokens
356
+ else:
357
+ omit.append(i)
358
+ return sel, omit
359
+
360
+ def _pack(
361
+ self, req: ContextRequest | DomainPackRequest, sid: int,
362
+ budget: Budget,
363
+ selected: list[ContextItem], omitted: list[ContextItem],
364
+ essentials_cost: int, insufficient: bool,
365
+ diags: list[str], snap_status: str,
366
+ *, domain_factory: DomainSectionFactory | None = None,
367
+ ) -> ContextPack:
368
+ codes: list[str] = list(diags)
369
+ if omitted:
370
+ codes.append("pack-truncated")
371
+ if insufficient:
372
+ codes.extend(("insufficient-budget",
373
+ "mandatory-context-omitted"))
374
+ # lf-0121: a fábrica produz a seção de domínio + refs extras a
375
+ # partir dos selecionados desta iteração — contada no payload.
376
+ part = domain_factory(tuple(selected)) if domain_factory else None
377
+ expansion: list[ExpansionRef] = [
378
+ ExpansionRef(ref_id=i.item_id, kind=i.kind,
379
+ reason="omitido por budget ou nível")
380
+ for i in omitted if i.expandable]
381
+ unresolved: tuple[str, ...] = ()
382
+ ranking = (
383
+ "prioridade: evidências > diretos > contratos > deps > "
384
+ "snippets > complementar > transitivos; dentro da "
385
+ "classe, item_id estável")
386
+ if part is not None:
387
+ expansion.extend(part.extra_expansion_refs)
388
+ unresolved = part.extra_unresolved
389
+ if part.ranking_explanation:
390
+ ranking = part.ranking_explanation
391
+ manifest = PackManifest(
392
+ schema_version=PACK_SCHEMA_VERSION,
393
+ repository=req.repository_root, snapshot_id=sid,
394
+ level=req.level, estimator=budget.estimator,
395
+ tokens_estimated=True,
396
+ selected=len(selected), omitted=len(omitted),
397
+ truncated=bool(omitted),
398
+ freshness=f"snapshot:{sid} status:{snap_status}",
399
+ ranking_explanation=ranking)
400
+ diagnostics = PackDiagnostics(
401
+ codes=tuple(codes), messages=tuple(diags),
402
+ insufficient_budget=insufficient,
403
+ mandatory_context_omitted=insufficient,
404
+ suggested_minimum_budget=(
405
+ essentials_cost + budget.limit - budget.available_items
406
+ if insufficient else 0))
407
+ return ContextPack(
408
+ manifest=manifest, budget=budget,
409
+ items=tuple(selected),
410
+ selected_symbols=tuple(
411
+ i.symbol_key for i in selected
412
+ if i.kind == "symbol" and i.symbol_key),
413
+ relevant_files=tuple(sorted(
414
+ {i.path for i in selected if i.path})),
415
+ dependencies=tuple(
416
+ ev for i in selected if i.kind == "dependency"
417
+ for ev in i.evidence),
418
+ unresolved_refs=unresolved,
419
+ expansion_refs=tuple(expansion),
420
+ diagnostics=diagnostics,
421
+ domain=part.section if part is not None else None)
422
+
423
+ def _empty(
424
+ self, req: ContextRequest | DomainPackRequest, msg: str
425
+ ) -> ContextPack:
426
+ budget = self._budget(req)
427
+ manifest = PackManifest(
428
+ schema_version=PACK_SCHEMA_VERSION,
429
+ repository=req.repository_root, snapshot_id=0,
430
+ level=req.level, estimator=budget.estimator,
431
+ tokens_estimated=True, selected=0, omitted=0,
432
+ truncated=False, freshness="none",
433
+ ranking_explanation="vazio — resolução falhou")
434
+ return ContextPack(
435
+ manifest=manifest, budget=budget, items=(),
436
+ selected_symbols=(), relevant_files=(), dependencies=(),
437
+ unresolved_refs=(), expansion_refs=(),
438
+ diagnostics=PackDiagnostics(
439
+ codes=("resolve-failed",), messages=(msg,),
440
+ insufficient_budget=False,
441
+ mandatory_context_omitted=False,
442
+ suggested_minimum_budget=0))
@@ -0,0 +1,133 @@
1
+ """Expansão progressiva de contexto (lf-0041 / Cycle 4 Ph8).
2
+
3
+ Autocontida: o caller passa o ``snapshot_id`` do pack original + os
4
+ ``item_ids`` já entregues — nenhum estado global. Se o snapshot não é
5
+ mais READY, erro explícito; NUNCA troca de revisão silenciosamente.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import sqlite3
12
+ from dataclasses import dataclass
13
+
14
+ from code_atlas.context.contracts import (
15
+ PRIORITY_DEP,
16
+ ContextItem,
17
+ ExpansionRef,
18
+ PackEvidence,
19
+ )
20
+ from code_atlas.context.tokenizer import HeuristicTokenizer, TokenCounter
21
+ from code_atlas.search.graph import GraphRetrieval, TraversalLimits
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class ExpansionResult:
26
+ """Novos itens + dedup acumulada + refs restantes."""
27
+
28
+ snapshot_id: int
29
+ added: tuple[ContextItem, ...]
30
+ already_delivered: tuple[str, ...]
31
+ remaining_refs: tuple[ExpansionRef, ...]
32
+ truncated: bool
33
+ error: str = ""
34
+
35
+
36
+ class ContextExpander:
37
+ """Expande símbolo/deps/refs dentro do MESMO snapshot do pack."""
38
+
39
+ def __init__(
40
+ self, conn: sqlite3.Connection, counter: TokenCounter | None = None
41
+ ) -> None:
42
+ self._conn = conn
43
+ self._graph = GraphRetrieval(conn)
44
+ self._counter = counter or HeuristicTokenizer()
45
+
46
+ def expand(
47
+ self,
48
+ snapshot_id: int,
49
+ symbol: str,
50
+ *,
51
+ delivered: frozenset[str] = frozenset(),
52
+ budget_tokens: int = 4000,
53
+ depth: int = 2,
54
+ ) -> ExpansionResult:
55
+ """Dependências+referências do símbolo no snapshot — dedup contra
56
+ ``delivered``; respeita a mesma disciplina de budget."""
57
+ if not self._snapshot_ready(snapshot_id):
58
+ return ExpansionResult(
59
+ snapshot_id=snapshot_id, added=(), already_delivered=(),
60
+ remaining_refs=(), truncated=False,
61
+ error=f"snapshot {snapshot_id} indisponível (não READY)")
62
+ occ = self._occurrence_id(snapshot_id, symbol)
63
+ if occ is None:
64
+ return ExpansionResult(
65
+ snapshot_id=snapshot_id, added=(), already_delivered=(),
66
+ remaining_refs=(), truncated=False,
67
+ error=f"símbolo sem ocorrência no snapshot: {symbol}")
68
+ hits, _st = self._graph.traverse(
69
+ snapshot_id, [occ], direction="both",
70
+ limits=TraversalLimits(depth=depth, max_nodes=50,
71
+ max_results=50))
72
+ budget = max(64, budget_tokens)
73
+ used = added_n = 0
74
+ added: list[ContextItem] = []
75
+ dup: list[str] = []
76
+ remaining: list[ExpansionRef] = []
77
+ for h in hits:
78
+ iid = f"nb:{h.qualified_name}"
79
+ item = ContextItem(
80
+ item_id=iid, kind="symbol", priority=PRIORITY_DEP,
81
+ title=h.qualified_name,
82
+ body="\n".join(
83
+ f"{s.from_id}-[{s.relation}]->{s.to_id}"
84
+ for s in h.steps) or h.qualified_name,
85
+ est_tokens=0, symbol_key=h.qualified_name, path=h.path,
86
+ evidence=tuple(PackEvidence(
87
+ kind="relation", ref=s.relation) for s in h.steps),
88
+ expandable=True)
89
+ item = ContextItem(
90
+ **{**item.__dict__,
91
+ "est_tokens": self._counter.count(item.body)})
92
+ if iid in delivered:
93
+ dup.append(iid)
94
+ continue
95
+ if used + item.est_tokens <= budget:
96
+ added.append(item)
97
+ used += item.est_tokens
98
+ added_n += 1
99
+ else:
100
+ remaining.append(ExpansionRef(
101
+ ref_id=iid, kind="symbol",
102
+ reason="fora do budget da expansão"))
103
+ return ExpansionResult(
104
+ snapshot_id=snapshot_id, added=tuple(added),
105
+ already_delivered=tuple(dup),
106
+ remaining_refs=tuple(remaining),
107
+ truncated=bool(remaining))
108
+
109
+ def _snapshot_ready(self, snapshot_id: int) -> bool:
110
+ row = self._conn.execute(
111
+ "SELECT status FROM snapshots WHERE id = ?",
112
+ (snapshot_id,)).fetchone()
113
+ return bool(row) and str(row["status"]).endswith("READY")
114
+
115
+ def _occurrence_id(self, snapshot_id: int, symbol: str) -> int | None:
116
+ row = self._conn.execute(
117
+ "SELECT o.id FROM symbol_occurrences o"
118
+ " JOIN symbols s ON s.id = o.logical_symbol_id"
119
+ " WHERE o.snapshot_id = ? AND (s.qualified_name = ?"
120
+ " OR s.qualified_name LIKE ?) ORDER BY o.id LIMIT 1",
121
+ (snapshot_id, symbol, f"%.{symbol}"),
122
+ ).fetchone()
123
+ return int(row["id"]) if row else None
124
+
125
+
126
+ def expansion_to_json(res: ExpansionResult) -> str:
127
+ """Envelope parseável do expand — schema v1."""
128
+ import dataclasses
129
+
130
+ return json.dumps({
131
+ "schema_version": 1, "renderer_version": "expand-json-v1",
132
+ **{k: v for k, v in dataclasses.asdict(res).items()},
133
+ }, sort_keys=True)
@@ -0,0 +1,71 @@
1
+ """Renderers versionados do ContextPack — JSON + Markdown (lf-0040)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ from code_atlas.context.contracts import ContextPack
9
+
10
+ RENDERER_VERSION_JSON = "pack-json-v1"
11
+ RENDERER_VERSION_MD = "pack-markdown-v1"
12
+
13
+
14
+ def render_json(pack: ContextPack) -> str:
15
+ """Objeto único parseável com renderer_version embutida."""
16
+ d: dict[str, Any] = pack.to_dict()
17
+ d["renderer_version"] = RENDERER_VERSION_JSON
18
+ return json.dumps(d, sort_keys=True)
19
+
20
+
21
+ def render_markdown(pack: ContextPack) -> str:
22
+ """Markdown determinístico — mesma ordem de itens do pack."""
23
+ m = pack.manifest
24
+ lines = [
25
+ "<!-- renderer: pack-markdown-v1 -->",
26
+ f"# Context Pack — snapshot {m.snapshot_id} ({m.level})",
27
+ "",
28
+ f"- budget: {pack.budget.available_items}/"
29
+ f"{pack.budget.limit} tokens estimados ({m.estimator})",
30
+ f"- selected: {m.selected} · omitted: {m.omitted} · "
31
+ f"truncated: {str(m.truncated).lower()}",
32
+ f"- freshness: {m.freshness}",
33
+ ]
34
+ if pack.diagnostics.codes:
35
+ lines.append(
36
+ f"- diagnostics: {', '.join(pack.diagnostics.codes)}")
37
+ if pack.diagnostics.suggested_minimum_budget:
38
+ lines.append(
39
+ f"- suggested_minimum_budget: "
40
+ f"{pack.diagnostics.suggested_minimum_budget}")
41
+ lines.append("")
42
+ for item in pack.items:
43
+ lines.append(f"## {item.title}")
44
+ lines.append(f"_{item.kind} · prio={item.priority} · "
45
+ f"~{item.est_tokens} tok_")
46
+ lines.append("")
47
+ lines.append("```")
48
+ lines.append(item.body)
49
+ lines.append("```")
50
+ lines.append("")
51
+ if pack.expansion_refs:
52
+ lines.append("## Expandable refs")
53
+ for r in pack.expansion_refs:
54
+ lines.append(f"- `{r.ref_id}` ({r.kind}) — {r.reason}")
55
+ lines.append("")
56
+ if pack.domain is not None:
57
+ d = pack.domain
58
+ lines.append(f"## Domain — {d.status} "
59
+ f"(schema v{d.schema_version})")
60
+ if d.entity is not None:
61
+ e = d.entity
62
+ lines.append(
63
+ f"- {e.kind} `{e.identity_key}` — {e.qualified_name}")
64
+ lines.append(
65
+ f"- neighbors: {len(d.neighbors)}/{d.neighbors_total} "
66
+ f"(omitted {d.neighbors_omitted}) · "
67
+ f"counts: {dict(sorted(d.counts.items()))}")
68
+ if d.summary:
69
+ lines.append(f"- lineage: {d.summary}")
70
+ lines.append("")
71
+ return "\n".join(lines)
@@ -0,0 +1,37 @@
1
+ """Estimador de tokens (lf-0040).
2
+
3
+ Heurístico por default — ``estimate`` NUNCA é contagem exata (declarado
4
+ em ``tokens_estimated=True`` e no id do estimador). Seam
5
+ ``TokenCounter`` permite adapter real opt-in sem mudar o engine.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Protocol
11
+
12
+
13
+ class TokenCounter(Protocol):
14
+ """Conta/estima tokens de um texto."""
15
+
16
+ counter_id: str
17
+
18
+ def count(self, text: str) -> int: ...
19
+
20
+
21
+ class HeuristicTokenizer:
22
+ """chars/token fixo (default 4.0) — estimativa declarada, honesta.
23
+
24
+ Não é tokenizer de nenhum modelo: superestima texto ASCII denso,
25
+ subestima identificadores longos — erro uniforme e documentado.
26
+ """
27
+
28
+ counter_id = "heuristic-chars4"
29
+
30
+ def __init__(self, chars_per_token: float = 4.0) -> None:
31
+ if chars_per_token <= 0:
32
+ raise ValueError("chars_per_token deve ser > 0")
33
+ self._cpt = chars_per_token
34
+ self.counter_id = f"heuristic-chars{chars_per_token:g}"
35
+
36
+ def count(self, text: str) -> int:
37
+ return max(1, int(len(text) / self._cpt + 0.5))