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,271 @@
1
+ """Correlação explícita SCIP ↔ Atlas (lf-0017).
2
+
3
+ Símbolos SCIP NÃO são ids internos do Atlas — este módulo faz a ponte:
4
+
5
+ - ``parse_symbol``: string scip → partes (scheme, package, descriptors).
6
+ Gramática oficial (scip.proto):
7
+ ``<scheme> ' ' <manager> ' ' <name> ' ' <version> ' ' <descriptors>``
8
+ ou ``local <id>`` para símbolos confinados ao documento.
9
+ - ``symbol_to_qname``: descriptors → qname estilo Atlas
10
+ (``ns.Class.method``), preservando sufixos de descriptor.
11
+ - ``ScipCorrelator``: resolve occurrence→(path → símbolo Atlas), usando os
12
+ ``ScopeUniverse``/qnames montados pelo builder — associação por qname,
13
+ nunca por id posicional.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ from dataclasses import dataclass
20
+
21
+ from code_atlas.resolvers.syntactic.resolver import ScopeUniverse
22
+
23
+ # sufixo de descriptor → separador qname
24
+ _SUFFIX_SEP = {
25
+ 1: "/", # Namespace
26
+ 2: "#", # Type
27
+ 3: ".", # Term
28
+ 4: ".", # Method (com disambiguator)
29
+ 5: "[]", # TypeParameter
30
+ 6: "()", # Parameter
31
+ 7: ":", # Meta
32
+ 8: ".", # Local
33
+ 9: "!", # Macro
34
+ }
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class Descriptor:
39
+ name: str
40
+ disambiguator: str
41
+ suffix: int # Descriptor.Suffix
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class ScipSymbol:
46
+ """Símbolo SCIP decomposto — nunca usado como id Atlas direto."""
47
+
48
+ scheme: str
49
+ manager: str
50
+ package_name: str
51
+ package_version: str
52
+ descriptors: tuple[Descriptor, ...]
53
+ local_id: str = "" # preenchido só para 'local N'
54
+
55
+ @property
56
+ def is_local(self) -> bool:
57
+ return bool(self.local_id)
58
+
59
+ @property
60
+ def is_external(self) -> bool:
61
+ """Símbolo de pacote externo (manager/pacote preenchidos e
62
+ não-locais podem ser locais-do-repo também — externo de fato é
63
+ decidido pelo correlator via presença no universo)."""
64
+ return False
65
+
66
+
67
+ _SYMBOL_RE = re.compile(
68
+ r"^(?P<scheme>\S+)\s+(?P<manager>\S+)\s+(?P<pkg>\S+)\s+"
69
+ r"(?P<ver>\S+)\s+(?P<desc>.+)$"
70
+ )
71
+ _LOCAL_RE = re.compile(r"^local\s+(?P<id>\S+)$")
72
+
73
+ # um descriptor termina em: '/', '#', '.', ':', '!', '].', ').', '[]'
74
+ _DESC_TOKEN_RE = re.compile(
75
+ r"(?P<name>`(?:[^`]|``)*`|[^\s/.#!:\[\]()`]+|`[^`]*`)"
76
+ r"(?P<disambig>(?:\([^)]*\))?)"
77
+ r"(?P<suffix>/|#|\.|:|!|\]|\))"
78
+ )
79
+
80
+
81
+ def parse_symbol(raw: str) -> ScipSymbol | None:
82
+ """String scip → ScipSymbol; None quando malformado."""
83
+ raw = raw.strip()
84
+ if not raw:
85
+ return None
86
+ m = _LOCAL_RE.match(raw)
87
+ if m:
88
+ return ScipSymbol(
89
+ scheme="", manager="", package_name="", package_version="",
90
+ descriptors=(), local_id=m.group("id"),
91
+ )
92
+ m = _SYMBOL_RE.match(raw)
93
+ if not m:
94
+ return None
95
+ descriptors = _parse_descriptors(m.group("desc"))
96
+ if descriptors is None:
97
+ return None
98
+ return ScipSymbol(
99
+ scheme=m.group("scheme"), manager=m.group("manager"),
100
+ package_name=m.group("pkg"), package_version=m.group("ver"),
101
+ descriptors=tuple(descriptors),
102
+ )
103
+
104
+
105
+ def _parse_descriptors(text: str) -> list[Descriptor] | None:
106
+ """Separa a cadeia de descriptors em tokens (name+disambig+suffix)."""
107
+ out: list[Descriptor] = []
108
+ i = 0
109
+ n = len(text)
110
+ while i < n:
111
+ parsed = _read_name(text, i)
112
+ if parsed is None:
113
+ return None
114
+ name, i = parsed
115
+ disambig = ""
116
+ if i < n and text[i] == "(": # method: name(disambig).
117
+ j = text.find(")", i)
118
+ if j == -1:
119
+ return None
120
+ disambig = text[i + 1 : j]
121
+ i = j # consome ')' abaixo
122
+ if i >= n:
123
+ return None
124
+ end = _read_suffix(text, i)
125
+ if end is None:
126
+ return None
127
+ suffix, i = end
128
+ if not name:
129
+ return None
130
+ clean = name[1:-1] if name.startswith("`") and name.endswith("`") \
131
+ else name
132
+ out.append(Descriptor(name=clean, disambiguator=disambig,
133
+ suffix=suffix))
134
+ return out
135
+
136
+
137
+ def _read_name(text: str, i: int) -> tuple[str, int] | None:
138
+ """Lê um name (identifier ou `escaped`) a partir de i → (name, next)."""
139
+ n = len(text)
140
+ if text[i] == "`": # escaped identifier — `` = escape
141
+ j = i + 1
142
+ while j < n:
143
+ if text[j] == "`":
144
+ if j + 1 < n and text[j + 1] == "`":
145
+ j += 2
146
+ continue
147
+ break
148
+ j += 1
149
+ if j >= n:
150
+ return None # backtick não fechado
151
+ return text[i : j + 1], j + 1
152
+ j = i
153
+ while j < n and text[j] not in "/#.:(![`":
154
+ j += 1
155
+ return text[i:j], j
156
+
157
+
158
+ def _read_suffix(text: str, i: int) -> tuple[int, int] | None:
159
+ """Lê o terminador de descriptor a partir de i → (suffix, next)."""
160
+ ch = text[i]
161
+ if ch == ")": # fecha method descriptor (pode vir seguido de '.')
162
+ i += 1
163
+ if i < len(text) and text[i] == ".":
164
+ i += 1
165
+ return 4, i
166
+ if ch == "[":
167
+ j = text.find("]", i)
168
+ if j == -1:
169
+ return None
170
+ return 5, j + 1 # ']' termina o descriptor type-param
171
+ suffix = {"/": 1, "#": 2, ".": 3, ":": 7, "!": 9}.get(ch)
172
+ if suffix is None:
173
+ return None
174
+ return suffix, i + 1
175
+
176
+
177
+ def symbol_to_qname(sym: ScipSymbol) -> str:
178
+ """Converte descriptors scip → qname estilo Atlas (pkg.Class.method).
179
+
180
+ Namespace e Type/Terms são concatenados com '.'; Method recebe '()';
181
+ TypeParameter/Parameter são sufixados ao termo anterior.
182
+ """
183
+ if sym.is_local:
184
+ return f"local:{sym.local_id}"
185
+ parts: list[str] = []
186
+ for d in sym.descriptors:
187
+ if d.suffix in (5, 6): # type-param / parameter — suffixo inline
188
+ if parts:
189
+ parts[-1] += f"[{d.name}]" if d.suffix == 5 else f"({d.name})"
190
+ continue
191
+ name = d.name
192
+ if d.suffix == 4: # method
193
+ name = f"{name}({d.disambiguator})" if d.disambiguator else name
194
+ parts.append(name)
195
+ return ".".join(parts)
196
+
197
+
198
+ def symbol_parts(sym: ScipSymbol) -> tuple[str, tuple[str, ...]]:
199
+ """Decompõe descriptors em (doc_path candidato, nomes de membros).
200
+
201
+ Indexadores SCIP usam o path do documento como descriptor Namespace
202
+ (ex.: `` `pkg/util.py`/helper(). ``). Membros = descriptors
203
+ não-namespace, com disambiguator/parens removidos.
204
+ """
205
+ if sym.is_local:
206
+ return "", ()
207
+ ns_parts = [d.name for d in sym.descriptors if d.suffix == 1]
208
+ members = tuple(
209
+ d.name for d in sym.descriptors if d.suffix != 1
210
+ )
211
+ doc = "/".join(ns_parts)
212
+ return doc, members
213
+
214
+
215
+ class ScipCorrelator:
216
+ """Mapa occurrence→nó Atlas dentro de um ``ScopeUniverse``.
217
+
218
+ Associação por (path do documento → módulo ctx) + (qname do símbolo →
219
+ ``qname_nid``); nunca assume que o id scip é o id interno.
220
+ """
221
+
222
+ def __init__(self, universe: ScopeUniverse) -> None:
223
+ self._u = universe
224
+
225
+ def module_for_document(self, relative_path: str) -> str | None:
226
+ """Document scip → nid do módulo Atlas com mesmo path."""
227
+ return self._u.by_path.get(relative_path)
228
+
229
+ def node_for_symbol(self, raw: str, path: str) -> str | None:
230
+ """String scip → nid de símbolo no snapshot (ou None).
231
+
232
+ Estratégia: (1) doc-path do namespace → ctx do módulo + membro
233
+ final; (2) leaf global como fallback; (3) qname direto. Só
234
+ retorna nid quando a identidade é única.
235
+ """
236
+ sym = parse_symbol(raw)
237
+ if sym is None or sym.is_local:
238
+ return None
239
+ doc, members = symbol_parts(sym)
240
+ hits = self._match(sym, doc, members, path)
241
+ return hits[0] if len(hits) == 1 else None
242
+
243
+ def _match(
244
+ self,
245
+ sym: ScipSymbol,
246
+ doc: str,
247
+ members: tuple[str, ...],
248
+ path: str,
249
+ ) -> list[str]:
250
+ if doc:
251
+ ctx = self._u.ctx_for_path(doc)
252
+ if ctx is not None:
253
+ if not members:
254
+ return [ctx.nid]
255
+ hits = sorted(set(ctx.defines.get(members[-1], [])))
256
+ if hits:
257
+ return hits
258
+ if members:
259
+ hits = sorted(set(self._u.leaf.get(members[-1], [])))
260
+ if hits:
261
+ return hits
262
+ qname = symbol_to_qname(sym)
263
+ return sorted(set(self._u.qname_nid.get(qname, [])))
264
+
265
+ def candidates_for_symbol(self, raw: str) -> tuple[str, ...]:
266
+ sym = parse_symbol(raw)
267
+ if sym is None or sym.is_local:
268
+ return ()
269
+ doc, members = symbol_parts(sym)
270
+ hits = self._match(sym, doc, members, "")
271
+ return tuple(sorted(set(hits)))
@@ -0,0 +1,211 @@
1
+ """Contrato de proveniência + revision safety do índice SCIP (lf-0028).
2
+
3
+ ``project_root`` bater não basta: um índice pode ser de outro repo com
4
+ o mesmo path, estar velho (arquivos modificados/removidos depois da
5
+ indexação) ou simplesmente incompleto. ``IndexProvenance`` é o modelo
6
+ interno — não presume que o .scip contenha todos os campos; o que não
7
+ pode ser provado fica explicitamente marcado.
8
+
9
+ ``validation_status``:
10
+ - ``current``: todas as verificações passaram.
11
+ - ``degraded``: índice utilizável, mas evidência de revisão
12
+ insuficiente — relações derivadas NUNCA são promovidas como
13
+ comprovadas (confidence capado em ``heuristic`` no resolver).
14
+ - ``rejected``: incompatível — o resolver não produz resolução
15
+ nenhuma (índice incompatível nunca contamina snapshot READY).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import hashlib
21
+ from dataclasses import dataclass
22
+ from datetime import UTC, datetime
23
+ from pathlib import Path
24
+
25
+ from code_atlas.resolvers.scip.importer import ScipDocument, ScipIndex
26
+ from code_atlas.resolvers.scip.validation import project_root_matches
27
+
28
+ # position_encoding válido no mapper: UTF-32 (3) ou não-declarado (0
29
+ # — protobuf enum zero/default); outros encodings tornam spans
30
+ # não-confiáveis → degraded (o mapper não re-decodifica).
31
+ TRUSTED_ENCODINGS = frozenset({0, 3})
32
+
33
+ STATUS_CURRENT = "current"
34
+ STATUS_DEGRADED = "degraded"
35
+ STATUS_REJECTED = "rejected"
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class IndexProvenance:
40
+ """Proveniência de um índice externo — inspecionável e serializável."""
41
+
42
+ index_format: str = "scip"
43
+ index_sha256: str = "" # sha256 dos bytes do arquivo
44
+ producer_name: str = "" # metadata.tool_info.name
45
+ producer_version: str = "" # metadata.tool_info.version
46
+ repository_identity: str = "" # metadata.project_root (URI)
47
+ source_revision: str = "" # revisão do repo indexado, se provável
48
+ worktree_digest: str = "" # digest dos arquivos indexados vistos
49
+ indexed_at: str = "" # mtime do arquivo .scip (evidência host)
50
+ validation_status: str = STATUS_REJECTED
51
+ validation_evidence: tuple[str, ...] = ()
52
+
53
+ def summary(self) -> str:
54
+ """Resumo one-line para evidence notes / diagnósticos."""
55
+ return (
56
+ f"scip-provenance status={self.validation_status} "
57
+ f"sha256={self.index_sha256[:12]} "
58
+ f"producer={self.producer_name}@{self.producer_version} "
59
+ f"revision={self.source_revision or 'unknown'}"
60
+ )
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class RevisionAssessment:
65
+ """Resultado do assessment: proveniência + erros + avisos."""
66
+
67
+ provenance: IndexProvenance
68
+ errors: tuple[str, ...] = ()
69
+ warnings: tuple[str, ...] = ()
70
+
71
+
72
+ def assess_revision(
73
+ index: ScipIndex,
74
+ index_path: Path,
75
+ workspace_root: Path,
76
+ *,
77
+ raw_bytes: bytes | None = None,
78
+ ) -> RevisionAssessment:
79
+ """Verifica o índice contra o worktree atual.
80
+
81
+ Sinais sem executar nada do repo: identidade (``project_root`` ↔
82
+ workspace), staleness, paths, spans vs fim de arquivo,
83
+ ``position_encoding``, completude de metadata, símbolos duplicados.
84
+ """
85
+ if raw_bytes is None:
86
+ try:
87
+ raw_bytes = index_path.read_bytes()
88
+ except OSError as exc:
89
+ return RevisionAssessment(
90
+ IndexProvenance(validation_status=STATUS_REJECTED),
91
+ errors=(f"cannot read index for provenance: {exc}",),
92
+ )
93
+ sha = hashlib.sha256(raw_bytes).hexdigest()
94
+ index_mtime, indexed_at = _index_stamp(index_path)
95
+ base = dict(
96
+ index_sha256=sha,
97
+ producer_name=index.tool_name,
98
+ producer_version=index.tool_version,
99
+ repository_identity=index.project_root,
100
+ indexed_at=indexed_at,
101
+ )
102
+ if not project_root_matches(index, workspace_root):
103
+ return RevisionAssessment(
104
+ IndexProvenance(
105
+ **base,
106
+ validation_status=STATUS_REJECTED,
107
+ validation_evidence=("project_root mismatch",),
108
+ ),
109
+ errors=(
110
+ f"index project_root {index.project_root!r} does not "
111
+ f"identify workspace {workspace_root}",
112
+ ),
113
+ )
114
+ degraded, digest = _check_docs(
115
+ index, Path(workspace_root).resolve(), index_mtime)
116
+ degraded += _check_completeness(index, index_mtime)
117
+ status = STATUS_DEGRADED if degraded else STATUS_CURRENT
118
+ return RevisionAssessment(
119
+ IndexProvenance(
120
+ **base,
121
+ source_revision="", # scip metadata não prova commit
122
+ worktree_digest=digest,
123
+ validation_status=status,
124
+ validation_evidence=(
125
+ "project_root matches workspace", *degraded),
126
+ ),
127
+ warnings=tuple(degraded)
128
+ + (() if index_mtime else ("index mtime unavailable",)),
129
+ )
130
+
131
+
132
+ def _index_stamp(index_path: Path) -> tuple[float, str]:
133
+ try:
134
+ mtime = index_path.stat().st_mtime
135
+ except OSError:
136
+ return 0.0, ""
137
+ return mtime, datetime.fromtimestamp(
138
+ mtime, tz=UTC).isoformat()
139
+
140
+
141
+ def _check_docs(
142
+ index: ScipIndex, root: Path, index_mtime: float
143
+ ) -> tuple[list[str], str]:
144
+ """staleness + paths + spans + encoding + símbolos duplicados."""
145
+ degraded: list[str] = []
146
+ defined_in: dict[str, str] = {}
147
+ digest_parts: list[str] = []
148
+ for doc in index.documents:
149
+ p = doc.relative_path
150
+ if "\\" in p or "//" in p or p.startswith("./"):
151
+ degraded.append(f"non-normalized path: {p!r}")
152
+ if doc.position_encoding not in TRUSTED_ENCODINGS:
153
+ degraded.append(f"doc {p}: position_encoding="
154
+ f"{doc.position_encoding} untrusted")
155
+ for s in doc.symbols:
156
+ prev = defined_in.get(s.symbol)
157
+ if prev is not None and prev != p:
158
+ degraded.append(
159
+ f"symbol {s.symbol!r} defined in {prev} and {p}")
160
+ else:
161
+ defined_in[s.symbol] = p
162
+ degraded += _check_doc_file(doc, root / p, index_mtime,
163
+ digest_parts)
164
+ digest = hashlib.sha256(
165
+ "\n".join(sorted(digest_parts)).encode()).hexdigest()
166
+ return degraded, digest
167
+
168
+
169
+ def _check_doc_file(
170
+ doc: ScipDocument, target: Path, index_mtime: float,
171
+ digest_parts: list[str],
172
+ ) -> list[str]:
173
+ """staleness e spans de um documento contra o arquivo atual."""
174
+ p = doc.relative_path
175
+ if not target.is_file():
176
+ digest_parts.append(f"{p}:absent")
177
+ return [f"indexed file missing: {p}"]
178
+ try:
179
+ fst = target.stat()
180
+ except OSError:
181
+ return [f"cannot stat {p}"]
182
+ digest_parts.append(f"{p}:{fst.st_size}")
183
+ out: list[str] = []
184
+ if index_mtime and fst.st_mtime > index_mtime:
185
+ out.append(f"file newer than index: {p}")
186
+ if doc.occurrences:
187
+ try:
188
+ n_lines = len(
189
+ target.read_text(encoding="utf-8").splitlines())
190
+ except (OSError, UnicodeDecodeError):
191
+ n_lines = -1
192
+ if n_lines >= 0:
193
+ max_line = max(
194
+ (o.range.start_line if o.range else 0)
195
+ for o in doc.occurrences)
196
+ if max_line > n_lines:
197
+ out.append(f"doc {p}: occurrence span line {max_line} "
198
+ f"beyond file end {n_lines}")
199
+ return out
200
+
201
+
202
+ def _check_completeness(index: ScipIndex, index_mtime: float) -> list[str]:
203
+ """índice válido ≠ índice completo."""
204
+ out: list[str] = []
205
+ if not index.tool_version:
206
+ out.append("metadata.tool_info.version missing")
207
+ if not index.documents:
208
+ out.append("index has no documents")
209
+ if not index_mtime:
210
+ out.append("index timestamp unknown")
211
+ return out