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.
- code_atlas/__init__.py +6 -0
- code_atlas/__main__.py +6 -0
- code_atlas/api/__init__.py +5 -0
- code_atlas/api/handlers.py +1514 -0
- code_atlas/api/server.py +394 -0
- code_atlas/cli.py +2084 -0
- code_atlas/config.py +149 -0
- code_atlas/context/__init__.py +66 -0
- code_atlas/context/contracts.py +277 -0
- code_atlas/context/domain.py +760 -0
- code_atlas/context/engine.py +442 -0
- code_atlas/context/expand.py +133 -0
- code_atlas/context/renderers.py +71 -0
- code_atlas/context/tokenizer.py +37 -0
- code_atlas/contracts/__init__.py +38 -0
- code_atlas/contracts/enums.py +67 -0
- code_atlas/contracts/models.py +175 -0
- code_atlas/contracts/ports.py +70 -0
- code_atlas/discovery/__init__.py +25 -0
- code_atlas/discovery/gitignore.py +119 -0
- code_atlas/discovery/identity.py +52 -0
- code_atlas/discovery/models.py +204 -0
- code_atlas/discovery/scanner.py +495 -0
- code_atlas/domains/__init__.py +107 -0
- code_atlas/domains/api/__init__.py +28 -0
- code_atlas/domains/api/adapter.py +405 -0
- code_atlas/domains/api/common.py +308 -0
- code_atlas/domains/api/consumers.py +224 -0
- code_atlas/domains/api/emit.py +231 -0
- code_atlas/domains/api/openapi.py +398 -0
- code_atlas/domains/api/routes.py +582 -0
- code_atlas/domains/api/yaml_subset.py +683 -0
- code_atlas/domains/aws/__init__.py +28 -0
- code_atlas/domains/aws/adapter.py +382 -0
- code_atlas/domains/aws/common.py +261 -0
- code_atlas/domains/aws/configs.py +1164 -0
- code_atlas/domains/aws/emit.py +411 -0
- code_atlas/domains/aws/pyfiles.py +2220 -0
- code_atlas/domains/aws/sqlbridge.py +448 -0
- code_atlas/domains/cicd/__init__.py +50 -0
- code_atlas/domains/cicd/adapter.py +762 -0
- code_atlas/domains/cicd/classify.py +136 -0
- code_atlas/domains/cicd/workflow.py +504 -0
- code_atlas/domains/cicd/yamlmini.py +946 -0
- code_atlas/domains/cli_surface.py +739 -0
- code_atlas/domains/contracts.py +274 -0
- code_atlas/domains/crossresolve.py +1208 -0
- code_atlas/domains/evidence.py +57 -0
- code_atlas/domains/identity.py +149 -0
- code_atlas/domains/model.py +202 -0
- code_atlas/domains/registry.py +250 -0
- code_atlas/domains/resolution.py +65 -0
- code_atlas/domains/spark/__init__.py +31 -0
- code_atlas/domains/spark/adapter.py +146 -0
- code_atlas/domains/spark/emit.py +344 -0
- code_atlas/domains/spark/extract.py +108 -0
- code_atlas/domains/spark/sqllit.py +192 -0
- code_atlas/domains/spark/walk.py +1943 -0
- code_atlas/domains/sql/__init__.py +30 -0
- code_atlas/domains/sql/adapter.py +382 -0
- code_atlas/domains/sql/extract.py +1271 -0
- code_atlas/domains/sql/nodes.py +264 -0
- code_atlas/domains/sql/parser.py +1510 -0
- code_atlas/domains/sql/tokens.py +318 -0
- code_atlas/domains/summarize.py +126 -0
- code_atlas/domains/terraform/__init__.py +20 -0
- code_atlas/domains/terraform/adapter.py +127 -0
- code_atlas/domains/terraform/catalog.py +43 -0
- code_atlas/domains/terraform/extract.py +1277 -0
- code_atlas/domains/terraform/hcl.py +538 -0
- code_atlas/domains/terraform/refs.py +181 -0
- code_atlas/domains/terraform/tfjson.py +163 -0
- code_atlas/domains/terraform/tfvars.py +98 -0
- code_atlas/domains/validation.py +120 -0
- code_atlas/env_override.py +27 -0
- code_atlas/eval/__init__.py +26 -0
- code_atlas/eval/domain_eval.py +681 -0
- code_atlas/eval/ground_truth.py +688 -0
- code_atlas/eval/impact_truth.py +758 -0
- code_atlas/eval/metrics.py +178 -0
- code_atlas/eval/pack_value.py +610 -0
- code_atlas/federation/__init__.py +72 -0
- code_atlas/federation/federation.py +676 -0
- code_atlas/federation/model.py +355 -0
- code_atlas/federation/store.py +194 -0
- code_atlas/gitbin.py +48 -0
- code_atlas/graph/__init__.py +20 -0
- code_atlas/graph/builder.py +378 -0
- code_atlas/graph/identity.py +54 -0
- code_atlas/graph/queries.py +47 -0
- code_atlas/graph/validation.py +92 -0
- code_atlas/impact/__init__.py +37 -0
- code_atlas/impact/blast_radius.py +171 -0
- code_atlas/impact/contract_analyzer.py +104 -0
- code_atlas/impact/contracts.py +315 -0
- code_atlas/impact/diff_reader.py +142 -0
- code_atlas/impact/domain_traversal.py +768 -0
- code_atlas/impact/engine.py +392 -0
- code_atlas/impact/packs.py +128 -0
- code_atlas/impact/report.py +136 -0
- code_atlas/impact/revision_binding.py +220 -0
- code_atlas/impact/risk_classifier.py +83 -0
- code_atlas/impact/snapshot_resolver.py +136 -0
- code_atlas/impact/symbol_diff.py +210 -0
- code_atlas/impact/test_selector.py +166 -0
- code_atlas/indexing/__init__.py +5 -0
- code_atlas/indexing/cache.py +396 -0
- code_atlas/indexing/change_detector.py +278 -0
- code_atlas/indexing/coordinator.py +1032 -0
- code_atlas/indexing/dependency_planner.py +268 -0
- code_atlas/indexing/persist.py +436 -0
- code_atlas/indexing/reuse.py +85 -0
- code_atlas/install/__init__.py +7 -0
- code_atlas/install/cli.py +168 -0
- code_atlas/install/lifecycle.py +286 -0
- code_atlas/mcp.py +674 -0
- code_atlas/observability/__init__.py +19 -0
- code_atlas/observability/logging.py +199 -0
- code_atlas/parsers/__init__.py +43 -0
- code_atlas/parsers/contracts.py +209 -0
- code_atlas/parsers/engine.py +125 -0
- code_atlas/parsers/languages/__init__.py +1 -0
- code_atlas/parsers/languages/python.py +391 -0
- code_atlas/parsers/languages/tsx.py +28 -0
- code_atlas/parsers/languages/typescript.py +909 -0
- code_atlas/parsers/registry.py +51 -0
- code_atlas/parsers/tree_sitter_runtime.py +118 -0
- code_atlas/resolvers/__init__.py +33 -0
- code_atlas/resolvers/bootstrap.py +246 -0
- code_atlas/resolvers/capabilities.py +33 -0
- code_atlas/resolvers/contracts.py +133 -0
- code_atlas/resolvers/coordinator.py +96 -0
- code_atlas/resolvers/enrichment.py +318 -0
- code_atlas/resolvers/lsp/__init__.py +26 -0
- code_atlas/resolvers/lsp/adapter.py +256 -0
- code_atlas/resolvers/lsp/lifecycle.py +251 -0
- code_atlas/resolvers/lsp/spawn.py +92 -0
- code_atlas/resolvers/lsp/transport.py +240 -0
- code_atlas/resolvers/registry.py +33 -0
- code_atlas/resolvers/scip/__init__.py +49 -0
- code_atlas/resolvers/scip/decoder.py +216 -0
- code_atlas/resolvers/scip/importer.py +248 -0
- code_atlas/resolvers/scip/loader.py +90 -0
- code_atlas/resolvers/scip/mapper.py +271 -0
- code_atlas/resolvers/scip/provenance.py +211 -0
- code_atlas/resolvers/scip/resolver.py +289 -0
- code_atlas/resolvers/scip/validation.py +101 -0
- code_atlas/resolvers/syntactic/__init__.py +8 -0
- code_atlas/resolvers/syntactic/resolver.py +254 -0
- code_atlas/search/__init__.py +32 -0
- code_atlas/search/contracts.py +176 -0
- code_atlas/search/domain.py +561 -0
- code_atlas/search/engine.py +530 -0
- code_atlas/search/federated.py +276 -0
- code_atlas/search/graph.py +282 -0
- code_atlas/search/hybrid.py +151 -0
- code_atlas/search/intent.py +48 -0
- code_atlas/search/lexical.py +176 -0
- code_atlas/search/refs.py +359 -0
- code_atlas/search/structural.py +505 -0
- code_atlas/search/tokenizer.py +43 -0
- code_atlas/search/vector/__init__.py +35 -0
- code_atlas/search/vector/contracts.py +76 -0
- code_atlas/search/vector/embeddings.py +96 -0
- code_atlas/search/vector/index.py +169 -0
- code_atlas/search/vector/provider.py +50 -0
- code_atlas/storage/__init__.py +14 -0
- code_atlas/storage/db.py +47 -0
- code_atlas/storage/migrations.py +552 -0
- code_atlas/storage/store.py +1045 -0
- code_atlas/ui/__init__.py +29 -0
- code_atlas/ui/i18n.py +143 -0
- code_atlas/ui/kit.py +689 -0
- code_atlas/ui/wizard.py +317 -0
- code_atlas_forge-1.1.0.dist-info/METADATA +246 -0
- code_atlas_forge-1.1.0.dist-info/RECORD +181 -0
- code_atlas_forge-1.1.0.dist-info/WHEEL +5 -0
- code_atlas_forge-1.1.0.dist-info/entry_points.txt +2 -0
- code_atlas_forge-1.1.0.dist-info/licenses/LICENSE +202 -0
- code_atlas_forge-1.1.0.dist-info/licenses/NOTICE +17 -0
- 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)
|