sourcecode 2.5.3__py3-none-any.whl → 2.5.5__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.
@@ -0,0 +1,346 @@
1
+ """reconciliation.py — Evidence Reconciliation Layer (F-1).
2
+
3
+ Cross-validates independent evidence views BEFORE a result is narrated:
4
+
5
+ evidence views → consistency rules → positively-detected contradictions
6
+
7
+ Each analyzer (call graph, endpoint index, …) is treated as an evidence
8
+ provider whose view may be incomplete. A reconciliation rule fires only on a
9
+ *provable* self-contradiction between two views — never on absence alone —
10
+ and its output downgrades confidence and names the gap instead of letting the
11
+ narrative assert an answer the evidence does not support.
12
+
13
+ Rules are pure functions over frozensets of symbols: no I/O, no parsing, no
14
+ framework-name branching (annotation tables come from the open-standard
15
+ declarative sets in repository_ir — VAI-safe). New rules append to the
16
+ registry of the relevant query surface; consumers surface each finding as a
17
+ blind spot + warning.
18
+
19
+ Rule registry (impact queries):
20
+ endpoint_coverage — a class in the call chain declares HTTP handler
21
+ methods (Spring MVC / JAX-RS verb annotations) but the endpoint index
22
+ modeled NO route for it. The call graph and the endpoint index are
23
+ contradicting each other; `endpoints_affected` under-reports real HTTP
24
+ exposure (e.g. JAX-RS sub-resource locator chains the route extractor
25
+ cannot compose). First shipped instance of F-1 (SIM-2, keycloak
26
+ simulacro 2026-07-16).
27
+
28
+ Rule registry (extraction):
29
+ parse_coverage — a scanned Java file provably declares a type (masked
30
+ source matches a type-declaration keyword) but symbol extraction
31
+ produced NOTHING for it. The file inventory and the symbol index are
32
+ contradicting each other; every symbol in that file is silently
33
+ invisible to the graph, callers and endpoints (P1-E: legal-Java
34
+ declarations the regex extractor cannot bind, e.g. `public class\n
35
+ Name` split across lines, Unicode type identifiers).
36
+
37
+ Rule registry (architecture):
38
+ layer_language_coherence — a directory-name-matched layer holds NO file in
39
+ the runtime that carries the rest of the claimed pattern, while being
40
+ owned by a DIFFERENT runtime. The layer table and the file-language
41
+ census are contradicting each other: the "architecture" spans two
42
+ subsystems (SIM-3: keycloak's "MVC … view layer" was a React SPA, zero
43
+ Java files, sitting beside a 1088-file Java model).
44
+
45
+ Never raises. Empty evidence → no findings.
46
+ """
47
+ from __future__ import annotations
48
+
49
+ import re
50
+ from dataclasses import dataclass
51
+ from pathlib import Path
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class ReconciliationFinding:
56
+ """One positively-detected contradiction between two evidence views."""
57
+ rule: str # stable rule id, e.g. "endpoint_coverage"
58
+ symbols: tuple[str, ...] # symbols the contradiction is anchored to
59
+ detail: str # one-sentence human/agent-readable statement
60
+
61
+ def to_dict(self) -> dict:
62
+ return {
63
+ "rule": self.rule,
64
+ "symbols": list(self.symbols),
65
+ "detail": self.detail,
66
+ }
67
+
68
+
69
+ def http_handler_classes(cir) -> frozenset[str]:
70
+ """Classes with at least one method carrying an HTTP handler annotation.
71
+
72
+ Independent evidence of "this class serves HTTP" derived straight from the
73
+ per-symbol annotation facts — deliberately NOT derived from the endpoint
74
+ index, so the two views can be reconciled against each other.
75
+ """
76
+ from sourcecode.repository_ir import _ENDPOINT_ANNOTATIONS
77
+
78
+ raw = getattr(cir, "_raw_ir", None) or {}
79
+ nodes = (raw.get("graph") or {}).get("nodes") or []
80
+ out: set[str] = set()
81
+ for node in nodes:
82
+ if not isinstance(node, dict):
83
+ continue
84
+ fqn = node.get("fqn") or ""
85
+ if "#" not in fqn:
86
+ continue
87
+ anns = node.get("annotations") or []
88
+ if any(a in _ENDPOINT_ANNOTATIONS for a in anns):
89
+ out.add(fqn.split("#", 1)[0])
90
+ return frozenset(out)
91
+
92
+
93
+ def _rule_endpoint_coverage(
94
+ chain_classes: frozenset[str],
95
+ handler_classes: frozenset[str],
96
+ modeled_classes: frozenset[str],
97
+ ) -> list[ReconciliationFinding]:
98
+ """Chain class declares HTTP handlers but the endpoint index has no route for it.
99
+
100
+ Fires only on total absence from the index (extractor could not model ANY
101
+ route for a class that provably declares handlers). A class that IS in the
102
+ index but contributes only a subset of its endpoints to a query is the
103
+ intentional method-precision rule, not a contradiction — never flagged.
104
+ """
105
+ unmapped = sorted((chain_classes & handler_classes) - modeled_classes)
106
+ if not unmapped:
107
+ return []
108
+ return [
109
+ ReconciliationFinding(
110
+ rule="endpoint_coverage",
111
+ symbols=tuple(unmapped),
112
+ detail=(
113
+ f"{len(unmapped)} class(es) in the call chain declare HTTP handler "
114
+ "methods but the endpoint index modeled no route for them — "
115
+ "endpoints_affected under-reports real HTTP exposure."
116
+ ),
117
+ )
118
+ ]
119
+
120
+
121
+ def reconcile_impact_evidence(
122
+ *,
123
+ chain_classes: frozenset[str],
124
+ handler_classes: frozenset[str],
125
+ modeled_classes: frozenset[str],
126
+ ) -> list[ReconciliationFinding]:
127
+ """Run all impact-query reconciliation rules. Never raises.
128
+
129
+ Args:
130
+ chain_classes: classes of every seed + caller in the resolved chain.
131
+ handler_classes: classes with HTTP-handler annotation evidence
132
+ (from http_handler_classes(cir)).
133
+ modeled_classes: classes the endpoint index has ≥1 route for
134
+ (endpoint_index.controller_fqns).
135
+ """
136
+ findings: list[ReconciliationFinding] = []
137
+ try:
138
+ findings.extend(
139
+ _rule_endpoint_coverage(chain_classes, handler_classes, modeled_classes)
140
+ )
141
+ except Exception:
142
+ pass
143
+ return findings
144
+
145
+
146
+ # ---------------------------------------------------------------------------
147
+ # Extraction-time reconciliation (file inventory × symbol index)
148
+ # ---------------------------------------------------------------------------
149
+
150
+ # A type-declaration keyword followed by the start of an identifier. Applied to
151
+ # COMMENT/STRING-MASKED source only, so `/* class Ghost */` and "class" inside
152
+ # a string literal never match. `record` is a contextual keyword; requiring a
153
+ # following identifier keeps `record = ...` assignments out, and the rule is
154
+ # only ever consulted for files that yielded zero symbols — where a stray
155
+ # match would still be pointing at real, unmodeled Java.
156
+ _TYPE_DECL_RE = re.compile(r"\b(?:class|interface|enum|record)\s+\w")
157
+
158
+
159
+ def declares_java_type(masked_source: str) -> bool:
160
+ """True if comment/string-masked Java source declares a type.
161
+
162
+ The caller masks (repository_ir._mask_java) so this stays a pure text
163
+ predicate with no parsing dependency.
164
+ """
165
+ return bool(_TYPE_DECL_RE.search(masked_source))
166
+
167
+
168
+ def _rule_parse_coverage(
169
+ type_decl_files: frozenset[str],
170
+ extracted_files: frozenset[str],
171
+ ) -> list[ReconciliationFinding]:
172
+ """File declares a type but symbol extraction produced nothing for it.
173
+
174
+ Fires only on total extraction absence for the file — a file that yielded
175
+ at least one symbol is never flagged (partial member loss is a different,
176
+ weaker signal this rule does not claim to detect).
177
+ """
178
+ lost = sorted(type_decl_files - extracted_files)
179
+ if not lost:
180
+ return []
181
+ return [
182
+ ReconciliationFinding(
183
+ rule="parse_coverage",
184
+ symbols=tuple(lost),
185
+ detail=(
186
+ f"{len(lost)} Java file(s) declare a type but produced no symbols — "
187
+ "their classes and methods are invisible to the graph, callers and "
188
+ "endpoints."
189
+ ),
190
+ )
191
+ ]
192
+
193
+
194
+ # ---------------------------------------------------------------------------
195
+ # Architecture reconciliation (layer table × file-language census)
196
+ # ---------------------------------------------------------------------------
197
+
198
+ # Source extension → RUNTIME FAMILY. Grouping by runtime, not by extension, is
199
+ # what makes the rule correct: a Kotlin `view/` beside Java `model/` is ONE
200
+ # system (same runtime, direct interop) and must not be called a contradiction,
201
+ # while a TypeScript `view/` beside them is a separate program. Extensions
202
+ # absent here (.html/.ftl/.jsp/.css/.xml …) are deliberately unmapped: a
203
+ # template or asset carries no runtime of its own, so it can never contradict a
204
+ # claim — that carve-out is what keeps a genuine Spring-MVC `templates/` view
205
+ # layer intact.
206
+ _RUNTIME_FAMILY: dict[str, str] = {
207
+ ".java": "jvm", ".kt": "jvm", ".kts": "jvm", ".scala": "jvm", ".groovy": "jvm",
208
+ ".js": "js", ".jsx": "js", ".ts": "js", ".tsx": "js", ".mjs": "js", ".cjs": "js",
209
+ ".py": "python",
210
+ ".go": "go",
211
+ ".rs": "rust",
212
+ ".rb": "ruby",
213
+ ".cs": "dotnet",
214
+ ".php": "php",
215
+ ".swift": "swift",
216
+ }
217
+
218
+ # Patterns that describe REPO COMPOSITION rather than one program's internal
219
+ # architecture — their layers are SUPPOSED to span runtimes, so the coherence
220
+ # rule must never fire on them. (mvc/layered/onion/hexagonal/clean are claims
221
+ # about a single program and must hold together in one runtime.)
222
+ _CROSS_RUNTIME_PATTERNS: frozenset[str] = frozenset({
223
+ "fullstack", "microservices", "monorepo",
224
+ })
225
+
226
+
227
+ def runtime_census(layer_files: dict[str, list[str]]) -> dict[str, dict[str, int]]:
228
+ """{layer -> {runtime_family -> file count}} — the language evidence view.
229
+
230
+ Files whose extension carries no runtime (templates, assets, config) are
231
+ simply absent from the census; a layer made only of them yields {}.
232
+ """
233
+ out: dict[str, dict[str, int]] = {}
234
+ for layer, files in layer_files.items():
235
+ counts: dict[str, int] = {}
236
+ for f in files:
237
+ fam = _RUNTIME_FAMILY.get(Path(str(f)).suffix.lower())
238
+ if fam:
239
+ counts[fam] = counts.get(fam, 0) + 1
240
+ out[layer] = counts
241
+ return out
242
+
243
+
244
+ def _rule_layer_language_coherence(
245
+ pattern: str,
246
+ census: dict[str, dict[str, int]],
247
+ repo_runtimes: dict[str, int],
248
+ ) -> list[ReconciliationFinding]:
249
+ """A claimed layer is owned by a different runtime than the repo it describes.
250
+
251
+ The dominant runtime is taken from the WHOLE repo, not from the matched
252
+ layers: which directories happen to match a keyword table is exactly the
253
+ thing under suspicion, so it cannot also be the arbiter. (Measured: on a
254
+ Java backend beside a React SPA, layer-local dominance can invert — the SPA
255
+ out-matches the backend's one matched layer and the JAVA layer gets flagged.)
256
+
257
+ A layer is INCOHERENT iff it holds ZERO files of the repo's runtime AND at
258
+ least one file of another — it is a different program, not a facet of this
259
+ one. A layer with no runtime files at all (templates, assets) is never
260
+ flagged: it does not contradict, it just does not attest.
261
+
262
+ Silent for cross-runtime patterns, whose layers are meant to span runtimes.
263
+ """
264
+ if pattern in _CROSS_RUNTIME_PATTERNS or len(census) < 2:
265
+ return []
266
+ if len(repo_runtimes) < 2:
267
+ return [] # single-runtime repo — nothing to contradict
268
+
269
+ dominant = max(sorted(repo_runtimes), key=lambda f: repo_runtimes[f])
270
+
271
+ incoherent = sorted(
272
+ layer for layer, counts in census.items()
273
+ if not counts.get(dominant) and any(counts.values())
274
+ )
275
+ if not incoherent:
276
+ return []
277
+ return [
278
+ ReconciliationFinding(
279
+ rule="layer_language_coherence",
280
+ symbols=tuple(incoherent),
281
+ detail=(
282
+ f"{len(incoherent)} layer(s) of the '{pattern}' claim hold no "
283
+ f"{dominant} file while the rest of the pattern is {dominant} — "
284
+ "the claim spans two separate programs, so it does not describe "
285
+ "one architecture."
286
+ ),
287
+ )
288
+ ]
289
+
290
+
291
+ def reconcile_architecture_evidence(
292
+ *,
293
+ pattern: str,
294
+ layer_files: dict[str, list[str]],
295
+ repo_files: "list[str]",
296
+ ) -> list[ReconciliationFinding]:
297
+ """Run architecture reconciliation rules. Never raises.
298
+
299
+ Args:
300
+ pattern: candidate pattern name (e.g. "mvc").
301
+ layer_files: {layer -> files matched for that layer}.
302
+ repo_files: every source file considered — supplies the repo's own
303
+ runtime, the arbiter of which layers belong to the claim.
304
+ """
305
+ findings: list[ReconciliationFinding] = []
306
+ try:
307
+ repo_runtimes = runtime_census({"_repo": list(repo_files)})["_repo"]
308
+ findings.extend(
309
+ _rule_layer_language_coherence(
310
+ pattern, runtime_census(layer_files), repo_runtimes
311
+ )
312
+ )
313
+ except Exception:
314
+ pass
315
+ return findings
316
+
317
+
318
+ def incoherent_layers(
319
+ pattern: str, layer_files: dict[str, list[str]], repo_files: "list[str]"
320
+ ) -> frozenset[str]:
321
+ """Layer names the coherence rule rejects for `pattern` (convenience view)."""
322
+ out: set[str] = set()
323
+ for f in reconcile_architecture_evidence(
324
+ pattern=pattern, layer_files=layer_files, repo_files=repo_files
325
+ ):
326
+ out.update(f.symbols)
327
+ return frozenset(out)
328
+
329
+
330
+ def reconcile_extraction_evidence(
331
+ *,
332
+ type_decl_files: frozenset[str],
333
+ extracted_files: frozenset[str],
334
+ ) -> list[ReconciliationFinding]:
335
+ """Run extraction-time reconciliation rules. Never raises.
336
+
337
+ Args:
338
+ type_decl_files: files whose masked source declares a Java type.
339
+ extracted_files: files symbol extraction produced ≥1 symbol for.
340
+ """
341
+ findings: list[ReconciliationFinding] = []
342
+ try:
343
+ findings.extend(_rule_parse_coverage(type_decl_files, extracted_files))
344
+ except Exception:
345
+ pass
346
+ return findings