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,392 @@
1
+ """Orquestrador do Impact Engine (Phases 1-7 reunidas).
2
+
3
+ Pipeline: ChangeSet (git) → symbol_diff (snapshots) → contract
4
+ analysis → blast radius → test selection → risk. Sem snapshot READY
5
+ comparável → diagnóstico explícito, nunca fallback silencioso.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import sqlite3
12
+ from dataclasses import replace
13
+ from pathlib import Path
14
+
15
+ from code_atlas.impact import (
16
+ contract_analyzer,
17
+ diff_reader,
18
+ domain_traversal,
19
+ revision_binding,
20
+ risk_classifier,
21
+ symbol_diff,
22
+ test_selector,
23
+ )
24
+ from code_atlas.impact.contracts import (
25
+ ANALYZER_VERSION,
26
+ IMPACT_SCHEMA_VERSION,
27
+ EvidenceStrength,
28
+ ImpactCandidate,
29
+ ImpactDiagnostic,
30
+ ImpactRequest,
31
+ ImpactResult,
32
+ SymbolChange,
33
+ SymbolChangeKind,
34
+ )
35
+ from code_atlas.impact.revision_binding import ImpactRevisionPlan
36
+ from code_atlas.impact.snapshot_resolver import (
37
+ ImpactSnapshotResolver,
38
+ ResolvedImpactSnapshots,
39
+ )
40
+
41
+ # cap do lookup de seeds what-if por símbolo — declarado como
42
+ # ``symbol-seed-limit`` quando cheio (lf-0151).
43
+ _SYMBOL_SEED_LIMIT = 64
44
+
45
+
46
+ class ImpactEngine:
47
+ def __init__(self, conn: sqlite3.Connection) -> None:
48
+ self._conn = conn
49
+ conn.row_factory = sqlite3.Row
50
+
51
+ def _resolve_snapshots(
52
+ self, req: ImpactRequest, root: Path,
53
+ diags: list[ImpactDiagnostic],
54
+ ) -> ResolvedImpactSnapshots | None:
55
+ resolved, d = ImpactSnapshotResolver(self._conn).resolve(req, root)
56
+ diags.extend(d)
57
+ return resolved
58
+
59
+ def _bind_revisions(
60
+ self, req: ImpactRequest, root: Path,
61
+ pair: ResolvedImpactSnapshots, diags: list[ImpactDiagnostic],
62
+ ) -> ImpactRevisionPlan:
63
+ """lf-0077: binding vira plano — MISMATCH/UNAVAILABLE bloqueia
64
+ a análise semântica (Phase 1 hard invariant)."""
65
+ p = revision_binding.plan(
66
+ self._conn, root, pair.repository_id,
67
+ req.base_revision, req.target_revision,
68
+ pair.base_snapshot_id, pair.target_snapshot_id)
69
+ diags.extend(p.diagnostics)
70
+ return p
71
+
72
+ def _reconcile(
73
+ self, removed_impacts: tuple[ImpactCandidate, ...],
74
+ sym_changes: tuple[SymbolChange, ...],
75
+ target_sid: int, repo_id: int,
76
+ ) -> tuple[ImpactCandidate, ...]:
77
+ """lf-0078: classifica o consumidor histórico no destino.
78
+
79
+ still_present | consumer_modified | also_removed |
80
+ unresolved_in_target — existência histórica separada do risco.
81
+ lf-0120: candidatos de domínio reconciliam por ``identity_key``
82
+ (presença de ocorrência no alvo / row do repositório).
83
+ """
84
+ if not removed_impacts:
85
+ return removed_impacts
86
+ target_keys = {
87
+ f"{r['language']}:{r['kind']}:{r['qualified_name']}"
88
+ for r in self._conn.execute(
89
+ "SELECT s.language, s.kind, s.qualified_name"
90
+ " FROM symbol_occurrences o JOIN symbols s"
91
+ " ON s.id = o.logical_symbol_id"
92
+ " WHERE o.snapshot_id = ?", (target_sid,))}
93
+ removed_keys = {s.symbol_key for s in sym_changes
94
+ if s.change == SymbolChangeKind.REMOVED}
95
+ changed_keys = {s.symbol_key for s in sym_changes}
96
+ domain_states: dict[str, set[str]] = {"still_present": set(),
97
+ "repo": set()}
98
+ if any(i.node_type == "domain" for i in removed_impacts):
99
+ domain_states["still_present"] = {
100
+ str(r["identity_key"]) for r in self._conn.execute(
101
+ "SELECT DISTINCT de.identity_key"
102
+ " FROM domain_entity_occurrences deo"
103
+ " JOIN domain_entities de ON de.id = deo.entity_id"
104
+ " WHERE deo.snapshot_id = ?", (target_sid,))}
105
+ domain_states["repo"] = {
106
+ str(r["identity_key"]) for r in self._conn.execute(
107
+ "SELECT identity_key FROM domain_entities"
108
+ " WHERE repository_id = ?", (repo_id,))}
109
+ out = []
110
+ for i in removed_impacts:
111
+ if i.node_type == "domain":
112
+ if i.symbol_key in domain_states["still_present"]:
113
+ state = "still_present"
114
+ elif i.symbol_key in domain_states["repo"]:
115
+ state = "also_removed"
116
+ else:
117
+ state = "unresolved_in_target"
118
+ elif i.symbol_key in removed_keys:
119
+ state = "also_removed"
120
+ elif i.symbol_key in changed_keys:
121
+ state = "consumer_modified"
122
+ elif i.symbol_key in target_keys:
123
+ state = "still_present"
124
+ else:
125
+ state = "unresolved_in_target"
126
+ out.append(replace(i, consumer_state=state))
127
+ return tuple(out)
128
+
129
+ def _blast(
130
+ self, req: ImpactRequest, pair: ResolvedImpactSnapshots,
131
+ sym_changes: tuple[SymbolChange, ...],
132
+ base_sid: int, target_sid: int,
133
+ unknown: tuple[str, ...],
134
+ diags: list[ImpactDiagnostic],
135
+ ) -> tuple[
136
+ tuple[ImpactCandidate, ...], set[str], tuple[str, ...]]:
137
+ """Passo 6: blast misto (código + domínio) nos dois snapshots.
138
+
139
+ Origens: ocorrências vivas de símbolos alterados no alvo;
140
+ REMOVIDAS na base (lf-0074). lf-0120 soma seeds de entidade —
141
+ ``entity`` explícito; ``symbol`` também resolve refs de
142
+ entidade (fiação mínima — handlers que já passam
143
+ ``symbol``/``ident`` aceitam seeds de domínio sem mudança de
144
+ assinatura). Retorna ``(impactos, razões_de_truncamento,
145
+ unknown_coverage)`` — lf-0151: cada bound que descartou
146
+ trabalho entra no conjunto de razões, nunca silencioso.
147
+ """
148
+ reasons: set[str] = set()
149
+ origins_target, origins_base = self._origins(sym_changes)
150
+ # lf-0120: ``symbol`` também é seed de símbolo — "se ESTE
151
+ # símbolo mudar" cobre ocorrências não-alteradas (what-if),
152
+ # não só as que o diff marcou como origens.
153
+ if req.symbol:
154
+ seeds_sym, seed_capped = self._symbol_seeds(
155
+ req.symbol, target_sid)
156
+ if seed_capped:
157
+ reasons.add("symbol-seed-limit")
158
+ diags.append(ImpactDiagnostic(
159
+ "symbol-seed-limit",
160
+ f"lookup de seeds por símbolo atingiu o cap de "
161
+ f"{_SYMBOL_SEED_LIMIT} ocorrências — ocorrências "
162
+ "além do cap não viraram origem de blast"))
163
+ for key, occ in seeds_sym.items():
164
+ origins_target.setdefault(key, occ)
165
+
166
+ refs: list[tuple[str, bool]] = []
167
+ if req.entity:
168
+ refs.append((req.entity, True))
169
+ if req.symbol:
170
+ refs.append((req.symbol, False))
171
+ seeds = domain_traversal.resolve_seeds(
172
+ self._conn, pair.repository_id, tuple(refs),
173
+ base_sid, target_sid)
174
+ diags.extend(seeds.diagnostics)
175
+
176
+ # Estado dos dados de domínio no alvo — flag de ausência
177
+ # honesta (spec: parcial marcado, nunca erro silencioso).
178
+ unknown_list = list(unknown) + self._domain_flags(target_sid)
179
+
180
+ t_out = domain_traversal.blast(
181
+ self._conn, target_sid,
182
+ sym_origins=origins_target,
183
+ ent_origins=seeds.target_origins, limits=req.limits)
184
+ impacts = t_out.candidates
185
+ reasons |= set(t_out.reasons)
186
+ unc = dict(t_out.uncertain)
187
+ cross_snap = t_out.cross_snapshot
188
+ removed_impacts: tuple[ImpactCandidate, ...] = ()
189
+ if base_sid != target_sid and (origins_base or
190
+ seeds.base_origins):
191
+ r_out = domain_traversal.blast(
192
+ self._conn, base_sid,
193
+ sym_origins=origins_base,
194
+ ent_origins=seeds.base_origins, limits=req.limits,
195
+ reason_suffix=" (base snapshot — símbolo removido)")
196
+ removed_impacts = r_out.candidates
197
+ reasons |= set(r_out.reasons)
198
+ cross_snap += r_out.cross_snapshot
199
+ for st, n in r_out.uncertain.items():
200
+ unc[st] = unc.get(st, 0) + n
201
+ removed_impacts = self._reconcile(
202
+ removed_impacts, sym_changes, target_sid,
203
+ pair.repository_id)
204
+ impacts = impacts + removed_impacts
205
+ for st in sorted(unc):
206
+ unknown_list.append(f"domain-{st}-deps:{unc[st]}")
207
+ if cross_snap:
208
+ unknown_list.append(f"domain-cross-snapshot:{cross_snap}")
209
+ return impacts, reasons, tuple(unknown_list)
210
+
211
+ @staticmethod
212
+ def _origins(
213
+ sym_changes: tuple[SymbolChange, ...],
214
+ ) -> tuple[dict[str, int], dict[str, int]]:
215
+ """``(origens no alvo, origens na base)`` a partir do diff."""
216
+ target: dict[str, int] = {}
217
+ base: dict[str, int] = {}
218
+ for s in sym_changes:
219
+ if s.change == SymbolChangeKind.REMOVED:
220
+ if s.old_occurrence_id is not None:
221
+ base[s.symbol_key] = s.old_occurrence_id
222
+ else:
223
+ occ = s.new_occurrence_id or s.old_occurrence_id
224
+ if occ is not None:
225
+ target[s.symbol_key] = occ
226
+ return target, base
227
+
228
+ def _symbol_seeds(
229
+ self, ref: str, target_sid: int,
230
+ ) -> tuple[dict[str, int], bool]:
231
+ """Ocorrências-alvo cujo ``symbol_key``/``qualified_name``
232
+ casa ``ref`` — seeds de símbolo para análise what-if.
233
+
234
+ Retorna ``(seeds, atingiu_cap)``: o lookup é bounded por
235
+ ``_SYMBOL_SEED_LIMIT`` com uma linha de sonda +1 — só overflow
236
+ real declara o cap (ocorrências além do prefixo
237
+ ``ORDER BY o.id`` nunca viraram origem, lf-0151)."""
238
+ out: dict[str, int] = {}
239
+ rows = self._conn.execute(
240
+ "SELECT o.id AS occ, s.language, s.kind,"
241
+ " s.qualified_name FROM symbol_occurrences o"
242
+ " JOIN symbols s ON s.id = o.logical_symbol_id"
243
+ " WHERE o.snapshot_id = ? AND ("
244
+ " s.language || ':' || s.kind || ':'"
245
+ " || s.qualified_name = ?"
246
+ " OR instr(s.qualified_name, ?) > 0)"
247
+ " ORDER BY o.id LIMIT ?",
248
+ (target_sid, ref, ref,
249
+ _SYMBOL_SEED_LIMIT + 1)).fetchall()
250
+ capped = len(rows) > _SYMBOL_SEED_LIMIT
251
+ for r in rows[:_SYMBOL_SEED_LIMIT]:
252
+ key = (f"{r['language']}:{r['kind']}:"
253
+ f"{r['qualified_name']}")
254
+ out.setdefault(key, int(r["occ"]))
255
+ return out, capped
256
+
257
+ def _domain_flags(self, target_sid: int) -> list[str]:
258
+ """Flag de ausência/parcialidade dos dados de domínio."""
259
+ dstate = domain_traversal.domain_state(
260
+ self._conn, target_sid, self._snapshot_versions(target_sid))
261
+ if dstate == "adapters-disabled":
262
+ return ["domain-adapters-disabled"]
263
+ if dstate == "empty":
264
+ return ["domain-data-empty"]
265
+ return []
266
+
267
+ def _snapshot_versions(self, sid: int) -> dict[str, str]:
268
+ """``analyzer_versions`` de um snapshot — ``{}`` se corrompido."""
269
+ row = self._conn.execute(
270
+ "SELECT analyzer_versions FROM snapshots WHERE id = ?",
271
+ (sid,)).fetchone()
272
+ if row is None:
273
+ return {}
274
+ try:
275
+ v = json.loads(str(row["analyzer_versions"] or "{}"))
276
+ except json.JSONDecodeError:
277
+ return {}
278
+ if not isinstance(v, dict):
279
+ return {}
280
+ return {str(k): str(x) for k, x in v.items()}
281
+
282
+ def analyze(self, req: ImpactRequest) -> ImpactResult:
283
+ diags: list[ImpactDiagnostic] = []
284
+ root = Path(req.repository_root)
285
+
286
+ # 1) ChangeSet via git (se revisões informadas).
287
+ change_set = None
288
+ if req.base_revision:
289
+ change_set, d = diff_reader.read_diff(
290
+ root, req.base_revision, req.target_revision or None)
291
+ diags.extend(d)
292
+
293
+ # 2) Snapshots — SEMPRE escopados ao repositório do root.
294
+ pair = self._resolve_snapshots(req, root, diags)
295
+ if pair is None:
296
+ return self._empty(req, diags)
297
+ base_sid = pair.base_snapshot_id
298
+ target_sid = pair.target_snapshot_id
299
+
300
+ rev_plan = self._bind_revisions(req, root, pair, diags)
301
+ if not rev_plan.semantic_analysis_allowed:
302
+ # Phase 1: ChangeSet Git-only preservado; sem SymbolDiff/
303
+ # Blast/TestSelector sob aparência de resultado válido.
304
+ return ImpactResult(
305
+ schema_version=IMPACT_SCHEMA_VERSION,
306
+ analyzer_version=ANALYZER_VERSION,
307
+ repository=req.repository_root,
308
+ base_revision=req.base_revision or pair.base_revision
309
+ or f"snapshot:{base_sid}",
310
+ target_revision=req.target_revision or pair.target_revision
311
+ or f"snapshot:{target_sid}",
312
+ base_snapshot_id=base_sid, target_snapshot_id=target_sid,
313
+ change_set=change_set, symbol_changes=(),
314
+ contract_changes=(), impacts=(), test_candidates=(),
315
+ risk=risk_classifier.assess((), (), (), ()),
316
+ unknown_coverage=("semantic-blocked",),
317
+ truncated=False, diagnostics=tuple(diags))
318
+
319
+ # 3) Symbol diff.
320
+ sym_changes, unknown, diff_trunc = symbol_diff.diff_snapshots(
321
+ self._conn, base_sid, target_sid,
322
+ max_changes=req.limits.max_changes)
323
+ reasons: set[str] = {"max-changes"} if diff_trunc else set()
324
+
325
+ # 4) Seleção explícita de símbolo restringe a análise.
326
+ if req.symbol:
327
+ sym_changes = tuple(
328
+ s for s in sym_changes
329
+ if req.symbol in s.qualified_name)
330
+
331
+ # 5) Contracts.
332
+ contract_changes = contract_analyzer.analyze(sym_changes)
333
+
334
+ # 6) Blast radius: origens vivas no alvo; REMOVIDAS na base
335
+ # (lf-0074 — a ocorrência atual não existe mais). lf-0120: o
336
+ # blast é misto — relações de código + relações de domínio —
337
+ # e seeds podem ser entidades (``req.entity``/``req.symbol``).
338
+ impacts, b_reasons, unknown = self._blast(
339
+ req, pair, sym_changes, base_sid, target_sid, unknown,
340
+ diags)
341
+ reasons |= b_reasons
342
+ if req.known_only or not req.include_possible:
343
+ impacts = tuple(
344
+ i for i in impacts
345
+ if i.strength == EvidenceStrength.VERIFIED)
346
+
347
+ # 7) Testes + risco. lf-0151: o cap de candidatos também é
348
+ # bound declarado — saída cheia não prova que não havia mais.
349
+ tests = test_selector.select(
350
+ self._conn, target_sid, sym_changes, impacts,
351
+ base_snapshot_id=base_sid)
352
+ if len(tests) >= test_selector.MAX_TEST_CANDIDATES:
353
+ reasons.add("test-candidates")
354
+ risk = risk_classifier.assess(
355
+ sym_changes, contract_changes, impacts, unknown)
356
+ truncation_reasons = tuple(sorted(reasons))
357
+
358
+ return ImpactResult(
359
+ schema_version=IMPACT_SCHEMA_VERSION,
360
+ analyzer_version=ANALYZER_VERSION,
361
+ repository=req.repository_root,
362
+ base_revision=(req.base_revision or pair.base_revision
363
+ or f"snapshot:{base_sid}"),
364
+ target_revision=(req.target_revision or pair.target_revision
365
+ or f"snapshot:{target_sid}"),
366
+ base_snapshot_id=base_sid, target_snapshot_id=target_sid,
367
+ change_set=change_set,
368
+ symbol_changes=sym_changes,
369
+ contract_changes=contract_changes,
370
+ impacts=impacts,
371
+ test_candidates=tests,
372
+ risk=risk,
373
+ unknown_coverage=unknown,
374
+ truncated=bool(truncation_reasons),
375
+ diagnostics=tuple(diags),
376
+ truncation_reasons=truncation_reasons)
377
+
378
+ def _empty(
379
+ self, req: ImpactRequest, diags: list[ImpactDiagnostic],
380
+ ) -> ImpactResult:
381
+ return ImpactResult(
382
+ schema_version=IMPACT_SCHEMA_VERSION,
383
+ analyzer_version=ANALYZER_VERSION,
384
+ repository=req.repository_root,
385
+ base_revision=req.base_revision,
386
+ target_revision=req.target_revision,
387
+ base_snapshot_id=None, target_snapshot_id=None,
388
+ change_set=None, symbol_changes=(),
389
+ contract_changes=(), impacts=(), test_candidates=(),
390
+ risk=risk_classifier.assess((), (), (), ()),
391
+ unknown_coverage=("no-snapshot",),
392
+ truncated=False, diagnostics=tuple(diags))
@@ -0,0 +1,128 @@
1
+ """Impact Context Pack (Phase 8) — seleção budget-aware dos achados.
2
+
3
+ Prioridades do prompt (estritas):
4
+ 1. alteração observada → 2. contrato afetado → 3. consumidores diretos
5
+ → 4. caminhos de evidência → 5. testes → 6. transitivos → 7. resto.
6
+ Conclusão crítica truncada é sempre declarada (``critical-omitted``).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+
13
+ from code_atlas.context.tokenizer import HeuristicTokenizer
14
+ from code_atlas.impact.contracts import ImpactKind, ImpactResult
15
+
16
+ _P_CHANGE = 10
17
+ _P_CONTRACT = 20
18
+ _P_DIRECT = 30
19
+ _P_PATH = 40
20
+ _P_TEST = 50
21
+ _P_TRANSITIVE = 60
22
+ _P_MISC = 90
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class ImpactPackItem:
27
+ item_id: str
28
+ kind: str
29
+ priority: int
30
+ title: str
31
+ body: str
32
+ est_tokens: int
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class ImpactContextPack:
37
+ schema_version: int
38
+ budget_tokens: int
39
+ tokens_used: int
40
+ items: tuple[ImpactPackItem, ...]
41
+ omitted: tuple[str, ...]
42
+ diagnostics: tuple[str, ...]
43
+ truncated: bool
44
+
45
+
46
+ def _items(res: ImpactResult, tok: HeuristicTokenizer) -> list[ImpactPackItem]:
47
+ out: list[ImpactPackItem] = []
48
+
49
+ def add(kind: str, prio: int, title: str, body: str,
50
+ iid: str) -> None:
51
+ est = int(tok.count(title + " " + body))
52
+ out.append(ImpactPackItem(iid, kind, prio, title, body, est))
53
+
54
+ for s in res.symbol_changes:
55
+ add("change", _P_CHANGE,
56
+ f"{s.change.value}: {s.qualified_name}",
57
+ f"{s.old_signature or ''} -> {s.new_signature or ''} "
58
+ f"{s.detail}",
59
+ f"chg:{s.symbol_key}")
60
+ for c in res.contract_changes:
61
+ add("contract", _P_CONTRACT,
62
+ f"{c.classification}: {c.symbol_key}",
63
+ f"{c.compatibility} — {c.detail}",
64
+ f"contract:{c.symbol_key}")
65
+ direct = [i for i in res.impacts
66
+ if i.impact == ImpactKind.DIRECT]
67
+ for i in direct:
68
+ body = (f"{i.kind} em {i.source_path or '?'}")
69
+ add("consumer", _P_DIRECT, f"direct: {i.qualified_name}", body,
70
+ f"imp:{i.symbol_key}")
71
+ if i.path:
72
+ # lf-0120: ``edge_refs`` é a proveniência por hop
73
+ # ("rel:<id>" código / "dom:<id>" domínio) — os ids
74
+ # numéricos de ``relation_ids`` cobrem só arestas de código.
75
+ refs = i.path.edge_refs or tuple(
76
+ f"rel:{r}" for r in i.path.relation_ids)
77
+ add("path", _P_PATH,
78
+ f"path {i.path.origin_symbol_key} -> "
79
+ f"{i.path.affected_symbol_key}",
80
+ f"hops={i.path.hops} edges={refs} "
81
+ f"verified={i.path.fully_verified}",
82
+ f"path:{i.symbol_key}")
83
+ for i in res.impacts:
84
+ if i.impact != ImpactKind.DIRECT:
85
+ add("consumer", _P_TRANSITIVE,
86
+ f"{i.impact.value}: {i.qualified_name}",
87
+ i.source_path or "", f"imp:{i.symbol_key}")
88
+ for t in res.test_candidates:
89
+ add("test", _P_TEST, f"test: {t.test_file}",
90
+ f"{t.relationship} — {t.reason}", f"test:{t.test_file}")
91
+ risk_body = "; ".join(res.risk.reasons) or res.risk.category.value
92
+ add("risk", _P_MISC, f"risk: {res.risk.category.value}",
93
+ risk_body, "risk:summary")
94
+ for u in res.unknown_coverage:
95
+ add("unknown", _P_MISC, f"unknown: {u}", "", f"unk:{u}")
96
+ out.sort(key=lambda i: (i.priority, i.item_id))
97
+ return out
98
+
99
+
100
+ def build_pack(
101
+ res: ImpactResult, budget_tokens: int,
102
+ ) -> ImpactContextPack:
103
+ """Encaixa itens por prioridade; excluídos vão para ``omitted``."""
104
+ tok = HeuristicTokenizer()
105
+ budget = max(0, min(int(budget_tokens), 1_000_000))
106
+ items = _items(res, tok)
107
+ selected: list[ImpactPackItem] = []
108
+ omitted: list[str] = []
109
+ used = 0
110
+ for it in items:
111
+ if used + it.est_tokens <= budget:
112
+ selected.append(it)
113
+ used += it.est_tokens
114
+ else:
115
+ omitted.append(it.item_id)
116
+ diags: list[str] = []
117
+ critical_omitted = [o for o in omitted
118
+ if o.startswith(("chg:", "contract:"))]
119
+ if critical_omitted:
120
+ diags.append(
121
+ "critical-omitted: conclusão crítica truncada por budget "
122
+ f"({len(critical_omitted)} item(ns))")
123
+ if omitted:
124
+ diags.append(f"pack-truncated: {len(omitted)} item(ns) omitidos")
125
+ return ImpactContextPack(
126
+ schema_version=1, budget_tokens=budget, tokens_used=used,
127
+ items=tuple(selected), omitted=tuple(omitted),
128
+ diagnostics=tuple(diags), truncated=bool(omitted))
@@ -0,0 +1,136 @@
1
+ """Impact Report — renderers JSON + Markdown + CLI (Phase 9).
2
+
3
+ Ordenação determinística, truncamento explícito, nada fictício.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import dataclasses
9
+ import json
10
+ from dataclasses import asdict
11
+
12
+ from code_atlas.impact.contracts import ImpactResult
13
+
14
+
15
+ def _jsonable(obj: object) -> object:
16
+ if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
17
+ return {k: _jsonable(v) for k, v in asdict(obj).items()}
18
+ if isinstance(obj, (tuple, list)):
19
+ return [_jsonable(v) for v in obj]
20
+ return obj
21
+
22
+
23
+ def to_json(res: ImpactResult) -> str:
24
+ """Envelope JSON versionado (schema_version incluso no result)."""
25
+ return json.dumps(_jsonable(res), indent=2,
26
+ sort_keys=True, default=str)
27
+
28
+
29
+ def _fmt_sig(c: object) -> str:
30
+ return getattr(c, "new_signature", None) or getattr(
31
+ c, "old_signature", None) or ""
32
+
33
+
34
+ def to_markdown(res: ImpactResult) -> str:
35
+ """Formato do Phase 9: seções fixas, ordenadas, auditáveis."""
36
+ lines = [
37
+ "# Impact Report", "",
38
+ f"- **Repository**: `{res.repository}`",
39
+ f"- **Base Revision**: `{res.base_revision}`",
40
+ f"- **Target Revision**: `{res.target_revision}`",
41
+ f"- **Snapshots**: {res.base_snapshot_id} → "
42
+ f"{res.target_snapshot_id}",
43
+ f"- **Risk**: **{res.risk.category.value}** "
44
+ f"(uncertainty: {res.risk.uncertainty})",
45
+ "",
46
+ "## Change Summary", ""]
47
+ if res.change_set:
48
+ lines.append(f"- {len(res.change_set.files)} arquivo(s) "
49
+ f"alterado(s) via {res.change_set.source}")
50
+ for f in res.change_set.files[:20]:
51
+ lines.append(f" - `{f.kind.value}` "
52
+ f"{f.new_path or f.old_path}")
53
+ lines += _symbols_section(res)
54
+ lines += _impacts_sections(res)
55
+ lines += _tests_section(res)
56
+ lines += _risk_section(res)
57
+ lines += _limitations_section(res)
58
+ lines += ["", f"_analyzer: {res.analyzer_version} "
59
+ f"· schema v{res.schema_version}_", ""]
60
+ return "\n".join(lines)
61
+
62
+
63
+ def _symbols_section(res: ImpactResult) -> list[str]:
64
+ lines = ["", "## Modified Symbols", ""]
65
+ for s in res.symbol_changes:
66
+ lines.append(f"- `{s.change.value}` **{s.qualified_name}** "
67
+ f"({s.kind}, {s.strength.value})")
68
+ if res.contract_changes:
69
+ lines += ["", "## Contract Changes", ""]
70
+ for c in res.contract_changes:
71
+ lines.append(
72
+ f"- **{c.classification}** `{c.symbol_key}` — "
73
+ f"{c.compatibility}")
74
+ return lines
75
+
76
+
77
+ def _impacts_sections(res: ImpactResult) -> list[str]:
78
+ lines: list[str] = []
79
+ for title, kinds in (
80
+ ("Direct Impact", ("direct",)),
81
+ ("Transitive Impact", ("transitive",)),
82
+ ("Possible Impact", ("possible", "unknown"))):
83
+ group = [i for i in res.impacts
84
+ if i.impact.value in kinds]
85
+ lines += ["", f"## {title}", ""]
86
+ for i in group[:30]:
87
+ hops = i.path.hops if i.path else 0
88
+ # lf-0120: nós de domínio marcados — qualified_name já
89
+ # traz ``dominio:ns.qname``; uncertain_deps é incerteza,
90
+ # nunca silenciada.
91
+ tag = "domain " if i.node_type == "domain" else ""
92
+ deps = (f", {len(i.uncertain_deps)} dep(s) incerta(s)"
93
+ if i.uncertain_deps else "")
94
+ lines.append(
95
+ f"- **{i.qualified_name}** ({tag}{i.kind}) — "
96
+ f"{hops} hop(s), {i.strength.value}{deps}")
97
+ return lines
98
+
99
+
100
+ def _tests_section(res: ImpactResult) -> list[str]:
101
+ lines = ["", "## Test Recommendations", ""]
102
+ for t in res.test_candidates[:20]:
103
+ lines.append(f"- `{t.test_file}` — {t.relationship}: "
104
+ f"{t.reason}")
105
+ return lines
106
+
107
+
108
+ def _risk_section(res: ImpactResult) -> list[str]:
109
+ return ["", "## Risk Assessment", "",
110
+ *[f"- {r}" for r in res.risk.reasons]]
111
+
112
+
113
+ def _limitations_section(res: ImpactResult) -> list[str]:
114
+ lines = ["", "## Limitations", ""]
115
+ for u in res.unknown_coverage:
116
+ lines.append(f"- {u}")
117
+ for d in res.diagnostics:
118
+ lines.append(f"- diagnostic `{d.code}`: {d.message}")
119
+ if res.truncated:
120
+ # lf-0151: cada bound que cortou trabalho é nomeado — um
121
+ # "TRUNCATED" sem razão seria de novo silêncio invertido.
122
+ why = ", ".join(res.truncation_reasons) or "limites"
123
+ lines.append(
124
+ f"- **TRUNCATED**: limites de análise atingidos ({why})")
125
+ return lines
126
+
127
+
128
+ def to_pr_comment(res: ImpactResult) -> str:
129
+ """Phase 12 (opt-in): corpo de comentário Markdown para PR.
130
+
131
+ Gerado localmente — NUNCA publica. A integração de providers
132
+ (GitHub/GitLab) é seam futura; sem token/rede, reporta-se NOT RUN.
133
+ """
134
+ header = ("> Code Atlas Impact Report (impact-v1) — gerado "
135
+ "localmente; publicação opt-in\n\n")
136
+ return header + to_markdown(res)