sourcecode 2.5.2__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.
@@ -28,6 +28,7 @@ from __future__ import annotations
28
28
  from dataclasses import dataclass
29
29
  from typing import TYPE_CHECKING, Optional
30
30
 
31
+ from sourcecode.repository_ir import _resolve_declared_type
31
32
  from sourcecode.semantic_services import SemanticServices
32
33
 
33
34
  if TYPE_CHECKING: # avoid import cycle
@@ -236,13 +237,15 @@ class SemanticImpactEngine:
236
237
  """(field_name, type_fqn) for fields whose declared type resolves to a role
237
238
  in `roles` — the plain setter/XML-injected dependencies the injection graph
238
239
  cannot see (e.g. openmrs `private TemplateDAO templateDAO;`). Uses field-type
239
- facts (#10) resolved to a concrete type by simple name (unambiguous only)."""
240
+ facts (#10), which carry the owning file's import-resolved FQN when it had
241
+ one; a bare simple name still resolves only while unambiguous."""
240
242
  by_simple = self.s.types_by_simple()
243
+ known = {f for fqns in by_simple.values() for f in fqns}
241
244
  out: list[tuple[str, str]] = []
242
- for fname, tsimple in sorted(self.s.field_types_of(owner_fqn).items()):
243
- fqns = by_simple.get(tsimple, [])
244
- if len(fqns) == 1 and self._role(fqns[0]) in roles:
245
- out.append((fname, fqns[0]))
245
+ for fname, declared in sorted(self.s.field_types_of(owner_fqn).items()):
246
+ fqn = _resolve_declared_type(declared, owner_fqn, by_simple, known)
247
+ if fqn and self._role(fqn) in roles:
248
+ out.append((fname, fqn))
246
249
  return out
247
250
 
248
251
  def _called_via_receiver(self, owner_fqn: str, receiver: str) -> Optional[str]:
@@ -209,9 +209,11 @@ class SemanticServices:
209
209
  return self._g.annotation_arg_values_in(fqn)
210
210
 
211
211
  def field_types_of(self, class_fqn: str) -> dict[str, str]:
212
- """{field_name -> declared_type_simple_name} for a class's fields (annotated
213
- or plain). Resolves a body invocation's receiver to its typed dependency —
214
- the fact injection edges miss for plain setter/XML-injected fields."""
212
+ """{field_name -> declared_type} for a class's fields (annotated or plain).
213
+ Resolves a body invocation's receiver to its typed dependency — the fact
214
+ injection edges miss for plain setter/XML-injected fields. The type is the
215
+ FQN when the declaring file imported it explicitly, else a simple name;
216
+ `repository_ir._resolve_declared_type` handles both."""
215
217
  return self._g.field_types_in(class_fqn)
216
218
 
217
219
  def return_type_index(self) -> dict[str, set[str]]:
@@ -26,6 +26,7 @@ from pathlib import Path
26
26
  from typing import TYPE_CHECKING, Optional
27
27
 
28
28
  from sourcecode.fqn_utils import normalize_owner_fqn
29
+ from sourcecode.reconciliation import http_handler_classes, reconcile_impact_evidence
29
30
  from sourcecode.spring_findings import SEVERITY_ORDER, SpringFinding
30
31
  from sourcecode.spring_model import SpringSemanticModel
31
32
 
@@ -340,6 +341,22 @@ def _recover_external_iface_callers(
340
341
  # Symbol resolution
341
342
  # ---------------------------------------------------------------------------
342
343
 
344
+ def _parse_coverage_gap(cir) -> Optional[dict]:
345
+ """The parse_coverage analysis gap from CIR metadata, if any (P1-E).
346
+
347
+ Written by the extraction-time reconciliation rule (reconciliation.py):
348
+ files that declare a type but produced no symbols. Impact answers built on
349
+ a graph with invisible files must say so instead of asserting completeness.
350
+ """
351
+ try:
352
+ for g in (getattr(cir, "metadata", None) or {}).get("analysis_gaps") or []:
353
+ if isinstance(g, dict) and g.get("area") == "parse_coverage":
354
+ return g
355
+ except Exception:
356
+ pass
357
+ return None
358
+
359
+
343
360
  def _class_of(fqn: str) -> str:
344
361
  """Extract class FQN from a method FQN. pkg.Class#method → pkg.Class."""
345
362
  if "#" in fqn:
