sourcecode 2.5.3__py3-none-any.whl → 2.5.4__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.
sourcecode/__init__.py CHANGED
@@ -4,4 +4,4 @@ ASK Engine is the product. ``ask`` is the canonical CLI command; ``sourcecode``
4
4
  the legacy compatibility alias and the Python/PyPI package name. See
5
5
  docs/PRODUCT_IDENTITY.md (normative)."""
6
6
 
7
- __version__ = "2.5.3"
7
+ __version__ = "2.5.4"
@@ -31,7 +31,14 @@ _TOOLING_PREFIXES = (
31
31
  "dist/",
32
32
  "build/",
33
33
  )
34
- _SRC_TRANSPARENT = {"src", "lib", "app", "pkg"}
34
+ # Structural wrappers to walk THROUGH when looking for a module: they are build
35
+ # layout, never a module name. "main" and the language dirs come from the Maven /
36
+ # Gradle source-set convention (src/main/java/...) — without them a Maven repo with
37
+ # no other signal offers "main" as one of its modules (P1-D, spring-petclinic).
38
+ _SRC_TRANSPARENT = {
39
+ "src", "lib", "app", "pkg",
40
+ "main", "java", "kotlin", "scala", "groovy",
41
+ }
35
42
  _CODE_EXTENSIONS = {
36
43
  ".py", ".js", ".ts", ".tsx", ".jsx", ".mjs", ".cjs",
37
44
  ".go", ".java", ".kt", ".rs", ".rb",
@@ -56,7 +63,13 @@ _BENCHMARK_DIRS: frozenset[str] = frozenset({
56
63
  "sandbox",
57
64
  })
58
65
  _DOCS_DIRS: frozenset[str] = frozenset({"docs", "doc", "documentation", "wiki"})
59
- _TOOLING_DIRS: frozenset[str] = frozenset({"scripts", "script", "tools", "tool", "ci"})
66
+ # Tooling/CI config, never runtime architecture. The dot-dirs are conventional: a
67
+ # leading dot marks tooling config across ecosystems, and without them `.github`
68
+ # was offered as a neo4j "module" alongside community/ and packaging/ (P1-D).
69
+ _TOOLING_DIRS: frozenset[str] = frozenset({
70
+ "scripts", "script", "tools", "tool", "ci",
71
+ ".github", ".circleci", ".mvn", ".idea", ".vscode",
72
+ })
60
73
  # All dirs that are not part of the runtime source architecture
61
74
  _NON_SOURCE_DIRS: frozenset[str] = _TEST_DIRS | _BENCHMARK_DIRS | _DOCS_DIRS | _TOOLING_DIRS | _ASSET_DIRS
62
75
 
@@ -137,11 +150,25 @@ LAYER_PATTERNS: dict[str, dict[str, list[str]]] = {
137
150
  "model": ["model", "models", "entity", "entities", "domain"],
138
151
  "view": ["views", "templates", "pages", "components"],
139
152
  },
153
+ # P1-D: a keyword may only name a layer it actually ATTESTS. The dropped
154
+ # synonyms below are naming conventions that say nothing about a layer role,
155
+ # and they were the engine of the fabricated claims — mass alone cannot filter
156
+ # them out, because in the repos they mislabel they are large:
157
+ # * "api" — a Java `foo/api/` package is a public API surface (interfaces),
158
+ # not an HTTP controller layer. It manufactured neo4j's controller layer
159
+ # (925 files, 16.5%, and neo4j has no controller dir at all) and openmrs's
160
+ # (821 files, 94.8% — that is `org/openmrs/api/`, the service API).
161
+ # * "store"/"storage" — a storage ENGINE is not a repository/DAO layer
162
+ # (neo4j's `store` is exactly that).
163
+ # * "db"/"database" — a database package is not an infrastructure layer.
164
+ # Kept are the names that state their role: controller/handlers/routes/endpoints,
165
+ # repository/repo/dao, infra/infrastructure/persistence. Broadleaf keeps all four
166
+ # of its layers on those alone.
140
167
  "layered": {
141
- "controller": ["controller", "controllers", "api", "routes", "handlers", "endpoints"],
168
+ "controller": ["controller", "controllers", "routes", "handlers", "endpoints"],
142
169
  "service": ["service", "services", "usecase", "usecases", "application"],
143
- "repository": ["repository", "repositories", "repo", "repos", "store", "storage", "dao"],
144
- "infrastructure": ["infra", "infrastructure", "persistence", "db", "database"],
170
+ "repository": ["repository", "repositories", "repo", "repos", "dao"],
171
+ "infrastructure": ["infra", "infrastructure", "persistence"],
145
172
  },
146
173
  "hexagonal": {
147
174
  "port": ["port", "ports", "interface", "interfaces"],
@@ -154,7 +181,11 @@ LAYER_PATTERNS: dict[str, dict[str, list[str]]] = {
154
181
  },
155
182
  "fullstack": {
156
183
  "frontend": ["frontend", "client", "web", "ui", "pages", "components", "app"],
157
- "backend": ["backend", "server", "api", "services"],
184
+ # P1-D: "api" dropped — a `foo/api/` package is a public API surface, not
185
+ # evidence of a backend TIER. With it, openmrs (94.8% `org/openmrs/api/`)
186
+ # and jobrunr fell straight from a fabricated "layered" into a fabricated
187
+ # "fullstack" instead. Same conflation, same fix.
188
+ "backend": ["backend", "server", "services"],
158
189
  },
159
190
  }
160
191
 
@@ -167,6 +198,28 @@ _PATTERN_REQUIRED_KEYS: dict[str, frozenset[str]] = {
167
198
  "mvc": frozenset({"view"}),
168
199
  }
169
200
 
201
+ # Minimum share of source files a matched layer must hold to count as a LAYER
202
+ # (P1-D, neo4j field test). Matching was pure name PRESENCE over the set of every
203
+ # directory name in the repo, so one incidental dir minted a whole layer: neo4j — a
204
+ # graph engine with no controller, repository or infrastructure directory anywhere —
205
+ # reported "Layered Architecture with controller, service, repository, infrastructure
206
+ # layers", synthesized from `api`, `service` (7 files), `store`/`storage` and
207
+ # `database`. Presence does not scale: the bigger the repo, the likelier some
208
+ # directory somewhere matches a keyword, so large systems were the most confidently
209
+ # mislabelled.
210
+ #
211
+ # A layer is a place where a real share of the code lives. Same reasoning the
212
+ # archetype engine already applies to the same question ("a 0.3% `api/` dir does not
213
+ # manufacture a controller layer", _architectural_style).
214
+ #
215
+ # Calibrated against the field-test repos rather than copied from archetype's 5%,
216
+ # which scores a web+service+repository triplet and is too coarse to erase a single
217
+ # layer with: Broadleaf's controller layer is a real 4.68% (it has a literal
218
+ # `controller/` dir) and 5% deleted it. The phantoms all sit an order of magnitude
219
+ # lower — jenkins 0.72% (`handlers`, and it has no HTTP controllers at all), ofbiz
220
+ # 0.19%, neo4j's service layer 7 files at 0.12% — so 1% separates them cleanly.
221
+ _LAYER_MIN_SHARE = 0.01
222
+
170
223
  # Higher value = wins when score ties
171
224
  _PATTERN_PRIORITY: dict[str, int] = {
172
225
  "cqrs": 8,
@@ -555,24 +608,44 @@ class ArchitectureAnalyzer:
555
608
  if not source_paths:
556
609
  return "unknown", []
557
610
 
558
- dir_names: set[str] = set()
559
- for p in source_paths:
560
- parts = p.replace("\\", "/").split("/")
561
- for part in parts[:-1]:
562
- dir_names.add(part.lower())
611
+ # Index each directory NAME to the source files under it, so a matched layer
612
+ # can be weighed by the code it actually holds instead of counted as present
613
+ # (P1-D). Built once; every pattern below reads it.
614
+ dir_files: dict[str, set[int]] = {}
615
+ for i, p in enumerate(source_paths):
616
+ for part in p.replace("\\", "/").split("/")[:-1]:
617
+ dir_files.setdefault(part.lower(), set()).add(i)
618
+ dir_names: set[str] = set(dir_files)
619
+ total_files = len(source_paths)
620
+
621
+ def _layer_files(matched_dirs: list[str]) -> set[int]:
622
+ files: set[int] = set()
623
+ for d in matched_dirs:
624
+ files |= dir_files.get(d, set())
625
+ return files
563
626
 
564
627
  # 1. Classical keyword-based pattern matching
565
628
  best_pattern = ""
566
629
  best_score = 0
567
630
  best_priority = -1
568
631
  best_matched: dict[str, list[str]] = {}
632
+ best_layer_files: dict[str, set[int]] = {}
569
633
 
570
634
  for pattern_name, layer_keys in LAYER_PATTERNS.items():
571
635
  matched: dict[str, list[str]] = {}
636
+ layer_files: dict[str, set[int]] = {}
572
637
  for layer_key, keywords in layer_keys.items():
573
638
  matched_dirs = [d for d in dir_names if d in keywords]
574
- if matched_dirs:
575
- matched[layer_key] = matched_dirs
639
+ if not matched_dirs:
640
+ continue
641
+ # P1-D: a layer is where a real share of the code lives. An incidental
642
+ # directory that merely matches a keyword is not one — neo4j's 7-file
643
+ # `service/` dir (0.12%) never made it a service layer.
644
+ files = _layer_files(matched_dirs)
645
+ if total_files and len(files) / total_files < _LAYER_MIN_SHARE:
646
+ continue
647
+ matched[layer_key] = matched_dirs
648
+ layer_files[layer_key] = files
576
649
  # A pattern with unmet required keys cannot qualify (e.g. mvc needs a view).
577
650
  required = _PATTERN_REQUIRED_KEYS.get(pattern_name)
578
651
  if required and not required.issubset(matched.keys()):
@@ -584,18 +657,13 @@ class ArchitectureAnalyzer:
584
657
  best_priority = priority
585
658
  best_pattern = pattern_name
586
659
  best_matched = matched
660
+ best_layer_files = layer_files
587
661
 
588
662
  if best_score >= 2:
589
663
  layer_confidence: Literal["high", "medium", "low"] = "medium" if best_score >= 3 else "low"
590
664
  layers: list[ArchitectureLayer] = []
591
665
  for layer_key, matched_dirs in best_matched.items():
592
- matched_files = [
593
- p for p in source_paths
594
- if any(
595
- seg.lower() in matched_dirs
596
- for seg in p.replace("\\", "/").split("/")[:-1]
597
- )
598
- ]
666
+ matched_files = [source_paths[i] for i in sorted(best_layer_files[layer_key])]
599
667
  layers.append(ArchitectureLayer(
600
668
  name=layer_key,
601
669
  pattern=best_pattern,
@@ -716,6 +784,14 @@ class ArchitectureAnalyzer:
716
784
  - orchestration: cli.py, main.py, *_handler.py, *_controller.py
717
785
  - processing: *_analyzer.py, *_processor.py, *_parser.py, *_detector.py, *_scanner.py
718
786
  - data: schema.py, model.py, *_repository.py, *_serializer.py, config.py, …
787
+
788
+ P1-D: weighed by share, like the directory-name patterns. Matching on file
789
+ stems alone let a handful of incidentally-named files mint logical layers:
790
+ neo4j's "orchestration, processing, data" rested on ~6 files out of 5616
791
+ (0.05% each) — one `Main.java`, a `Config.java`, a `Serializer.java`, and two
792
+ `Server.java` that are model classes under `topology/model/`, "orchestration"
793
+ only because of their stem. Three layers named off 0.1% of a codebase is a
794
+ description of nothing.
719
795
  """
