sourcecode 2.5.5__py3-none-any.whl → 2.5.7__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.5"
7
+ __version__ = "2.5.7"
@@ -4,7 +4,11 @@ import re
4
4
  from pathlib import Path
5
5
  from typing import Literal, Optional
6
6
 
7
- from sourcecode.reconciliation import incoherent_layers
7
+ from sourcecode.reconciliation import (
8
+ incoherent_layers,
9
+ pattern_contradicted,
10
+ unattested_layers,
11
+ )
8
12
 
9
13
  from sourcecode.schema import (
10
14
  ArchitectureAnalysis,
@@ -41,11 +45,27 @@ _SRC_TRANSPARENT = {
41
45
  "src", "lib", "app", "pkg",
42
46
  "main", "java", "kotlin", "scala", "groovy",
43
47
  }
48
+
49
+ # `src/main/java17/` is a MULTI-RELEASE source set, as transparent as `java/`
50
+ # — JobRunr's minted a module called "java17".
51
+ _VERSIONED_SRC_RE = re.compile(r"^(?:java|kotlin|scala)\d+$")
52
+
53
+
54
+ def _is_transparent_segment(name: str) -> bool:
55
+ """A path segment that carries no architectural meaning of its own."""
56
+ low = name.lower()
57
+ return low in _SRC_TRANSPARENT or bool(_VERSIONED_SRC_RE.match(low))
44
58
  _CODE_EXTENSIONS = {
45
59
  ".py", ".js", ".ts", ".tsx", ".jsx", ".mjs", ".cjs",
46
60
  ".go", ".java", ".kt", ".rs", ".rb",
47
61
  }
48
- _GENERIC_NAMES = {"utils", "helpers", "common", "shared", "misc", "core", "root", ""}
62
+ # Names that never identify a module: generic buckets, and the reverse-DNS
63
+ # package roots every JVM repo starts its namespace with (JobRunr reported a
64
+ # module called "org").
65
+ _GENERIC_NAMES = {
66
+ "utils", "helpers", "common", "shared", "misc", "core", "root", "",
67
+ "org", "com", "io", "net",
68
+ }
49
69
 
50
70
  _TEST_DIRS: frozenset[str] = frozenset({
51
71
  "tests", "test", "spec", "specs", "__tests__", "e2e",
@@ -671,14 +691,18 @@ class ArchitectureAnalyzer:
671
691
  # reconciliation rule drops layers owned by a different runtime;
672
692
  # template/asset-only layers are untouched, so a real Spring-MVC
673
693
  # `templates/` view survives.
674
- for _bad in incoherent_layers(
675
- pattern_name,
676
- {k: [source_paths[i] for i in v] for k, v in layer_files.items()},
677
- source_paths,
678
- ):
694
+ _layer_paths = {k: [source_paths[i] for i in v] for k, v in layer_files.items()}
695
+ for _bad in incoherent_layers(pattern_name, _layer_paths, source_paths):
679
696
  matched.pop(_bad, None)
680
697
  layer_files.pop(_bad, None)
681
698
 
699
+ # A claim that IS a runtime split must show one: JobRunr, a JVM
700
+ # library, read "Fullstack with frontend, backend layers" off a
701
+ # `dashboard/ui/model/` package of 16 Java files. Nothing to prune —
702
+ # the pattern itself is what the evidence contradicts.
703
+ if pattern_contradicted(pattern_name, _layer_paths, source_paths):
704
+ continue
705
+
682
706
  # A pattern with unmet required keys cannot qualify (e.g. mvc needs a view).
683
707
  required = _PATTERN_REQUIRED_KEYS.get(pattern_name)
684
708
  if required and not required.issubset(matched.keys()):
@@ -842,6 +866,14 @@ class ArchitectureAnalyzer:
842
866
  k: v for k, v in layer_files.items()
843
867
  if v and (not total or len(v) / total >= _LAYER_MIN_SHARE)
844
868
  }
869
+ # P1-D residue: these stem patterns are Python-shaped (cli.py,
870
+ # *_analyzer.py, config.py). On a Java repo almost nothing matches, so
871
+ # the few stray hits are not a layering — petclinic's "Layered
872
+ # Architecture with orchestration, data layers" rested on ONE directory
873
+ # entry plus a settings.gradle, with 47 Java files visible and ignored.
874
+ # A logical code layer that holds no code of this repo is not one.
875
+ for _bad in unattested_layers(non_empty, paths):
876
+ non_empty.pop(_bad, None)
845
877
  if len(non_empty) >= 2:
846
878
  return "layered", [
847
879
  ArchitectureLayer(name=k, pattern="layered", files=v, confidence="low")
@@ -861,13 +893,19 @@ class ArchitectureAnalyzer:
861
893
  for p in paths:
862
894
  parts = p.replace("\\", "/").split("/")
863
895
  for part in parts[:-1]:
864
- if (part not in _SRC_TRANSPARENT
896
+ if (not _is_transparent_segment(part)
865
897
  and part.lower() not in _NON_SOURCE_DIRS
866
898
  and part.lower() not in _GENERIC_NAMES):
867
899
  module_files.setdefault(part, []).append(p)
868
900
  break
869
901
 
870
902
  meaningful = {k: v for k, v in module_files.items() if len(v) >= 3}
903
+ # P1-D residue: a MODULE of this repo must hold this repo's code. File
904
+ # count alone let build and asset directories be reported as modules —
905
+ # ofbiz "docker, framework, runtime, gradle", neo4j "packaging",
906
+ # eureka "images" (five PNGs). The real modules survive untouched.
907
+ for _bad in unattested_layers(meaningful, paths):
908
+ meaningful.pop(_bad, None)
871
909
  if len(meaningful) >= 2:
872
910
  return "modular", [
873
911
  ArchitectureLayer(name=k, pattern="modular", files=v, confidence="low")
@@ -205,6 +205,22 @@ class ArchitectureSummarizer:
205
205
  for f in s.frameworks[:2]
206
206
  ))[:3]
207
207
 
208
+ # F-1 reconciliation: the headline describes ONE program, but fw_names is
209
+ # gathered from every stack in the repo. On a polyglot repository that
210
+ # welds a foreign runtime's framework onto the primary's label. Drop what
211
+ # the claimed stack does not attest, and never let the TYPE label — which
212
+ # those same frameworks drove — survive its own evidence (P2-F).
213
+ from sourcecode.reconciliation import type_claim_unattested, unattested_frameworks
214
+
215
+ foreign = unattested_frameworks(primary.stack, sm.project_type, sm.stacks)
216
+ attested = [name for name in fw_names if name not in foreign]
217
+ dropped = [name for name in fw_names if name in foreign]
218
+ if dropped and not attested and type_claim_unattested(
219
+ claimed_stack=primary.stack, project_type=sm.project_type, stacks=sm.stacks
220
+ ):
221
+ return self._describe_foreign_evidence(sm, stack_label, runtime, dropped)
222
+ fw_names = attested
223
+
208
224
  fw_str = f" using {', '.join(fw_names)}" if fw_names else ""
209
225
  if runtime:
210
226
  # BUG #4: never assert "rest api" in the headline unless the endpoints
@@ -237,6 +253,29 @@ class ArchitectureSummarizer:
237
253
  return f"{stack_label} {runtime.lower()}{fw_str}."
238
254
  return f"{stack_label} project{fw_str}."
239
255
 
256
+ def _describe_foreign_evidence(
257
+ self, sm: SourceMap, stack_label: str, runtime: str, dropped: list[str]
258
+ ) -> str:
259
+ """Headline for a claim whose defining evidence belongs to another stack.
260
+
261
+ Says what IS known instead of asserting a program that does not exist:
262
+ the primary stack, the foreign frameworks, and the stacks that actually
263
+ declare them. The reader gets the same facts, correctly attributed.
264
+ """
265
+ from sourcecode.context_summarizer import _STACK_LABELS
266
+ from sourcecode.reconciliation import framework_owner_stacks
267
+
268
+ owners = framework_owner_stacks(sm.stacks)
269
+ owner_ids = sorted({sid for name in dropped for sid in owners.get(name, ())})
270
+ owner_labels = [_STACK_LABELS.get(sid, sid) for sid in owner_ids] or ["another"]
271
+ plural = "s" if len(owner_labels) > 1 else ""
272
+ return (
273
+ f"{stack_label} project. Polyglot repository: the {runtime.lower()} "
274
+ f"evidence ({', '.join(dropped)}) is declared by the "
275
+ f"{', '.join(owner_labels)} stack{plural}, not by {stack_label} — no "
276
+ "single stack owns this repository's architecture. See `stacks`."
277
+ )
278
+
240
279
  def _endpoint_support(self) -> tuple[int, int]:
241
280
  """Return (total, high_confidence) endpoint counts from the canonical
242
281
  Java endpoint extractor — the same source the `endpoints` command uses,
@@ -412,41 +451,51 @@ class ArchitectureSummarizer:
412
451
  return None
413
452
 
414
453
  def _summarize_dotnet_entry(self, stacks: list[StackDetection]) -> list[str]:
454
+ # P2-F-RESIDUE(c): every line here counts .csproj files — facts about the
455
+ # .NET stack ALONE. Unscoped, they read as claims about the repository:
456
+ # the confluent examples monorepo (20+ subprojects, mostly Java/Python/Go)
457
+ # reported a bare "1 project detected." beside a headline that had just
458
+ # called it polyglot. Naming the stack costs one word and makes each line
459
+ # true on any repo, so no polyglot test is needed to decide the wording.
460
+ from sourcecode.context_summarizer import _STACK_LABELS
461
+
415
462
  dotnet_stacks = [s for s in stacks if s.stack == "dotnet"]
416
463
  if not dotnet_stacks:
417
464
  return []
465
+ label = _STACK_LABELS.get("dotnet", "dotnet") # same table as the headline
418
466
  lines: list[str] = []
419
467
  signals = [sig for s in dotnet_stacks for sig in s.signals]
420
468
 
421
469
  for sig in signals:
422
470
  if "project" in sig and "detected" in sig:
423
- lines.append(sig[0].upper() + sig[1:] + ".")
471
+ count = sig.split(" ", 1)[0]
472
+ lines.append(f"{label} projects: {count}.")
424
473
  break
425
474
 
426
475
  for sig in signals:
427
476
  if sig.startswith("project types:"):
428
477
  types = sig.removeprefix("project types:").strip()
429
- lines.append(f"Stack: {types}.")
478
+ lines.append(f"{label} project types: {types}.")
430
479
  break
431
480
 
432
481
  for sig in signals:
433
482
  if sig.startswith("target frameworks:"):
434
483
  fws = sig.removeprefix("target frameworks:").strip()
435
- lines.append(f"Target: {fws}.")
484
+ lines.append(f"{label} target frameworks: {fws}.")
436
485
  break
437
486
 
438
487
  for sig in signals:
439
488
  if sig.startswith("architecture:"):
440
489
  pattern = sig.removeprefix("architecture:").strip()
441
- lines.append(f"Patrón detectado: {pattern}.")
490
+ lines.append(f"{label} solution pattern: {pattern}.")
442
491
  break
443
492
 
444
493
  framework_names = [f.name for s in dotnet_stacks for f in s.frameworks]
445
494
  if framework_names:
446
- lines.append(f"Frameworks: {', '.join(framework_names)}.")
495
+ lines.append(f"{label} frameworks: {', '.join(framework_names)}.")
447
496
 
448
497
  if not lines:
449
- lines.append("Solución .NET detectada.")
498
+ lines.append(f"{label} solution detected.")
450
499
  return lines
451
500
 
452
501
  def _summarize_go_entry(self, path: str, content: str) -> list[str]:
@@ -102,6 +102,45 @@ class ConfidenceAnalyzer:
102
102
  if heuristic_only and not any(s.detection_method != "heuristic" for s in sm.stacks):
103
103
  anomalies.append("All stacks detected via heuristic only — no manifest found")
104
104
 
105
+ # ── Anomaly: a claim attributed to a stack that does not attest it ────
106
+ # F-1 reconciliation, second sink. The headline degrades itself
107
+ # (architecture_summary), but `project_type` and `primary_stack` are
108
+ # separate structured fields an agent can read on their own — and they
109
+ # keep asserting the claim this rule refutes. Same rule, same judgement,
110
+ # different surface: here it becomes an anomaly + gap instead of prose.
111
+ if primary_stacks:
112
+ from sourcecode.reconciliation import (
113
+ reconcile_headline_evidence,
114
+ type_claim_unattested,
115
+ )
116
+
117
+ _primary = primary_stacks[0]
118
+ for _rf in reconcile_headline_evidence(
119
+ claimed_stack=_primary.stack,
120
+ project_type=sm.project_type,
121
+ stacks=sm.stacks,
122
+ ):
123
+ anomalies.append(
124
+ f"{', '.join(_rf.symbols)} declared by another runtime, not by the "
125
+ f"{_primary.stack} primary stack"
126
+ )
127
+ _reason = _rf.detail
128
+ if type_claim_unattested(
129
+ claimed_stack=_primary.stack,
130
+ project_type=sm.project_type,
131
+ stacks=sm.stacks,
132
+ ):
133
+ _reason += (
134
+ f' project_type="{sm.project_type}" rests on that same evidence — '
135
+ f"it is not attested for the {_primary.stack} primary stack, and "
136
+ "the repository is polyglot rather than one program."
137
+ )
138
+ gaps.append(AnalysisGap(
139
+ area=_rf.rule,
140
+ reason=_reason,
141
+ impact="medium",
142
+ ))
143
+
105
144
  # ── Anomaly: entry points all low-confidence ──────────────────────────
106
145
  if normalized_entry_points and all(ep.confidence == "low" for ep in normalized_entry_points):
107
146
  anomalies.append("All entry points are low-confidence (heuristic/code_signal only)")
@@ -42,6 +42,15 @@ Rule registry (architecture):
42
42
  subsystems (SIM-3: keycloak's "MVC … view layer" was a React SPA, zero
43
43
  Java files, sitting beside a 1088-file Java model).
44
44
 
45
+ Rule registry (narrative):
46
+ framework_stack_attestation — the headline welds a stack label to a
47
+ framework that no stack of that runtime declares. The stack table and
48
+ the framework-ownership view are contradicting each other: the sentence
49
+ describes one program while the evidence names two (P2-F: the confluent
50
+ examples repo read "C#/.NET rest api using JAX-RS" — a JVM framework,
51
+ declared by a Java stack in one sub-workspace, attributed to a .NET
52
+ primary that declares no framework at all).
53
+
45
54
  Never raises. Empty evidence → no findings.
46
55
  """
47
56
  from __future__ import annotations
@@ -223,6 +232,15 @@ _CROSS_RUNTIME_PATTERNS: frozenset[str] = frozenset({
223
232
  "fullstack", "microservices", "monorepo",
224
233
  })
225
234
 
235
+ # Patterns whose meaning IS the runtime split — a "fullstack" repo names a
236
+ # frontend TIER and a backend TIER. If every layer runs on the same runtime
237
+ # there is no split, and the claim is a directory-name coincidence: JobRunr, a
238
+ # JVM library, read "Fullstack with frontend, backend layers" off a
239
+ # `dashboard/ui/model/` package holding 16 JAVA files. (Not monorepo or
240
+ # microservices: an all-TypeScript monorepo, or same-runtime services, are
241
+ # perfectly real.)
242
+ _SPLIT_REQUIRED_PATTERNS: frozenset[str] = frozenset({"fullstack"})
243
+
226
244
 
227
245
  def runtime_census(layer_files: dict[str, list[str]]) -> dict[str, dict[str, int]]:
228
246
  """{layer -> {runtime_family -> file count}} — the language evidence view.
@@ -288,6 +306,91 @@ def _rule_layer_language_coherence(
288
306
  ]
289
307
 
290
308
 
309
+ def _rule_layer_code_attestation(
310
+ census: dict[str, dict[str, int]],
311
+ repo_runtimes: dict[str, int],
312
+ ) -> list[ReconciliationFinding]:
313
+ """A claimed code module holds no code of the repo's runtime.
314
+
315
+ Stricter sibling of `layer_language_coherence`, for claims whose layers are
316
+ modules or logical code layers — their whole role is to hold code, so a
317
+ directory with none is not one of them. (`layer_language_coherence` cannot
318
+ serve here: it deliberately ignores layers with no runtime files at all, so
319
+ that a real Spring-MVC `templates/` view survives. A view may be templates;
320
+ a MODULE may not.)
321
+
322
+ Measured on the field-test repos: ofbiz's "modular architecture" listed
323
+ `docker`, `gradle`, `runtime` and `themes` (shell scripts, wrapper jars, a
324
+ .gitignore, images — zero Java); neo4j listed `packaging`; eureka listed
325
+ `images` (five PNGs); petclinic's whole "Layered Architecture with
326
+ orchestration, data layers" rested on one directory entry and a
327
+ settings.gradle.
328
+ """
329
+ if not repo_runtimes:
330
+ return []
331
+ dominant = max(sorted(repo_runtimes), key=lambda f: repo_runtimes[f])
332
+ unattested = sorted(
333
+ layer for layer, counts in census.items() if not counts.get(dominant)
334
+ )
335
+ if not unattested:
336
+ return []
337
+ return [
338
+ ReconciliationFinding(
339
+ rule="layer_code_attestation",
340
+ symbols=tuple(unattested),
341
+ detail=(
342
+ f"{len(unattested)} claimed layer(s) hold no {dominant} code — "
343
+ "a directory without the repo's code is not a module of it."
344
+ ),
345
+ )
346
+ ]
347
+
348
+
349
+ def _rule_composition_runtime_split(
350
+ pattern: str,
351
+ census: dict[str, dict[str, int]],
352
+ ) -> list[ReconciliationFinding]:
353
+ """A pattern that names a runtime split shows none.
354
+
355
+ The mirror image of `layer_language_coherence`: a claim about one program
356
+ must NOT span runtimes; a claim that IS the split must show it. Silent
357
+ unless the pattern's layers carry runtime evidence at all.
358
+ """
359
+ if pattern not in _SPLIT_REQUIRED_PATTERNS or len(census) < 2:
360
+ return []
361
+ families = {fam for counts in census.values() for fam in counts}
362
+ if len(families) != 1:
363
+ return [] # a real split, or no runtime evidence to judge
364
+ only = next(iter(families))
365
+ return [
366
+ ReconciliationFinding(
367
+ rule="composition_runtime_split",
368
+ symbols=tuple(sorted(census)),
369
+ detail=(
370
+ f"the '{pattern}' claim names a runtime split but every layer is "
371
+ f"{only} — the layer names are a coincidence, not two tiers."
372
+ ),
373
+ )
374
+ ]
375
+
376
+
377
+ def unattested_layers(
378
+ layer_files: dict[str, list[str]], repo_files: "list[str]"
379
+ ) -> frozenset[str]:
380
+ """Layers holding no code of the repo's runtime (convenience view).
381
+
382
+ For consumers claiming code modules / logical code layers. Never raises.
383
+ """
384
+ try:
385
+ repo_runtimes = runtime_census({"_repo": list(repo_files)})["_repo"]
386
+ out: set[str] = set()
387
+ for f in _rule_layer_code_attestation(runtime_census(layer_files), repo_runtimes):
388
+ out.update(f.symbols)
389
+ return frozenset(out)
390
+ except Exception:
391
+ return frozenset()
392
+
393
+
291
394
  def reconcile_architecture_evidence(
292
395
  *,
293
396
  pattern: str,
@@ -304,17 +407,34 @@ def reconcile_architecture_evidence(
304
407
  """
305
408
  findings: list[ReconciliationFinding] = []
306
409
  try:
410
+ census = runtime_census(layer_files)
307
411
  repo_runtimes = runtime_census({"_repo": list(repo_files)})["_repo"]
308
412
  findings.extend(
309
- _rule_layer_language_coherence(
310
- pattern, runtime_census(layer_files), repo_runtimes
311
- )
413
+ _rule_layer_language_coherence(pattern, census, repo_runtimes)
312
414
  )
415
+ findings.extend(_rule_composition_runtime_split(pattern, census))
313
416
  except Exception:
314
417
  pass
315
418
  return findings
316
419
 
317
420
 
421
+ def pattern_contradicted(
422
+ pattern: str, layer_files: dict[str, list[str]], repo_files: "list[str]"
423
+ ) -> bool:
424
+ """True when a rule rejects the PATTERN itself, not just some of its layers.
425
+
426
+ `incoherent_layers` prunes layers and lets the pattern re-qualify on what
427
+ is left; a split-less "fullstack" has nothing to re-qualify with — the
428
+ claim as a whole is the thing contradicted.
429
+ """
430
+ return any(
431
+ f.rule == "composition_runtime_split"
432
+ for f in reconcile_architecture_evidence(
433
+ pattern=pattern, layer_files=layer_files, repo_files=repo_files
434
+ )
435
+ )
436
+
437
+
318
438
  def incoherent_layers(
319
439
  pattern: str, layer_files: dict[str, list[str]], repo_files: "list[str]"
320
440
  ) -> frozenset[str]:
@@ -327,6 +447,188 @@ def incoherent_layers(
327
447
  return frozenset(out)
328
448
 
329
449
 
450
+ # ---------------------------------------------------------------------------
451
+ # Narrative reconciliation (headline claim × stack ownership)
452
+ # ---------------------------------------------------------------------------
453
+
454
+ # Stack identifier → RUNTIME FAMILY. Same reasoning as `_RUNTIME_FAMILY` above,
455
+ # applied to the stack ontology instead of file extensions: a Kotlin framework
456
+ # named in a Java headline is ONE program (same runtime, direct interop), while a
457
+ # Java framework named in a .NET headline is a different one. Stacks absent here
458
+ # are deliberately unjudgeable: an unknown stack can never prove a contradiction.
459
+ _STACK_FAMILY: dict[str, str] = {
460
+ "java": "jvm", "kotlin": "jvm", "scala": "jvm", "groovy": "jvm",
461
+ "nodejs": "js", "deno": "js", "bun": "js",
462
+ "python": "python",
463
+ "go": "go",
464
+ "rust": "rust",
465
+ "ruby": "ruby",
466
+ "dotnet": "dotnet",
467
+ "php": "php",
468
+ "swift": "swift",
469
+ "dart": "dart",
470
+ "elixir": "beam", "erlang": "beam",
471
+ }
472
+
473
+ # Project types that are SUPPOSED to span runtimes — their headline names a tier
474
+ # split, so a framework from another runtime is the point, not a contradiction.
475
+ # Same carve-out (and same names) as `_CROSS_RUNTIME_PATTERNS` for layer claims.
476
+ _CROSS_RUNTIME_TYPES = _CROSS_RUNTIME_PATTERNS
477
+
478
+ # Project types DRIVEN by framework presence: `_classify_project_type` pools
479
+ # frameworks across every stack to pick them, while a separate vote picks the
480
+ # primary stack — so on a polyglot repo the type can be decided by one stack's
481
+ # evidence and reported as another's. When the frameworks that drove the type are
482
+ # all foreign to the primary, the type inherits their lie: the sentence
483
+ # "C#/.NET rest api" is the same claim as "using JAX-RS", one clause shorter.
484
+ # Lives here, not in a consumer: BOTH the headline and the confidence/gap surface
485
+ # must judge this identically or the duplicated table lies again.
486
+ _FRAMEWORK_DRIVEN_TYPES: frozenset[str] = frozenset({
487
+ "api", "webapp", "web_mvc", "angular-spa",
488
+ })
489
+
490
+
491
+ def framework_owner_stacks(stacks) -> dict[str, frozenset[str]]:
492
+ """{framework name -> stack ids that declare it} — the ownership view.
493
+
494
+ Ownership is already structural: a FrameworkDetection is discovered inside a
495
+ StackDetection's manifests, so which runtime a framework belongs to is
496
+ evidence the detector recorded, never a name lookup (VAI-safe — no rule here
497
+ branches on what the framework is called).
498
+ """
499
+ out: dict[str, set[str]] = {}
500
+ for stack in stacks or []:
501
+ sid = getattr(stack, "stack", "") or ""
502
+ if not sid:
503
+ continue
504
+ for fw in getattr(stack, "frameworks", None) or []:
505
+ name = getattr(fw, "name", "") or ""
506
+ if name:
507
+ out.setdefault(name, set()).add(sid)
508
+ return {name: frozenset(ids) for name, ids in out.items()}
509
+
510
+
511
+ def framework_owner_families(stacks) -> dict[str, frozenset[str]]:
512
+ """{framework name -> runtime families that declare it} — the ownership view."""
513
+ return {
514
+ name: frozenset(
515
+ fam for sid in ids if (fam := _STACK_FAMILY.get(sid)) is not None
516
+ )
517
+ for name, ids in framework_owner_stacks(stacks).items()
518
+ }
519
+
520
+
521
+ def _rule_framework_stack_attestation(
522
+ claimed_family: str | None,
523
+ owner_families: dict[str, frozenset[str]],
524
+ ) -> list[ReconciliationFinding]:
525
+ """A framework named in a headline is declared by no stack of the claimed runtime.
526
+
527
+ The headline describes ONE program: it welds a stack label ("C#/.NET") to the
528
+ frameworks found anywhere in the repo. On a polyglot repository those can be
529
+ different programs, and the sentence asserts a relationship the evidence
530
+ denies — the confluent examples repo read "C#/.NET rest api using JAX-RS",
531
+ attributing a JVM-only framework, declared by a Java stack in one
532
+ sub-workspace, to a .NET primary that declares no framework at all.
533
+
534
+ Fires only when ownership is PROVEN and disjoint: the framework has at least
535
+ one known owning family and the claimed family is not among them. An unknown
536
+ stack, or a framework no stack claims, yields nothing.
537
+ """
538
+ if not claimed_family:
539
+ return []
540
+ foreign = sorted(
541
+ name for name, fams in owner_families.items()
542
+ if fams and claimed_family not in fams
543
+ )
544
+ if not foreign:
545
+ return []
546
+ others = sorted({fam for name in foreign for fam in owner_families[name]})
547
+ return [
548
+ ReconciliationFinding(
549
+ rule="framework_stack_attestation",
550
+ symbols=tuple(foreign),
551
+ # Sink-neutral: this detail is read by a prose surface AND by a
552
+ # structured gap, so it states the contradiction, never "the
553
+ # headline says…" — the headline may already have dropped the claim.
554
+ detail=(
555
+ f"{len(foreign)} framework(s) are declared only by "
556
+ f"{', '.join(others)} stack(s), not by the {claimed_family} "
557
+ "primary stack — attributing them to it describes two separate "
558
+ "programs as one."
559
+ ),
560
+ )
561
+ ]
562
+
563
+
564
+ def reconcile_headline_evidence(
565
+ *,
566
+ claimed_stack: str,
567
+ project_type: str | None,
568
+ stacks,
569
+ ) -> list[ReconciliationFinding]:
570
+ """Run headline reconciliation rules. Never raises.
571
+
572
+ Args:
573
+ claimed_stack: stack id the headline's label is built from (the primary).
574
+ project_type: claimed type — cross-runtime types are exempt, their
575
+ headline is MEANT to name another tier's framework.
576
+ stacks: every StackDetection, each owning its own frameworks.
577
+ """
578
+ if project_type in _CROSS_RUNTIME_TYPES:
579
+ return []
580
+ findings: list[ReconciliationFinding] = []
581
+ try:
582
+ findings.extend(
583
+ _rule_framework_stack_attestation(
584
+ _STACK_FAMILY.get(claimed_stack or ""),
585
+ framework_owner_families(stacks),
586
+ )
587
+ )
588
+ except Exception:
589
+ pass
590
+ return findings
591
+
592
+
593
+ def type_claim_unattested(
594
+ *, claimed_stack: str, project_type: str | None, stacks
595
+ ) -> bool:
596
+ """True when a framework-driven type label rests ONLY on foreign evidence.
597
+
598
+ The sibling of `_rule_framework_stack_attestation` for the type clause: the
599
+ frameworks that decided `project_type` are declared, every one of them, by
600
+ stacks of another runtime. The narrative consumer degrades the sentence; the
601
+ confidence consumer records the gap — same judgement, two sinks, one table.
602
+
603
+ Provable only: silent when the type is not framework-driven, when the claimed
604
+ stack's runtime is unknown, or when no framework is owned at all.
605
+ """
606
+ try:
607
+ if project_type not in _FRAMEWORK_DRIVEN_TYPES:
608
+ return False
609
+ family = _STACK_FAMILY.get(claimed_stack or "")
610
+ if not family:
611
+ return False
612
+ owners = framework_owner_families(stacks)
613
+ if not owners:
614
+ return False
615
+ return not any(family in fams for fams in owners.values())
616
+ except Exception:
617
+ return False
618
+
619
+
620
+ def unattested_frameworks(
621
+ claimed_stack: str, project_type: str | None, stacks
622
+ ) -> frozenset[str]:
623
+ """Framework names the claimed stack does not attest (convenience view)."""
624
+ out: set[str] = set()
625
+ for f in reconcile_headline_evidence(
626
+ claimed_stack=claimed_stack, project_type=project_type, stacks=stacks
627
+ ):
628
+ out.update(f.symbols)
629
+ return frozenset(out)
630
+
631
+
330
632
  def reconcile_extraction_evidence(
331
633
  *,
332
634
  type_decl_files: frozenset[str],
sourcecode/summarizer.py CHANGED
@@ -9,7 +9,7 @@ from pathlib import Path
9
9
  from typing import Any
10
10
 
11
11
  from sourcecode.architecture_analyzer import _LAYER_MIN_SHARE, _PATTERN_REQUIRED_KEYS
12
- from sourcecode.reconciliation import incoherent_layers
12
+ from sourcecode.reconciliation import incoherent_layers, pattern_contradicted
13
13
  from sourcecode.detectors.parsers import load_json_file, load_toml_file
14
14
  from sourcecode.tree_utils import safe_read_text
15
15
  from sourcecode.entrypoint_classifier import is_production_entry_point
@@ -439,6 +439,8 @@ class ProjectSummarizer:
439
439
  # two duplicated tables leaves the other one lying.
440
440
  for _bad in incoherent_layers(pattern_name, layer_files, file_paths):
441
441
  layer_files.pop(_bad, None)
442
+ if pattern_contradicted(pattern_name, layer_files, file_paths):
443
+ continue
442
444
  # A pattern must show its defining trait here too, or the headline
443
445
  # contradicts the architecture prose that already enforces this.
444
446
  required = _PATTERN_REQUIRED_KEYS.get(pattern_name)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sourcecode
3
- Version: 2.5.5
3
+ Version: 2.5.7
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,8 +1,8 @@
1
- sourcecode/__init__.py,sha256=RsMXFkrSEd5UuAhMfPgfXfYvqM0A43vYcSfn086K17s,308
1
+ sourcecode/__init__.py,sha256=AYI3dPxhkPPbI6cutQ3CdTcxsveWkRyIzUZMCY7lfFs,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=vQQzcvmniq4go4bWNB2Xm5HlyIoW65cU7ktJqHIfDpU,53614
5
- sourcecode/architecture_summary.py,sha256=pkl6lIOnSy-poa9EzW43U0MkXhx2FDfAFh8pDXLpcTo,26630
4
+ sourcecode/architecture_analyzer.py,sha256=1CEIeFtuxdzeFaeUtA5QuDmFkb5eilUK8zRmk9tcDxc,55644
5
+ sourcecode/architecture_summary.py,sha256=oBkjx7hZxmpdKLt4EracjRKHJFOGuPTd6SadTyPxY7Q,29568
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
@@ -12,7 +12,7 @@ sourcecode/cir_graphs.py,sha256=9G0HHj1kw2325IDyzo2OpX73BNswEckecf4MZUXB4JM,1207
12
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
- sourcecode/confidence_analyzer.py,sha256=_jckZSxksV-OU38vbkxfVNBnWCtlCq8Vwfg23x1uspA,19054
15
+ sourcecode/confidence_analyzer.py,sha256=KCRg8B4WH15LpOB7oYhE4dD5nou9Xi48RT3vpaO6Ar4,20868
16
16
  sourcecode/context_cache.py,sha256=maws79rLKDwyZ7c9Q2LwlIxqZHRFlaKzErtO3mglWoA,25036
17
17
  sourcecode/context_graph.py,sha256=WFtUvfxoE8XtQxtRJLvaF4SJuS00ICjNoTk0sH2_V5U,44454
18
18
  sourcecode/context_scorer.py,sha256=QpChSpsmaAYz91rXA4Ue5xzQmNz_ZboZN09YOHScq1U,14679
@@ -51,7 +51,7 @@ 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=L4r_k6c768yq4GSzSOsJHxot2y8bHREA6up68CUkzo4,13853
54
+ sourcecode/reconciliation.py,sha256=Ulmo4cNgeBnS1KxWv7kKFkXkwgJaQ70P0x6zC4F0I_I,26342
55
55
  sourcecode/redactor.py,sha256=SB4hwIvg8h-hvcqKcDWaZvA-aSyn-at-BIRwa0tUv5E,3227
56
56
  sourcecode/relevance_scorer.py,sha256=0AgEt4KrV73nioMqBgjhGjtY7L2C7L7cSyKtj3IKcrw,9408
57
57
  sourcecode/rename_refactor.py,sha256=h6dNFlB9aZ_3q6heeHBkgXQeXaT03nvPSsYH6P8qxFg,12965
@@ -75,7 +75,7 @@ sourcecode/spring_model.py,sha256=zOAgFmrRbG4a6KLm1TJl55aWMyPNsz3OS3FSczqPG6A,16
75
75
  sourcecode/spring_security_audit.py,sha256=Rk-aSohezdc7YDYbSoJquVnwpkDB8ty1BCD-4Hc4R5A,22832
76
76
  sourcecode/spring_semantic.py,sha256=jteQ1PkY9ArFJv0embg_jBIdbOxqrk9mQ2Xz8OF_FKA,14214
77
77
  sourcecode/spring_tx_analyzer.py,sha256=QMXkxa_hrqDVN3D8WJsd0nT7vfdZCT1E8ZWNoFFbvgU,34692
78
- sourcecode/summarizer.py,sha256=4IPhFg3Anrqa9U17i_bBKq70Q7eVefsCEK3idFC4VsY,26217
78
+ sourcecode/summarizer.py,sha256=E70QILQ8cCNIH485c7lVMv5dXOpUn8qx1habi1_cn6o,26340
79
79
  sourcecode/tree_utils.py,sha256=8GAkIfQAsvtEudIeW1l4ooH_oRtrWR8cpJQJsEa_Pfw,2093
80
80
  sourcecode/type_usage_surface.py,sha256=51IrKRQoIoRnlsiDjHnqpJBn2rc6E59aRhgS0HTzAF0,4428
81
81
  sourcecode/validation_inference.py,sha256=UtmYv-d4TEzT1ihGEipAhtXDrwq0XtVtjMpb3i7NsNc,10815
@@ -137,8 +137,8 @@ sourcecode/telemetry/consent.py,sha256=LIAO9ohJZF8OuZwM4u1VWtALlYfTCCKq4wV3Vwc7i
137
137
  sourcecode/telemetry/events.py,sha256=LtzYfaX9Ilckj5PTvAcTpDa9mLqDsYPDUiDkRa58piY,2580
138
138
  sourcecode/telemetry/filters.py,sha256=NHa5T-6DaZduQPFuC34jOqHWQgSizM-Ygq8aZ4j19ng,5834
139
139
  sourcecode/telemetry/transport.py,sha256=4gGHsq0WeY9VywEZXA3vUxykfiYnw9uuqfjAAec7F8o,1681
140
- sourcecode-2.5.5.dist-info/METADATA,sha256=jTeYMssrsUEZUxEVu8WAQq0ihp7c8FtWCT27m2eNeXo,10851
141
- sourcecode-2.5.5.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
142
- sourcecode-2.5.5.dist-info/entry_points.txt,sha256=-JEAdChrK5We51kZcb7OaDcyil-dHBjBPL-NhuO-QY8,89
143
- sourcecode-2.5.5.dist-info/licenses/LICENSE,sha256=7DdHrU9Z_3e7dSvq4ISijZNjnuHo5NIHNiHDouMQ9JU,10491
144
- sourcecode-2.5.5.dist-info/RECORD,,
140
+ sourcecode-2.5.7.dist-info/METADATA,sha256=WZPw6sppRfqGy5VxcYxS97BHKScBppWAW2UI8eD_TIM,10851
141
+ sourcecode-2.5.7.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
142
+ sourcecode-2.5.7.dist-info/entry_points.txt,sha256=-JEAdChrK5We51kZcb7OaDcyil-dHBjBPL-NhuO-QY8,89
143
+ sourcecode-2.5.7.dist-info/licenses/LICENSE,sha256=7DdHrU9Z_3e7dSvq4ISijZNjnuHo5NIHNiHDouMQ9JU,10491
144
+ sourcecode-2.5.7.dist-info/RECORD,,