@@ -581,6 +598,32 @@ def _bfs_callers(
581
598
  return direct, indirect, was_truncated, self_excluded
582
599
 
583
600
 
601
+ def _dedupe_caller_granularity(callers: list[str]) -> list[str]:
602
+ """Drop a class-level caller entry when a method entry of the same class is
603
+ present in the same list (P1-B3). Order preserved.
604
+
605
+ A static call binds TWO edges: the coarse class-level one from the per-file
606
+ scan (`X --calls--> T`, which cannot name the callee) and, since P1-B2, the
607
+ precise method-level one (`X#m --calls--> T#callee`). They are additive on
608
+ purpose — a static call in a field initializer or static block produces no
609
+ body fact, so only the class edge carries it and dropping it erases real
610
+ coupling (measured: BroadleafCurrencyUtils lost 1325→561 transitive reach and
611
+ all 20 affected endpoints). The cost of keeping both is that one caller is
612
+ reported at two granularities: `X` and `X#m` read as two callers, and
613
+ `len(direct_callers)` double-counts them into `_compute_risk`.
614
+
615
+ Collapsing is safe HERE and only here, because it is a view: the method entry
616
+ already names the class, so the set of caller *classes* is identical and no
617
+ edge is deleted. A class entry with no method sibling (the field-initializer
618
+ case) has no precise entry to defer to and is kept. Callers of both
619
+ granularities remain the input to endpoint and finding mapping — a class node
620
+ reaches every endpoint of a controller, a method node only its own — so this
621
+ is applied to the reported/scored lists, never to the reach computation.
622
+ """
623
+ classes_with_methods = {_class_of(c) for c in callers if "#" in c}
624
+ return [c for c in callers if "#" in c or c not in classes_with_methods]
625
+
626
+
584
627
  # ---------------------------------------------------------------------------
585
628
  # Endpoint mapping
586
629
  # ---------------------------------------------------------------------------
@@ -744,10 +787,21 @@ def _build_chain_explanation(
744
787
  endpoints_affected: int,
745
788
  findings: int,
746
789
  confidence: str,
790
+ blind_spot: bool = False,
747
791
  ) -> str:
748
792
  """Human-readable rationale, phrased to match the `impact` command's
749
- explanation so both surfaces read consistently."""
793
+ explanation so both surfaces read consistently.
794
+
795
+ `blind_spot` says a positively-detected gap explains the empty result, so it must
796
+ not be narrated as an isolated change: the warnings carry the diagnosis, and this
797
+ sentence is what an agent quotes."""
750
798
  if not direct_callers and not indirect_callers and not endpoints_affected:
799
+ if blind_spot:
800
+ return (
801
+ "No callers or endpoints resolved, but this analysis has a known blind "
802
+ "spot (see analysis_warnings) — an empty result here is NOT evidence of "
803
+ "an isolated or low-risk change."
804
+ )
751
805
  return "No callers or endpoints found in the impact chain. Low-risk isolated change."
752
806
  parts: list[str] = []
753
807
  if direct_callers:
@@ -840,10 +894,22 @@ class ImpactOrchestrator:
840
894
  warnings.extend(sym_warnings)
841
895
 
842
896
  if resolution == "not_found" or not seed_fqns:
897
+ if not warnings:
898
+ warnings = [f"Symbol '{symbol}' not found in CIR."]
899
+ # P1-E: a not-found verdict is weaker when files failed extraction —
900
+ # the queried symbol may be defined in one of them.
901
+ _nf_gap = _parse_coverage_gap(cir)
902
+ if _nf_gap:
903
+ _nf_n = len(_nf_gap.get("files") or [])
904
+ warnings.append(
905
+ f"Parse-coverage gap (P1-E): {_nf_n} Java file(s) declare a type "
906
+ "but produced no symbols — the queried symbol may be defined in "
907
+ "one of them (see analysis_gaps)."
908
+ )
843
909
  return ImpactChainResult(
844
910
  symbol=symbol,
845
911
  resolution="not_found",
846
- analysis_warnings=warnings or [f"Symbol '{symbol}' not found in CIR."],
912
+ analysis_warnings=warnings,
847
913
  risk_level="unknown",
848
914
  confidence="low",
849
915
  explanation=f"Symbol {symbol!r} not found in the IR.",
@@ -942,10 +1008,17 @@ class ImpactOrchestrator:
942
1008
  )
943
1009
 
944
1010
  # ── 2. BFS through reverse graph ─────────────────────────────────
945
- direct_callers, indirect_callers, truncated, self_excluded = _bfs_callers(
1011
+ direct_callers_raw, indirect_callers_raw, truncated, self_excluded = _bfs_callers(
946
1012
  seed_fqns, cir.reverse_graph, depth, impl_graph=impl_graph,
947
1013
  method_scoped=_method_scoped,
948
1014
  )
1015
+ # P1-B3: the reported/scored caller lists collapse the class-vs-method
1016
+ # granularity duplicate that additive static edges (P1-B2) produce. The RAW
1017
+ # lists stay the input to endpoint/finding mapping below: a class-level node
1018
+ # carries reach a method node does not (all handlers of a controller), so
1019
+ # deduping there would erase endpoints. See _dedupe_caller_granularity.
1020
+ direct_callers = _dedupe_caller_granularity(direct_callers_raw)
1021
+ indirect_callers = _dedupe_caller_granularity(indirect_callers_raw)
949
1022
  if self_excluded:
950
1023
  if _method_scoped:
951
1024
  warnings.append(
@@ -967,7 +1040,7 @@ class ImpactOrchestrator:
967
1040
  confidence_reducing = True # capped traversal → result is incomplete
968
1041
 
969
1042
  # ── 3. Endpoints affected ─────────────────────────────────────────
970
- all_callers = direct_callers + indirect_callers
1043
+ all_callers = direct_callers_raw + indirect_callers_raw
971
1044
  endpoints_affected = _collect_endpoints(all_callers, seed_fqns, model)
972
1045
 
973
1046
  # ── 4. TX boundary for the target symbol ─────────────────────────
@@ -994,7 +1067,8 @@ class ImpactOrchestrator:
994
1067
  all_findings = _run_audit_for_chain(cir, model, root)
995
1068
 
996
1069
  impact_findings_raw = _filter_findings(
997
- all_findings, seed_fqns, direct_callers, indirect_callers, endpoints_affected
1070
+ all_findings, seed_fqns, direct_callers_raw, indirect_callers_raw,
1071
+ endpoints_affected,
998
1072
  )
999
1073
  # Sort by severity, then symbol
1000
1074
  impact_findings_raw.sort(
@@ -1041,19 +1115,21 @@ class ImpactOrchestrator:
1041
1115
  )
1042
1116
  di_binding_ambiguous = _max_impl > 1
1043
1117
  # Keep only genuinely new caller classes (not seeds / already found).
1044
- _known = set(seed_fqns) | set(direct_callers) | set(indirect_callers)
1118
+ _known = set(seed_fqns) | set(direct_callers_raw) | set(indirect_callers_raw)
1045
1119
  di_recovered_callers = [c for c in di_recovered_callers if c not in _known]
1046
1120
 
1047
1121
  di_recovered = bool(di_recovered_callers)
1048
1122
  if di_recovered:
1049
1123
  # Merge recovered wiring sites into the chain and recompute the views
1050
1124
  # that depend on the caller set (endpoints, findings, surfaces, risk).
1125
+ direct_callers_raw = direct_callers_raw + di_recovered_callers
1051
1126
  direct_callers = direct_callers + di_recovered_callers
1052
1127
  empty_blast = False
1053
- all_callers = direct_callers + indirect_callers
1128
+ all_callers = direct_callers_raw + indirect_callers_raw
1054
1129
  endpoints_affected = _collect_endpoints(all_callers, seed_fqns, model)
1055
1130
  impact_findings_raw = _filter_findings(
1056
- all_findings, seed_fqns, direct_callers, indirect_callers, endpoints_affected
1131
+ all_findings, seed_fqns, direct_callers_raw, indirect_callers_raw,
1132
+ endpoints_affected,
1057
1133
  )
1058
1134
  impact_findings_raw.sort(
1059
1135
  key=lambda f: (SEVERITY_ORDER.get(f.severity, 9), f.symbol)
@@ -1130,6 +1206,107 @@ class ImpactOrchestrator:
1130
1206
  "proof this symbol is unused."
1131
1207
  )
1132
1208
 
1209
+ # P1-B: every guard above needs a class-level seed, so a `Class#method` query
1210
+ # never reached one — an empty method blast was reported as a high-confidence
1211
+ # "low-risk isolated change" no matter what was known about the class. When the
1212
+ # declaring class has inbound DI/import edges, the class-level query resolves
1213
+ # those callers correctly (they land on the class node), so the tool used to
1214
+ # contradict itself across granularity — "isolated" for the method, "critical"
1215
+ # for its class — with no signal that the narrower, more natural query was the
1216
+ # less trustworthy one. The method may still be genuinely uncalled; what is
1217
+ # unproven is the *emptiness*, so this downgrades confidence and warns rather
1218
+ # than inventing callers.
1219
+ method_scope_blind_spot = False
1220
+ if empty_blast and not class_level_seed and resolution != "not_found":
1221
+ _crev = cir.reverse_graph.get(_class_of(resolved_symbol)) or {}
1222
+ method_scope_inbound = sorted(
1223
+ {c for et in ("injects", "imports") for c in (_crev.get(et) or [])}
1224
+ )
1225
+ if method_scope_inbound:
1226
+ method_scope_blind_spot = True
1227
+ warnings.append(
1228
+ f"Method-scoped empty blast radius is unproven (P1-B): no call edge "
1229
+ f"resolves to this method, but {len(method_scope_inbound)} in-repo "
1230
+ f"class(es) inject or import the declaring class "
1231
+ f"{_class_of(resolved_symbol)}. The call may travel an edge the "
1232
+ "graph does not model (wildcard-imported or generic receiver, "
1233
+ "reflection, method reference, framework callback). 0 callers is "
1234
+ "NOT proof this method is unused — re-query at class level (drop "
1235
+ "the '#method') to see the declaring class's callers."
1236
+ )
1237
+
1238
+ # P1-E: an empty blast radius computed over a graph with provably
1239
+ # invisible files is unproven — a caller could live in a file that
1240
+ # declared a type but produced no symbols (parse_coverage gap written by
1241
+ # the extraction-time reconciliation rule). Fires at any seed
1242
+ # granularity: unlike CH-003/CH-005/G-2 (class-level) and P1-B
1243
+ # (method-level), file invisibility undermines both equally.
1244
+ parse_coverage_blind_spot = False
1245
+ if empty_blast:
1246
+ _pc_gap = _parse_coverage_gap(cir)
1247
+ if _pc_gap:
1248
+ parse_coverage_blind_spot = True
1249
+ _pc_n = len(_pc_gap.get("files") or [])
1250
+ warnings.append(
1251
+ f"Parse-coverage gap (P1-E): {_pc_n} Java file(s) declare a type "
1252
+ "but produced no symbols — a caller could live in an unmodeled "
1253
+ "file, so an empty blast radius is NOT proof this symbol is "
1254
+ "unused (see analysis_gaps for the file list)."
1255
+ )
1256
+
1257
+ # F-1 (Evidence Reconciliation Layer) / SIM-2: the call graph and the
1258
+ # endpoint index are independent evidence views and can contradict each
1259
+ # other. keycloak simulacro: the verified caller chain contained
1260
+ # AuthorizationEndpoint#process while the endpoint index had NO route for
1261
+ # that class (a JAX-RS sub-resource locator chain the route extractor
1262
+ # cannot compose) — "2 endpoints exposed" was narrated at confidence=high
1263
+ # while the real OAuth surface was invisible. Unlike the guards above,
1264
+ # this fires on PARTIAL results, not just empty ones: a chain class that
1265
+ # provably declares HTTP handlers but has no modeled route is a
1266
+ # positively-detected under-report of endpoints_affected.
1267
+ endpoint_coverage_blind_spot = False
1268
+ reconciliation_findings: list[dict] = []
1269
+ try:
1270
+ _chain_classes = frozenset(
1271
+ _class_of(f)
1272
+ for f in set(seed_fqns) | set(direct_callers_raw) | set(indirect_callers_raw)
1273
+ )
1274
+ _rec = reconcile_impact_evidence(
1275
+ chain_classes=_chain_classes,
1276
+ handler_classes=http_handler_classes(cir),
1277
+ modeled_classes=frozenset(model.endpoint_index.controller_fqns),
1278
+ )
1279
+ reconciliation_findings = [f.to_dict() for f in _rec]
1280
+ _unmapped: list[str] = []
1281
+ for _f in _rec:
1282
+ if _f.rule == "endpoint_coverage":
1283
+ endpoint_coverage_blind_spot = True
1284
+ _unmapped = list(_f.symbols)
1285
+ if endpoint_coverage_blind_spot:
1286
+ warnings.append(
1287
+ f"Endpoint-coverage contradiction (evidence reconciliation): {len(_unmapped)} "
1288
+ "class(es) in the call chain declare HTTP handler methods but the "
1289
+ "endpoint index has no modeled route for them ["
1290
+ + ", ".join(_unmapped[:5])
1291
+ + (", …" if len(_unmapped) > 5 else "")
1292
+ + "]. endpoints_affected UNDER-REPORTS real HTTP exposure (routes "
1293
+ "the extractor could not compose, e.g. JAX-RS sub-resource "
1294
+ "locator chains). Callers remain reliable; verify these classes' "
1295
+ "exposure before treating the endpoint list as complete."
1296
+ )
1297
+ except Exception:
1298
+ pass
1299
+
1300
+ blind_spots = (
1301
+ # framework_di stops being a blind spot once CH-007 recovers callers
1302
+ (["framework_di"] if framework_di_blind_spot and not di_recovered else [])
1303
+ + (["value_type"] if value_type_blind_spot else [])
1304
+ + (["unresolved_refs"] if unresolved_ref_blind_spot else [])
1305
+ + (["method_scope_unproven"] if method_scope_blind_spot else [])
1306
+ + (["parse_coverage"] if parse_coverage_blind_spot else [])
1307
+ + (["endpoint_coverage_partial"] if endpoint_coverage_blind_spot else [])
1308
+ )
1309
+
1133
1310
  confidence: str
1134
1311
  if resolution == "not_found":
1135
1312
  confidence = "low"
@@ -1137,15 +1314,38 @@ class ImpactOrchestrator:
1137
1314
  # Callers recovered structurally via the external-interface DI bridge.
1138
1315
  # Unambiguous binding (single in-repo impl) → medium; ambiguous → low.
1139
1316
  confidence = "low" if di_binding_ambiguous else "medium"
1140
- elif framework_di_blind_spot or value_type_blind_spot or unresolved_ref_blind_spot:
1317
+ elif (
1318
+ framework_di_blind_spot
1319
+ or value_type_blind_spot
1320
+ or unresolved_ref_blind_spot
1321
+ or method_scope_blind_spot
1322
+ or parse_coverage_blind_spot
1323
+ ):
1141
1324
  confidence = "low"
1142
- elif resolution == "partial" or confidence_reducing:
1325
+ elif resolution == "partial" or confidence_reducing or endpoint_coverage_blind_spot:
1326
+ # endpoint_coverage: the callers are still trustworthy — only the
1327
+ # endpoint axis is provably incomplete, so cap at medium, not low.
1143
1328
  confidence = "medium"
1144
1329
  else:
1145
1330
  confidence = "high"
1146
1331
 
1147
1332
  elapsed_ms = round((time.monotonic() - t0) * 1000, 2)
1148
1333
 
1334
+ explanation = _build_chain_explanation(
1335
+ risk_level,
1336
+ len(direct_callers),
1337
+ len(indirect_callers),
1338
+ len(endpoints_affected),
1339
+ len(impact_findings),
1340
+ confidence,
1341
+ blind_spot=bool(blind_spots),
1342
+ )
1343
+ if endpoint_coverage_blind_spot:
1344
+ explanation += (
1345
+ " HTTP exposure is under-reported: handler class(es) in the chain "
1346
+ "have no modeled routes (blind_spots=endpoint_coverage_partial)."
1347
+ )
1348
+
1149
1349
  return ImpactChainResult(
1150
1350
  symbol=resolved_symbol,
1151
1351
  resolution=resolution,
@@ -1161,14 +1361,7 @@ class ImpactOrchestrator:
1161
1361
  risk_level=risk_level,
1162
1362
  risk_score=risk_score,
1163
1363
  confidence=confidence,
1164
- explanation=_build_chain_explanation(
1165
- risk_level,
1166
- len(direct_callers),
1167
- len(indirect_callers),
1168
- len(endpoints_affected),
1169
- len(impact_findings),
1170
- confidence,
1171
- ),
1364
+ explanation=explanation,
1172
1365
  metadata={
1173
1366
  "analysis_depth": depth,
1174
1367
  "callers_total": len(direct_callers) + len(indirect_callers),
@@ -1177,12 +1370,8 @@ class ImpactOrchestrator:
1177
1370
  "risk_score": risk_score,
1178
1371
  "model_build_time_ms": model.build_time_ms,
1179
1372
  "query_time_ms": elapsed_ms,
1180
- "blind_spots": (
1181
- # framework_di stops being a blind spot once CH-007 recovers callers
1182
- (["framework_di"] if framework_di_blind_spot and not di_recovered else [])
1183
- + (["value_type"] if value_type_blind_spot else [])
1184
- + (["unresolved_refs"] if unresolved_ref_blind_spot else [])
1185
- ),
1373
+ "blind_spots": blind_spots,
1374
+ "reconciliation": reconciliation_findings,
1186
1375
  "external_supertypes": external_supertypes,
1187
1376
  "external_iface_callers_recovered": len(di_recovered_callers),
1188
1377
  "external_iface_binding_ambiguous": di_binding_ambiguous,
sourcecode/summarizer.py CHANGED
@@ -8,6 +8,7 @@ No API calls — templates applied directly over SourceMap.
8
8
  from pathlib import Path
9
9
  from typing import Any
10
10
 
11
+ from sourcecode.architecture_analyzer import _LAYER_MIN_SHARE
11
12
  from sourcecode.detectors.parsers import load_json_file, load_toml_file
12
13
  from sourcecode.tree_utils import safe_read_text
13
14
  from sourcecode.entrypoint_classifier import is_production_entry_point
@@ -48,11 +49,15 @@ _ARCH_LAYER_PATTERNS: dict[str, dict[str, list[str]]] = {
48
49
  "model": ["models", "entities", "domain"],
49
50
  "view": ["views", "templates", "pages", "components"],
50
51
  },
52
+ # P1-D: keep in step with architecture_analyzer.LAYER_PATTERNS — a keyword may
53
+ # only name a layer it attests. "api" (a public API package, not an HTTP
54
+ # controller layer), "store" and "db"/"database" are naming conventions that
55
+ # fabricated the "layered" claim in this very headline for neo4j and openmrs.
51
56
  "layered": {
52
- "controller": ["controllers", "api", "routes", "handlers", "endpoints"],
57
+ "controller": ["controllers", "routes", "handlers", "endpoints"],
53
58
  "service": ["services", "usecases", "application"],
54
- "repository": ["repositories", "repos", "store", "dao"],
55
- "infrastructure": ["infra", "infrastructure", "persistence", "db", "database"],
59
+ "repository": ["repositories", "repos", "dao"],
60
+ "infrastructure": ["infra", "infrastructure", "persistence"],
56
61
  },
57
62
  "hexagonal": {
58
63
  "port": ["ports", "interfaces"],
@@ -61,7 +66,11 @@ _ARCH_LAYER_PATTERNS: dict[str, dict[str, list[str]]] = {
61
66
  },
62
67
  "fullstack": {
63
68
  "frontend": ["frontend", "client", "web", "ui", "pages", "components"],
64
- "backend": ["backend", "server", "api", "services"],
69
+ # P1-D: "api" dropped — a `foo/api/` package is a public API surface, not
70
+ # evidence of a backend TIER. With it, openmrs (94.8% `org/openmrs/api/`)
71
+ # and jobrunr fell straight from a fabricated "layered" into a fabricated
72
+ # "fullstack" instead. Same conflation, same fix.
73
+ "backend": ["backend", "server", "services"],
65
74
  },
66
75
  }
67
76
 
@@ -390,20 +399,32 @@ class ProjectSummarizer:
390
399
  return ". ".join(parts) + "."
391
400
 
392
401
  def _detect_architecture_pattern(self, file_paths: list[str]) -> str | None:
393
- """Infer architecture pattern from directory names in file paths."""
394
- dir_names: set[str] = set()
395
- for p in file_paths:
396
- norm = p.replace("\\", "/")
397
- for seg in norm.split("/")[:-1]:
398
- dir_names.add(seg.lower())
402
+ """Infer architecture pattern from directory names in file paths.
403
+
404
+ P1-D: a layer must hold a real share of the code. Scoring on the mere
405
+ PRESENCE of a directory name meant one incidental dir asserted a whole
406
+ layer, and the claim landed in the headline: neo4j — a graph engine with no
407
+ controller, repository or infrastructure directory anywhere — read "Library
408
+ in Java (JAX-RS), layered". Presence does not scale, so the largest repos
409
+ were the most confidently mislabelled. Same gate and threshold as
410
+ ArchitectureAnalyzer, which answers this question for the architecture prose.
411
+ """
412
+ dir_files: dict[str, set[int]] = {}
413
+ for i, p in enumerate(file_paths):
414
+ for seg in p.replace("\\", "/").split("/")[:-1]:
415
+ dir_files.setdefault(seg.lower(), set()).add(i)
416
+ total = len(file_paths)
417
+
418
+ def _has_mass(keywords: list[str]) -> bool:
419
+ files: set[int] = set()
420
+ for d in keywords:
421
+ files |= dir_files.get(d, set())
422
+ return bool(files) and (not total or len(files) / total >= _LAYER_MIN_SHARE)
399
423
 
400
424
  best_pattern: str | None = None
401
425
  best_score = 0
402
426
  for pattern_name, layer_keys in _ARCH_LAYER_PATTERNS.items():
403
- score = sum(
404
- 1 for keywords in layer_keys.values()
405
- if any(d in keywords for d in dir_names)
406
- )
427
+ score = sum(1 for keywords in layer_keys.values() if _has_mass(keywords))
407
428
  if score > best_score:
408
429
  best_score = score
409
430
  best_pattern = pattern_name
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sourcecode
3
- Version: 2.5.2
3
+ Version: 2.5.4
4
4
  Summary: Persistent structural context and ultra-fast repeated analysis for AI coding agents
5
5
  License-File: LICENSE
6
6
  Keywords: agents,ai,codebase,context,developer-tools,llm
@@ -1,15 +1,15 @@
1
- sourcecode/__init__.py,sha256=nbtWf3A6IYQQ1rNky7L2moDkYZ2Uy7Nj9Mb5R7f2-KQ,308
1
+ sourcecode/__init__.py,sha256=XumauaLNdzXEwNGsJupjZthcLY2Qo8ud3CgsBguv8-o,308
2
2
  sourcecode/adaptive_scanner.py,sha256=XffluXKzJUXrMtjEiAOnSNPZnztdIcts17T9ouHeID0,10521
3
3
  sourcecode/archetype.py,sha256=d7yoN6Tj4OBj-VgSMPEKg4WF9H8FypnZ5A6AkUh5oIE,34484
4
- sourcecode/architecture_analyzer.py,sha256=6_wC4TYuBYxaqckZS0I1vBSMRPeH2vzlDB48gXwOOd4,46627
4
+ sourcecode/architecture_analyzer.py,sha256=CoxlEDWn1axufjIT4fpNHd_2HNUKgqBClq9z9k6jp4M,51684
5
5
  sourcecode/architecture_summary.py,sha256=pkl6lIOnSy-poa9EzW43U0MkXhx2FDfAFh8pDXLpcTo,26630
6
6
  sourcecode/ast_extractor.py,sha256=FEsYTsMCXbJfSYQZRk1k-I-PjUNGnKqO1IxMftsgA5U,51351
7
7
  sourcecode/cache.py,sha256=1V3vsaODAa2UBJAC0xpvxpmRdriCezQx5Q8JCcfgziE,31892
8
8
  sourcecode/call_surface.py,sha256=fiqYfHooxN1fX9oQoysq1LS3LoZcobhyUNjAGEZKpwk,4148
9
9
  sourcecode/caller_metrics.py,sha256=HG8RCOkpzzsEGdnMRob6byrwjoj5__t0TEsBj4yR7ks,2574
10
- sourcecode/canonical_ir.py,sha256=U69m8Vkr0p407xF1q5y1KguHXWEAubYw8NFHR9hDNJk,29178
10
+ sourcecode/canonical_ir.py,sha256=5hLAhhSpXNOdZAvPt-J-rXccJE6M3A6gVhmhU0Tym6Y,29269
11
11
  sourcecode/cir_graphs.py,sha256=9G0HHj1kw2325IDyzo2OpX73BNswEckecf4MZUXB4JM,12078
12
- sourcecode/classifier.py,sha256=RhLMDR031FAiUcT3bsf2EL3KrFwl7KsWh_wXXR_Bumo,18729
12
+ sourcecode/classifier.py,sha256=9olDN5joeKHcwG9vf73X-t_son16hLVNib0rBbqk1vc,18865
13
13
  sourcecode/cli.py,sha256=46Chp0JsEWD_j0HUWOx4tOhrsXSWgWV8JhL5G8SyW4I,322078
14
14
  sourcecode/code_notes_analyzer.py,sha256=EJemNCNc9Dn-1RZYu-aNbK0ELzmsyC4s6FdHi3XyNEI,9392
15
15
  sourcecode/confidence_analyzer.py,sha256=_jckZSxksV-OU38vbkxfVNBnWCtlCq8Vwfg23x1uspA,19054
@@ -51,11 +51,12 @@ sourcecode/pr_impact.py,sha256=dCDVw83EDbyVf6F9ZmEQmsFz8ruVH7d4mpeKQCIZHM0,16805
51
51
  sourcecode/prepare_context.py,sha256=PYygAKL_jwz8gGElgM9mroUSih18UKSSjv08ndfl4aA,222877
52
52
  sourcecode/progress.py,sha256=qn30sWaHOkjTgXsSBmiPkz7Rsbwc5oSlIe6JNEMYp_k,3149
53
53
  sourcecode/ranking_engine.py,sha256=ZAucq_YX2KkWUuAZf4P0lhtQ_38vEFnUhuGtSZd1S0E,12970
54
+ sourcecode/reconciliation.py,sha256=kW8PyB_R7J2Ig0N_QP9Jdm1fQXM1Ebf3PZbaTc80ULA,7941
54
55
  sourcecode/redactor.py,sha256=SB4hwIvg8h-hvcqKcDWaZvA-aSyn-at-BIRwa0tUv5E,3227
55
56
  sourcecode/relevance_scorer.py,sha256=0AgEt4KrV73nioMqBgjhGjtY7L2C7L7cSyKtj3IKcrw,9408
56
57
  sourcecode/rename_refactor.py,sha256=h6dNFlB9aZ_3q6heeHBkgXQeXaT03nvPSsYH6P8qxFg,12965
57
58
  sourcecode/repo_classifier.py,sha256=FG1vaWKdWXsWdl-S8hjVMiTqcwgaRXkDyvK4rPcOGtQ,22681
58
- sourcecode/repository_ir.py,sha256=bG81ARrPBaPZoGqGT9bXviGCHZ27D7zGli8KShn9-cI,291158
59
+ sourcecode/repository_ir.py,sha256=F6lRgPoP8oTtnGpRgjoLw-dlxZRXH7XOcehtcNwufEY,309550
59
60
  sourcecode/ris.py,sha256=aG2D4159gtpg968yD3PylYbIXhGFOwIlFBI0DSr84yY,20412
60
61
  sourcecode/runtime_classifier.py,sha256=uTAD6BDCiBLUZEDRfqk718kM4RTT_vAbfkcOI2_Xx58,18432
61
62
  sourcecode/scanner.py,sha256=WdOQ78mMzjR1NjmKTlbxdgwinnCTfAhxCVLBEFQiFHU,8899
@@ -63,18 +64,18 @@ sourcecode/schema.py,sha256=aHNXDf8LGyUC8ZDE_VS9kiskC2-Oswhi_WnpdGy6HDw,24897
63
64
  sourcecode/security_config.py,sha256=KblMEoRiEjrIE68YsPaUAFebxFp8UM7MS7lAk5CGD8U,3531
64
65
  sourcecode/security_posture.py,sha256=CAJw4oJD0aAW2yRewvd_fonkzi9_HpUjBZlIDZle00s,26034
65
66
  sourcecode/semantic_analyzer.py,sha256=bpgdC6m0_ftVtRf3rSdwhbhWjnZnGxRXaZVcfe4BbcQ,95414
66
- sourcecode/semantic_impact_engine.py,sha256=HpgjenY6DLPHPomP7Hw850zA-EBokf1S4O2sh8NegdI,20351
67
+ sourcecode/semantic_impact_engine.py,sha256=t09IirGC3JjQDy33JZd1_WKzQVKXkoNl3-XEUr5kjis,20563
67
68
  sourcecode/semantic_integration_engine.py,sha256=rxoCuP-uM_Y4-ELeBN0sz1a88e2eI1XTJDgsS57IOLI,15513
68
- sourcecode/semantic_services.py,sha256=AO_p7PRfjc-AUh6ThvLMfm4iUGi0issL_frCf8sxGlI,10637
69
+ sourcecode/semantic_services.py,sha256=nbUuPv-F01USTt_9CHT8iy_ucCIw3fz4W3Aquea_pd4,10782
69
70
  sourcecode/serializer.py,sha256=FmQo7eojA_z3ptLW-Vgze5mrEJ6FabcZqiwbQWWTgFI,130056
70
71
  sourcecode/spring_event_topology.py,sha256=5_ON_21Le5zbG-1GRc5GLIi5HJfy_QjcXLVPC5WeUGQ,18055
71
72
  sourcecode/spring_findings.py,sha256=EX7kLZLN74CFyR9iZPm3CI115BfFKNd4WRPuErNljZM,5729
72
- sourcecode/spring_impact.py,sha256=tjTxKAtmAvBqDVSSsC4ttCzbLaO55Wg4FJdOd_8MtO0,60077
73
+ sourcecode/spring_impact.py,sha256=ayZtHZ2YCRSlZpKBACoM9M7tW0K1W_C0r8SGj3R-4vU,70603
73
74
  sourcecode/spring_model.py,sha256=zOAgFmrRbG4a6KLm1TJl55aWMyPNsz3OS3FSczqPG6A,16594
74
75
  sourcecode/spring_security_audit.py,sha256=Rk-aSohezdc7YDYbSoJquVnwpkDB8ty1BCD-4Hc4R5A,22832
75
76
  sourcecode/spring_semantic.py,sha256=jteQ1PkY9ArFJv0embg_jBIdbOxqrk9mQ2Xz8OF_FKA,14214
76
77
  sourcecode/spring_tx_analyzer.py,sha256=QMXkxa_hrqDVN3D8WJsd0nT7vfdZCT1E8ZWNoFFbvgU,34692
77
- sourcecode/summarizer.py,sha256=ByfFxIGVRrGrf3paQPuDOg1rVj78LyzXN_EXBkfbCLU,23582
78
+ sourcecode/summarizer.py,sha256=BaBO0WKQi4fX2b2Xjip-GLEZwI3PYiV_U9jmBQlrfFI,25060
78
79
  sourcecode/tree_utils.py,sha256=8GAkIfQAsvtEudIeW1l4ooH_oRtrWR8cpJQJsEa_Pfw,2093
79
80
  sourcecode/type_usage_surface.py,sha256=51IrKRQoIoRnlsiDjHnqpJBn2rc6E59aRhgS0HTzAF0,4428
80
81
  sourcecode/validation_inference.py,sha256=UtmYv-d4TEzT1ihGEipAhtXDrwq0XtVtjMpb3i7NsNc,10815
@@ -90,7 +91,7 @@ sourcecode/detectors/elixir.py,sha256=jCpvt5Yi6jvplc80ovRtWh17q-11ZGo9qX7o8b57TJ
90
91
  sourcecode/detectors/go.py,sha256=2r66uRQfeTWsqxr4HDhT6vExZErby0t46QXLHVBRv9w,2782
91
92
  sourcecode/detectors/heuristic.py,sha256=7cRxrip4yIaggYzZJB6ef8yHKh-gHgiH_pXMFcjlyFU,3723
92
93
  sourcecode/detectors/hybrid.py,sha256=IGFRUVsAZ1ooRlFdznCeJAV6vy1yVDx-VyghvLtddXc,9101
93
- sourcecode/detectors/java.py,sha256=rTeVIKIliElE1yPdIkll1pKqDUzXD1EFFJdXxp24gr4,41005
94
+ sourcecode/detectors/java.py,sha256=ut8czxGzhFUxRAS5LeG78wev8kJolC7LG8IESDJZTNk,42349
94
95
  sourcecode/detectors/jvm_ext.py,sha256=EgHJ5W8EE-ZTN9V607mVzohyKgZE8Mc2jCi-DF8RAZU,2616
95
96
  sourcecode/detectors/nodejs.py,sha256=Hg3Gmr7yIMJFiLoDwOTk2wtu00wxIs6kZf-oQujTFUA,13187
96
97
  sourcecode/detectors/parsers.py,sha256=ug9K31tyHqinmv0HkIVQVjdTZpBv67FYKAEf52YXOSM,3178
@@ -136,8 +137,8 @@ sourcecode/telemetry/consent.py,sha256=LIAO9ohJZF8OuZwM4u1VWtALlYfTCCKq4wV3Vwc7i
136
137
  sourcecode/telemetry/events.py,sha256=LtzYfaX9Ilckj5PTvAcTpDa9mLqDsYPDUiDkRa58piY,2580
137
138
  sourcecode/telemetry/filters.py,sha256=NHa5T-6DaZduQPFuC34jOqHWQgSizM-Ygq8aZ4j19ng,5834
138
139
  sourcecode/telemetry/transport.py,sha256=4gGHsq0WeY9VywEZXA3vUxykfiYnw9uuqfjAAec7F8o,1681
139
- sourcecode-2.5.2.dist-info/METADATA,sha256=cOmLhyx7sqYDB9m4eU6MkDuV4hfXzK68a2cUC_-N490,10851
140
- sourcecode-2.5.2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
141
- sourcecode-2.5.2.dist-info/entry_points.txt,sha256=-JEAdChrK5We51kZcb7OaDcyil-dHBjBPL-NhuO-QY8,89
142
- sourcecode-2.5.2.dist-info/licenses/LICENSE,sha256=7DdHrU9Z_3e7dSvq4ISijZNjnuHo5NIHNiHDouMQ9JU,10491
143
- sourcecode-2.5.2.dist-info/RECORD,,
140
+ sourcecode-2.5.4.dist-info/METADATA,sha256=vFyQq0a39iemrb04OqC6TwsnM_xA7j6PTBz_lbEccTM,10851
141
+ sourcecode-2.5.4.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
142
+ sourcecode-2.5.4.dist-info/entry_points.txt,sha256=-JEAdChrK5We51kZcb7OaDcyil-dHBjBPL-NhuO-QY8,89
143
+ sourcecode-2.5.4.dist-info/licenses/LICENSE,sha256=7DdHrU9Z_3e7dSvq4ISijZNjnuHo5NIHNiHDouMQ9JU,10491
144
+ sourcecode-2.5.4.dist-info/RECORD,,