720
796
  layer_files: dict[str, list[str]] = {"orchestration": [], "processing": [], "data": []}
721
797
  for p in paths:
@@ -728,7 +804,11 @@ class ArchitectureAnalyzer:
728
804
  layer_files[layer].append(p)
729
805
  break
730
806
 
731
- non_empty = {k: v for k, v in layer_files.items() if v}
807
+ total = len(paths)
808
+ non_empty = {
809
+ k: v for k, v in layer_files.items()
810
+ if v and (not total or len(v) / total >= _LAYER_MIN_SHARE)
811
+ }
732
812
  if len(non_empty) >= 2:
733
813
  return "layered", [
734
814
  ArchitectureLayer(name=k, pattern="layered", files=v, confidence="low")
sourcecode/classifier.py CHANGED
@@ -25,7 +25,9 @@ _API_FRAMEWORKS = {
25
25
  "Micronaut",
26
26
  "Vert.x",
27
27
  "Quarkus",
28
- "Jakarta EE", # JAX-RS / Jakarta REST — pure JAX-RS projects must not fall to "unknown"
28
+ "JAX-RS", # Jakarta REST — pure JAX-RS projects must not fall to "unknown"
29
+ "Jakarta EE", # platform umbrella artifact (jakartaee-api); implies a web profile
30
+ "Java EE", # javax.* umbrella artifact (javaee-api)
29
31
  "Laravel",
30
32
  "Symfony",
31
33
  "Ktor",
@@ -42,7 +44,7 @@ _API_STACKS = {"python", "go", "java", "php", "ruby", "dotnet", "kotlin", "scala
42
44
  # guard because they are repo-wide (no locatable minority module):
43
45
  # * a jakarta.ws.rs / javax.ws.rs-api coordinate pinned in a BOM/parent pom's
44
46
  # <dependencyManagement> (a version pin, not usage), and
45
- # * a source-annotation "Jakarta EE" marker synthesized from @Path resources
47
+ # * a source-annotation "JAX-RS" marker synthesized from @Path resources
46
48
  # (detected_via carries no file path).
47
49
  # So on a repo large enough to HAVE a distinct center of gravity, require the API
48
50
  # surface to occupy a real share of the code (or the dominant module) before we
@@ -185,17 +185,22 @@ class JavaDetector(AbstractDetector):
185
185
  # build.gradle files are not scanned at all — yet it ships 18 @Path resource
186
186
  # classes. Without this the headline read "Java library / package" while the
187
187
  # endpoints command listed a full JAX-RS API. When the annotation scan found
188
- # JAX-RS entry points and no Jakarta-EE framework was inferred from the
188
+ # JAX-RS entry points and no JAX-RS framework was inferred from the
189
189
  # manifest, surface it so the stack (and project_type) reflect the REST API
190
190
  # the endpoints already prove. Structural (VAI): reasons from the JAX-RS
191
191
  # entry points found in source, not from a vendor dependency-name allowlist.
192
192
  # detected_via carries no path form, so it is treated as repo-wide evidence
193
193
  # (never localized to an adapter submodule).
194
- if not any(f.name == "Jakarta EE" for f in frameworks) and any(
194
+ # P1-C: @Path/@GET resources prove JAX-RS the one spec they belong to —
195
+ # and nothing about the rest of the Jakarta EE platform. Eureka (Jersey 1.x
196
+ # + Guice, no EE container) read "Java rest api using Jakarta EE" on this
197
+ # path alone. The API framing this exists to deliver is unchanged; only the
198
+ # claim narrows to what the annotations attest.
199
+ if not any(f.name == "JAX-RS" for f in frameworks) and any(
195
200
  ep.kind in ("jax_rs_controller", "jax_rs_provider") for ep in entry_points
196
201
  ):
197
202
  frameworks.append(FrameworkDetection(
198
- name="Jakarta EE", source="annotation", detected_via=["jax_rs_endpoints"],
203
+ name="JAX-RS", source="annotation", detected_via=["jax_rs_endpoints"],
199
204
  ))
200
205
 
201
206
  transactional_classes = self._collect_transactional_classes(context, all_paths)
@@ -436,15 +441,27 @@ class JavaDetector(AbstractDetector):
436
441
  frameworks.append(FrameworkDetection(name="Micronaut", source=source))
437
442
  if "io.vertx" in text or "vertx" in text:
438
443
  frameworks.append(FrameworkDetection(name="Vert.x", source=source))
444
+ # P1-C: name the spec that is actually evidenced, not the platform that
445
+ # contains it. "Jakarta EE" is a platform of ~40 specs; a JAX-RS coordinate
446
+ # attests ONE of them. Claiming the platform from it told neo4j (a graph
447
+ # engine with an HTTP API) and jobrunr (a job library with RESTEasy on the
448
+ # test path) that they were Jakarta EE applications.
449
+ #
450
+ # Only an aggregate platform artifact — the coordinate that IS the platform
451
+ # umbrella — attests the platform itself. Counting distinct jakarta.* specs
452
+ # instead would misfire: Spring Boot 3 pulls jakarta.servlet, .validation and
453
+ # .persistence transitively (Broadleaf ships the first two), so any threshold
454
+ # low enough to catch a real EE app relabels every Boot 3 app.
455
+ if "javaee-api" in text: # javax.* umbrella (pre-EE 9)
456
+ frameworks.append(FrameworkDetection(name="Java EE", source=source))
457
+ if "jakartaee-api" in text or "jakarta.platform" in text or "jakarta.ee" in text:
458
+ frameworks.append(FrameworkDetection(name="Jakarta EE", source=source))
439
459
  if (
440
- "jakarta.ee" in text
441
- or "javax.ws.rs-api" in text # JAX-RS 2.x spec; excludes jsr311-api (1.x API-only jar)
442
- or "jakarta.ws.rs" in text # Jakarta namespace (EE 9+)
443
- or "javaee-api" in text # full Java EE platform
444
- or "jakartaee-api" in text # full Jakarta EE platform
445
- or "resteasy" in text # RESTEasy (JBoss JAX-RS impl)
460
+ "javax.ws.rs-api" in text # JAX-RS 2.x spec; excludes jsr311-api (1.x API-only jar)
461
+ or "jakarta.ws.rs" in text # Jakarta namespace (EE 9+)
462
+ or "resteasy" in text # RESTEasy a JAX-RS implementation
446
463
  ):
447
- frameworks.append(FrameworkDetection(name="Jakarta EE", source=source))
464
+ frameworks.append(FrameworkDetection(name="JAX-RS", source=source))
448
465
  if "mybatis" in text:
449
466
  frameworks.append(FrameworkDetection(name="MyBatis", source=source))
450
467
  if "thymeleaf" in text:
@@ -0,0 +1,201 @@
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
+ Never raises. Empty evidence → no findings.
38
+ """
39
+ from __future__ import annotations
40
+
41
+ import re
42
+ from dataclasses import dataclass
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class ReconciliationFinding:
47
+ """One positively-detected contradiction between two evidence views."""
48
+ rule: str # stable rule id, e.g. "endpoint_coverage"
49
+ symbols: tuple[str, ...] # symbols the contradiction is anchored to
50
+ detail: str # one-sentence human/agent-readable statement
51
+
52
+ def to_dict(self) -> dict:
53
+ return {
54
+ "rule": self.rule,
55
+ "symbols": list(self.symbols),
56
+ "detail": self.detail,
57
+ }
58
+
59
+
60
+ def http_handler_classes(cir) -> frozenset[str]:
61
+ """Classes with at least one method carrying an HTTP handler annotation.
62
+
63
+ Independent evidence of "this class serves HTTP" derived straight from the
64
+ per-symbol annotation facts — deliberately NOT derived from the endpoint
65
+ index, so the two views can be reconciled against each other.
66
+ """
67
+ from sourcecode.repository_ir import _ENDPOINT_ANNOTATIONS
68
+
69
+ raw = getattr(cir, "_raw_ir", None) or {}
70
+ nodes = (raw.get("graph") or {}).get("nodes") or []
71
+ out: set[str] = set()
72
+ for node in nodes:
73
+ if not isinstance(node, dict):
74
+ continue
75
+ fqn = node.get("fqn") or ""
76
+ if "#" not in fqn:
77
+ continue
78
+ anns = node.get("annotations") or []
79
+ if any(a in _ENDPOINT_ANNOTATIONS for a in anns):
80
+ out.add(fqn.split("#", 1)[0])
81
+ return frozenset(out)
82
+
83
+
84
+ def _rule_endpoint_coverage(
85
+ chain_classes: frozenset[str],
86
+ handler_classes: frozenset[str],
87
+ modeled_classes: frozenset[str],
88
+ ) -> list[ReconciliationFinding]:
89
+ """Chain class declares HTTP handlers but the endpoint index has no route for it.
90
+
91
+ Fires only on total absence from the index (extractor could not model ANY
92
+ route for a class that provably declares handlers). A class that IS in the
93
+ index but contributes only a subset of its endpoints to a query is the
94
+ intentional method-precision rule, not a contradiction — never flagged.
95
+ """
96
+ unmapped = sorted((chain_classes & handler_classes) - modeled_classes)
97
+ if not unmapped:
98
+ return []
99
+ return [
100
+ ReconciliationFinding(
101
+ rule="endpoint_coverage",
102
+ symbols=tuple(unmapped),
103
+ detail=(
104
+ f"{len(unmapped)} class(es) in the call chain declare HTTP handler "
105
+ "methods but the endpoint index modeled no route for them — "
106
+ "endpoints_affected under-reports real HTTP exposure."
107
+ ),
108
+ )
109
+ ]
110
+
111
+
112
+ def reconcile_impact_evidence(
113
+ *,
114
+ chain_classes: frozenset[str],
115
+ handler_classes: frozenset[str],
116
+ modeled_classes: frozenset[str],
117
+ ) -> list[ReconciliationFinding]:
118
+ """Run all impact-query reconciliation rules. Never raises.
119
+
120
+ Args:
121
+ chain_classes: classes of every seed + caller in the resolved chain.
122
+ handler_classes: classes with HTTP-handler annotation evidence
123
+ (from http_handler_classes(cir)).
124
+ modeled_classes: classes the endpoint index has ≥1 route for
125
+ (endpoint_index.controller_fqns).
126
+ """
127
+ findings: list[ReconciliationFinding] = []
128
+ try:
129
+ findings.extend(
130
+ _rule_endpoint_coverage(chain_classes, handler_classes, modeled_classes)
131
+ )
132
+ except Exception:
133
+ pass
134
+ return findings
135
+
136
+
137
+ # ---------------------------------------------------------------------------
138
+ # Extraction-time reconciliation (file inventory × symbol index)
139
+ # ---------------------------------------------------------------------------
140
+
141
+ # A type-declaration keyword followed by the start of an identifier. Applied to
142
+ # COMMENT/STRING-MASKED source only, so `/* class Ghost */` and "class" inside
143
+ # a string literal never match. `record` is a contextual keyword; requiring a
144
+ # following identifier keeps `record = ...` assignments out, and the rule is
145
+ # only ever consulted for files that yielded zero symbols — where a stray
146
+ # match would still be pointing at real, unmodeled Java.
147
+ _TYPE_DECL_RE = re.compile(r"\b(?:class|interface|enum|record)\s+\w")
148
+
149
+
150
+ def declares_java_type(masked_source: str) -> bool:
151
+ """True if comment/string-masked Java source declares a type.
152
+
153
+ The caller masks (repository_ir._mask_java) so this stays a pure text
154
+ predicate with no parsing dependency.
155
+ """
156
+ return bool(_TYPE_DECL_RE.search(masked_source))
157
+
158
+
159
+ def _rule_parse_coverage(
160
+ type_decl_files: frozenset[str],
161
+ extracted_files: frozenset[str],
162
+ ) -> list[ReconciliationFinding]:
163
+ """File declares a type but symbol extraction produced nothing for it.
164
+
165
+ Fires only on total extraction absence for the file — a file that yielded
166
+ at least one symbol is never flagged (partial member loss is a different,
167
+ weaker signal this rule does not claim to detect).
168
+ """
169
+ lost = sorted(type_decl_files - extracted_files)
170
+ if not lost:
171
+ return []
172
+ return [
173
+ ReconciliationFinding(
174
+ rule="parse_coverage",
175
+ symbols=tuple(lost),
176
+ detail=(
177
+ f"{len(lost)} Java file(s) declare a type but produced no symbols — "
178
+ "their classes and methods are invisible to the graph, callers and "
179
+ "endpoints."
180
+ ),
181
+ )
182
+ ]
183
+
184
+
185
+ def reconcile_extraction_evidence(
186
+ *,
187
+ type_decl_files: frozenset[str],
188
+ extracted_files: frozenset[str],
189
+ ) -> list[ReconciliationFinding]:
190
+ """Run extraction-time reconciliation rules. Never raises.
191
+
192
+ Args:
193
+ type_decl_files: files whose masked source declares a Java type.
194
+ extracted_files: files symbol extraction produced ≥1 symbol for.
195
+ """
196
+ findings: list[ReconciliationFinding] = []
197
+ try:
198
+ findings.extend(_rule_parse_coverage(type_decl_files, extracted_files))
199
+ except Exception:
200
+ pass
201
+ return findings