sourcecode 2.6.0__py3-none-any.whl → 2.6.2__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.6.0"
7
+ __version__ = "2.6.2"
sourcecode/archetype.py CHANGED
@@ -599,7 +599,18 @@ class ArchetypeClassifier:
599
599
  # winner barely above the evidence floor is "undetermined-ish" and must
600
600
  # not read as high/medium however lonely it is.
601
601
  confident_enough = top.score >= _MIN_CONFIDENT_SCORE
602
- if confident_enough and rel_margin >= 0.4 and mass_backed and graph_backed:
602
+ # Signal diversity: "high" requires the label to be backed by MORE THAN ONE
603
+ # distinct signal. A lone dominant signal — however strong (e.g. a fan-in
604
+ # Gini alone labeling a REST API an "engine") — is a single point of view and
605
+ # must not read as high confidence (field-eval Fase 0).
606
+ distinct_signals = len({e.signal for e in top.evidence if e.contribution > 0})
607
+ if (
608
+ confident_enough
609
+ and rel_margin >= 0.4
610
+ and mass_backed
611
+ and graph_backed
612
+ and distinct_signals >= 2
613
+ ):
603
614
  confidence = "high"
604
615
  elif confident_enough and rel_margin >= 0.25 and mass_backed:
605
616
  confidence = "medium"
sourcecode/cli.py CHANGED
@@ -160,10 +160,11 @@ def _build_help_text() -> str:
160
160
  text = f"""\
161
161
  [bold]ASK Engine[/bold] [dim]· CLI: ask[/dim] {plan_badge}
162
162
 
163
- Persistent structural context and ultra-fast repeated analysis for AI coding agents.
163
+ Deterministic Java/Spring semantics and reusable structural context for AI coding agents.
164
164
 
165
- Cache warms on first scan; every subsequent call returns pre-built context in milliseconds.
166
- Cold scan: 2–10s depending on repo size. Warm cache: 0.3–0.6s.
165
+ Cache warms on first scan; later calls reuse pre-built context instead of rescanning.
166
+ Scan and warm time scale with repo size small repos in seconds, large repos (thousands
167
+ of files) in minutes. Semantic analysis itself is sub-second; repo indexing dominates.
167
168
 
168
169
  [bold]Primary usage:[/bold]
169
170
  ask --compact high-signal summary (~2,500–4,000 tokens)
@@ -725,6 +726,31 @@ DOCS_DEPTH_CHOICES = ["module", "symbols", "full"]
725
726
  # ── Module-level constants ─────────────────────────────────────────────────────
726
727
  _FREE_TIER_NODE_CAP: int = 50 # graph/semantic node cap — applies only to large repos on free tier
727
728
  _JAVA_MIN_SCAN_DEPTH: int = 12 # Maven src/main/java/<pkg>/<module>/File depth floor
729
+ _LARGE_REPO_ADVISORY_FILES: int = 3000 # cold-scan advisory threshold (files); honest expectation-setting, not a speed claim
730
+
731
+
732
+ def _cold_scan_advisory_message(
733
+ file_tree: dict,
734
+ *,
735
+ file_count: Optional[int] = None,
736
+ ) -> Optional[str]:
737
+ """Return the large-repo cold-scan advisory line, or ``None`` when the repo is
738
+ below :data:`_LARGE_REPO_ADVISORY_FILES`.
739
+
740
+ Honest expectation-setting (field-eval Fase 3, item E): it states the *shape* of
741
+ the cost — cold analysis scales with repo size, cached afterwards — and never
742
+ asserts a duration the tool cannot keep. Pure and TTY-agnostic: the caller owns
743
+ the ``sys.stderr.isatty()`` gate so this never reaches stdout (JSON) or an agent.
744
+ """
745
+ if file_count is None:
746
+ from sourcecode.tree_utils import flatten_file_tree as _flatten_ft
747
+ file_count = sum(1 for _p in _flatten_ft(file_tree) if Path(_p).suffix)
748
+ if file_count < _LARGE_REPO_ADVISORY_FILES:
749
+ return None
750
+ return (
751
+ f"[scan] large repository (~{file_count:,} files) — cold analysis scales with "
752
+ "repo size and may take a while; the result is cached, so later runs are fast."
753
+ )
728
754
  _JVM_STACKS: frozenset[str] = frozenset({"java", "kotlin", "scala", "groovy"})
729
755
  _IMPACT_PRIORITY_THRESHOLDS: list[tuple[float, str]] = [
730
756
  (0.60, "high"),
@@ -762,7 +788,7 @@ def _print_welcome_plain(tier: str) -> None:
762
788
  """Plain-text welcome — fallback when rich is unavailable."""
763
789
  lines = ["", f" ASK Engine {__version__} · {tier} · CLI: ask",
764
790
  " Actionable Software Knowledge Engine", "",
765
- " AI coding-agent context, instant.", "", " Get started:"]
791
+ " Deep Java/Spring semantics for AI coding agents.", "", " Get started:"]
766
792
  for cmd, desc in _WELCOME_CMDS:
767
793
  lines.append(f" {cmd.ljust(34)}{desc}")
768
794
  lines.append("")
@@ -812,7 +838,7 @@ def _print_welcome() -> None:
812
838
  t.append(_WELCOME_ENGINE, style="bold dim cyan")
813
839
  t.append(" Actionable Software Knowledge\n", style="dim")
814
840
 
815
- t.append("\nAI coding-agent context, instant.\n\n", style="white")
841
+ t.append("\nDeep Java/Spring semantics for AI coding agents.\n\n", style="white")
816
842
 
817
843
  for cmd, desc in _WELCOME_CMDS:
818
844
  t.append("▸ ", style="cyan")
@@ -1483,23 +1509,60 @@ def main(
1483
1509
  # Step 1: try L1 to obtain the core_hash needed for L2 key
1484
1510
  _l1_result = _cache_mod.read_core(target, _core_key)
1485
1511
 
1486
- # P1-A: --env-map misses L1 when base (em=False) exists.
1487
- # Try the base key so env analysis can be injected lazily (<1 s)
1488
- # instead of triggering a 17 s full rescan.
1512
+ # Additive overlays (--env-map / --git-context) miss L1 because they sit in
1513
+ # the core key, yet neither changes the semantic core env walks config
1514
+ # files, git walks history; both only ATTACH a block. So when the base
1515
+ # (em=False,gc=False) core exists, reuse it and inject the overlay lazily
1516
+ # (<1 s each) instead of triggering a full rescan. (--changed-only is a
1517
+ # different subsystem and is handled separately.)
1518
+ # Try candidate bases that differ from the current flags only by having
1519
+ # some subset of the requested overlays OFF, fewest flips first — so the
1520
+ # RICHEST already-cached core wins (e.g. with --compact --git-context, the
1521
+ # plain --compact core has em=True,gc=False, so we flip only gc and keep em).
1522
+ # Injecting a flag whose base already has it ON would corrupt the reuse, so
1523
+ # we inject exactly the overlays we flipped to land the hit.
1489
1524
  _l1_needs_env_inject = False
1490
- if _l1_result is None and env_map:
1491
- _base_flags = _core_flags_str.replace(",em=True,", ",em=False,")
1492
- _base_h8 = _hashlib.sha256(_base_flags.encode()).hexdigest()[:8]
1525
+ _l1_needs_git_inject = False
1526
+ if _l1_result is None and (env_map or git_context):
1493
1527
  _sha_prefix = _git_sha if _git_sha else "nogit"
1494
- _base_key = f"{_sha_prefix}-{_base_h8}"
1495
- _base_result = _cache_mod.read_core(target, _base_key)
1496
- if _base_result is not None:
1497
- _l1_result = _base_result
1498
- _l1_needs_env_inject = True
1528
+ _flippable = []
1529
+ if git_context:
1530
+ _flippable.append("gc") # inject git is cheap + additive
1531
+ if env_map:
1532
+ _flippable.append("em")
1533
+ # non-empty subsets, ascending size (fewest flips = richest base first)
1534
+ import itertools as _it
1535
+ _subsets = [
1536
+ s for n in range(1, len(_flippable) + 1)
1537
+ for s in _it.combinations(_flippable, n)
1538
+ ]
1539
+ for _subset in _subsets:
1540
+ _bf = _core_flags_str
1541
+ if "gc" in _subset:
1542
+ _bf = _bf.replace(",gc=True,", ",gc=False,")
1543
+ if "em" in _subset:
1544
+ _bf = _bf.replace(",em=True,", ",em=False,")
1545
+ _bk = f"{_sha_prefix}-{_hashlib.sha256(_bf.encode()).hexdigest()[:8]}"
1546
+ _br = _cache_mod.read_core(target, _bk)
1547
+ if _br is not None:
1548
+ _l1_result = _br
1549
+ _l1_needs_git_inject = "gc" in _subset
1550
+ _l1_needs_env_inject = "em" in _subset
1551
+ break
1499
1552
 
1500
1553
  if _l1_result is not None:
1501
1554
  _core_dict_l1, _core_hash = _l1_result
1502
1555
  _view_key = f"{_core_hash}-{_view_h}"
1556
+ # On the lazy-inject path the core is a REUSED base whose _core_hash is
1557
+ # shared with the base's overlay-free view — suffix the view key with the
1558
+ # injected overlays so the injected body never collides with (or is served
1559
+ # from) the base view. Non-inject runs keep their exact historical key, so
1560
+ # existing cached views stay valid (no cross-version churn).
1561
+ if _l1_needs_git_inject or _l1_needs_env_inject:
1562
+ _view_key = (
1563
+ f"{_view_key}-ov:gc{int(_l1_needs_git_inject)}"
1564
+ f"em{int(_l1_needs_env_inject)}"
1565
+ )
1503
1566
 
1504
1567
  # Step 2: try L2 (exact view match).
1505
1568
  # Skip L2 for --changed-only: the stored view is a previous
@@ -1553,6 +1616,23 @@ def main(
1553
1616
  ]
1554
1617
  except Exception:
1555
1618
  pass # env inject failed — continue without env data
1619
+ # P-gc: inject git context when a gc=False base L1 was reused.
1620
+ # GitAnalyzer walks history only — typically <1 s. Reuses the
1621
+ # serializer's own compact shaping (no duplication/drift).
1622
+ if _rebuilt is not None and _l1_needs_git_inject and compact:
1623
+ try:
1624
+ from types import SimpleNamespace as _NS_gc
1625
+ from sourcecode.git_analyzer import GitAnalyzer as _GitA_gc
1626
+ from sourcecode.serializer import _compact_git_context as _cgc
1627
+ _gc_obj = _GitA_gc().analyze(
1628
+ target, depth=git_depth, days=git_days
1629
+ )
1630
+ _gc_block = _cgc(_NS_gc(git_context=_gc_obj))
1631
+ if _gc_block:
1632
+ _rebuilt = dict(_rebuilt)
1633
+ _rebuilt["git_context"] = _gc_block
1634
+ except Exception:
1635
+ pass # git inject failed — continue without git data
1556
1636
  if _rebuilt is not None:
1557
1637
  # Apply redaction
1558
1638
  if not no_redact:
@@ -1729,6 +1809,17 @@ def main(
1729
1809
  # 2. Filter .env and *.secret entries from file tree (SEC-02, all levels)
1730
1810
  file_tree = filter_sensitive_files(raw_tree, redactor)
1731
1811
  perf.stop("discovery", _perf_discovery)
1812
+
1813
+ # Cold-scan advisory (honest expectation-setting, NOT a speed claim). A cache
1814
+ # hit short-circuits above (line ~1647) before this scan, so reaching here means
1815
+ # a cold run: the full parse+analysis below scales with repo size. On a large
1816
+ # repo, warn once on the TTY so the wait is expected — never on stdout (JSON) and
1817
+ # never for agents/pipes. This does not assert a duration; it states the shape.
1818
+ if sys.stderr.isatty() and not no_cache:
1819
+ _advisory = _cold_scan_advisory_message(file_tree)
1820
+ if _advisory:
1821
+ typer.echo(_advisory, err=True)
1822
+
1732
1823
  _perf_detection = perf.start()
1733
1824
  detector = ProjectDetector(build_default_detectors())
1734
1825
  workspace_analysis = WorkspaceAnalyzer().analyze(target, manifests)
@@ -5295,22 +5386,23 @@ def _render_spring_audit_github_comment(result: "SpringAuditResult", min_severit
5295
5386
  lines.append("")
5296
5387
 
5297
5388
  if not visible:
5389
+ # No findings at/above severity — but a gate-coverage escape set (a separate
5390
+ # signal, not a "finding") may still be worth surfacing before the footer.
5298
5391
  lines.append(f"_No findings at or above `{min_severity}` severity._")
5299
- return "\n".join(lines)
5300
-
5301
- lines += [
5302
- "| Sev | Pattern | File | Symbol | Title |",
5303
- "|-----|---------|------|--------|-------|",
5304
- ]
5305
- for f in sorted(visible, key=lambda x: (SEVERITY_ORDER.get(x.severity, 3), x.source_file)):
5306
- icon = _ICONS.get(f.severity, "")
5307
- label = _LABELS.get(f.severity, f.severity.upper())
5308
- short_file = f.source_file.split("/")[-1] if "/" in f.source_file else f.source_file
5309
- short_sym = f.symbol.split(".")[-1] if "." in f.symbol else f.symbol
5310
- title_escaped = f.title.replace("|", "\\|")
5311
- lines.append(f"| {icon} {label} | `{f.pattern_id}` | `{short_file}` | `{short_sym}` | {title_escaped} |")
5392
+ else:
5393
+ lines += [
5394
+ "| Sev | Pattern | File | Symbol | Title |",
5395
+ "|-----|---------|------|--------|-------|",
5396
+ ]
5397
+ for f in sorted(visible, key=lambda x: (SEVERITY_ORDER.get(x.severity, 3), x.source_file)):
5398
+ icon = _ICONS.get(f.severity, "")
5399
+ label = _LABELS.get(f.severity, f.severity.upper())
5400
+ short_file = f.source_file.split("/")[-1] if "/" in f.source_file else f.source_file
5401
+ short_sym = f.symbol.split(".")[-1] if "." in f.symbol else f.symbol
5402
+ title_escaped = f.title.replace("|", "\\|")
5403
+ lines.append(f"| {icon} {label} | `{f.pattern_id}` | `{short_file}` | `{short_sym}` | {title_escaped} |")
5312
5404
 
5313
- lines.append("")
5405
+ lines.append("")
5314
5406
 
5315
5407
  if visible:
5316
5408
  lines.append("<details>")
@@ -5327,6 +5419,8 @@ def _render_spring_audit_github_comment(result: "SpringAuditResult", min_severit
5327
5419
  lines.append("")
5328
5420
  lines.append("</details>")
5329
5421
 
5422
+ lines += _render_gate_coverage_section(result)
5423
+
5330
5424
  lines += [
5331
5425
  "",
5332
5426
  f"_Generated by [sourcecode](https://github.com/sourcecode-ai/sourcecode) · "
@@ -5335,6 +5429,57 @@ def _render_spring_audit_github_comment(result: "SpringAuditResult", min_severit
5335
5429
  return "\n".join(lines)
5336
5430
 
5337
5431
 
5432
+ _GATE_COVERAGE_RENDER_CAP = 25
5433
+
5434
+
5435
+ def _render_gate_coverage_section(result: "SpringAuditResult") -> list[str]: # type: ignore[name-defined]
5436
+ """Human-readable gate-coverage block: the controller handlers that do NOT carry
5437
+ the auto-detected custom authorization gate — the escape set the JSON already
5438
+ computes, surfaced so a reviewer sees it at a glance. Never says "unsecured": a
5439
+ handler a servlet filter pattern covers is marked "possibly filter-covered", and the
5440
+ unmatched ones are flagged for review, not condemned."""
5441
+ gc = (result.security_posture or {}).get("gate_coverage")
5442
+ if not gc:
5443
+ return []
5444
+ not_covered = gc.get("not_carrying_gate", 0)
5445
+ gates = ", ".join(f"`{g}`" for g in gc.get("gate_annotations", [])) or "the detected gate"
5446
+ total = gc.get("total_controller_handlers", 0)
5447
+ lines: list[str] = ["", "---", ""]
5448
+ if not_covered == 0:
5449
+ lines.append(f"✅ **Gate coverage** — all {total} controller handlers carry {gates}.")
5450
+ return lines
5451
+
5452
+ covered = gc.get("possibly_filter_covered", 0)
5453
+ lines.append(
5454
+ f"🔓 **Gate coverage** — {not_covered} of {total} controller handlers do not carry "
5455
+ f"{gates}."
5456
+ )
5457
+ if gc.get("reconstructed_filter_patterns"):
5458
+ lines.append(
5459
+ f"_{covered} of those match a reconstructed servlet filter pattern "
5460
+ f"(possibly filter-covered); {gc.get('no_matching_filter_pattern', 0)} match none._"
5461
+ )
5462
+ lines += ["", "<details>", "<summary>Handlers without the gate</summary>", ""]
5463
+ lines += [
5464
+ "| Method | Path | Filter pattern | Policy |",
5465
+ "|--------|------|----------------|--------|",
5466
+ ]
5467
+ handlers = gc.get("handlers_without_gate", [])
5468
+ for h in handlers[:_GATE_COVERAGE_RENDER_CAP]:
5469
+ method = (h.get("method") or "").replace("|", "\\|")
5470
+ path = (h.get("path") or "").replace("|", "\\|")
5471
+ fpat = h.get("filter_pattern_match")
5472
+ fcell = f"`{fpat}`" if fpat else "—"
5473
+ policy = (h.get("policy") or "").replace("|", "\\|")
5474
+ lines.append(f"| {method} | `{path}` | {fcell} | {policy} |")
5475
+ if len(handlers) > _GATE_COVERAGE_RENDER_CAP:
5476
+ lines.append(f"| … | _+{len(handlers) - _GATE_COVERAGE_RENDER_CAP} more_ | | |")
5477
+ lines += ["", "</details>", ""]
5478
+ if gc.get("note"):
5479
+ lines.append(f"> {gc['note']}")
5480
+ return lines
5481
+
5482
+
5338
5483
  @app.command("spring-audit")
5339
5484
  def spring_audit_cmd(
5340
5485
  path: Path = typer.Argument(
@@ -5804,7 +5949,14 @@ def impact_chain_cmd(
5804
5949
 
5805
5950
  _prog = Progress()
5806
5951
  _prog.start(f"analyzing impact ({len(file_list)} files)")
5807
- cir = ContextGraph.build(file_list, target).cir
5952
+ # Reuse the shared context-cache CIR (same entry `cache warm` and `explain` use) so
5953
+ # a warmed repo skips the expensive Java parse. Best-effort — any fault falls back to
5954
+ # a fresh build, exactly as explain does, so impact-chain never breaks.
5955
+ from sourcecode import context_cache as _ctxcache
5956
+ try:
5957
+ cir, _ = _ctxcache.get_or_build_cir(_resolve_repo_root(target), target, file_list)
5958
+ except Exception:
5959
+ cir = ContextGraph.build(file_list, target).cir
5808
5960
  _model = SpringSemanticModel.build(cir)
5809
5961
 
5810
5962
  if query_type == "events":
@@ -8215,6 +8367,22 @@ def cache_clear_cmd(
8215
8367
  typer.echo(f"Removed {removed} file(s).", err=True)
8216
8368
 
8217
8369
 
8370
+ def _warm_shared_cir(target: Path):
8371
+ """Pre-populate the shared Canonical IR in the context cache (the entry explain and
8372
+ other knowledge commands reuse). Returns the KnowledgeLookup, or None on any fault —
8373
+ warming is best-effort and never fails the warm command."""
8374
+ try:
8375
+ from sourcecode.repository_ir import find_java_files as _fjf
8376
+ from sourcecode import context_cache as _ctxcache
8377
+ files = _fjf(target)
8378
+ if not files:
8379
+ return None
8380
+ _cir, look = _ctxcache.get_or_build_cir(_resolve_repo_root(target), target, files)
8381
+ return look
8382
+ except Exception:
8383
+ return None
8384
+
8385
+
8218
8386
  @cache_app.command("warm")
8219
8387
  def cache_warm_cmd(
8220
8388
  path: Path = typer.Argument(Path("."), help="Repository path to warm (default: current directory)"),
@@ -8245,6 +8413,18 @@ def cache_warm_cmd(
8245
8413
  typer.echo(result.stderr.strip(), err=True)
8246
8414
  raise typer.Exit(code=result.returncode)
8247
8415
 
8416
+ # Also pre-populate the shared Canonical IR (context cache) — a DIFFERENT cache from
8417
+ # the L1/L2 core cache warmed above. explain (and, going forward, other knowledge
8418
+ # commands) reuse this CIR; without warming it here, the first explain rebuilds the
8419
+ # expensive Java parse despite a "warm" cache (field-eval: explain MISS after warm).
8420
+ _look = _warm_shared_cir(target)
8421
+ if _look is not None:
8422
+ typer.echo(
8423
+ f"Shared CIR {'reused' if _look.hit else 'built'} "
8424
+ "(explain/impact reuse this).",
8425
+ err=True,
8426
+ )
8427
+
8248
8428
 
8249
8429
  @cache_app.command("freshness")
8250
8430
  def cache_freshness_cmd(
@@ -8386,6 +8566,23 @@ def _stderr_is_interactive() -> bool:
8386
8566
  return False
8387
8567
 
8388
8568
 
8569
+ def _force_utf8_streams() -> None:
8570
+ """Force UTF-8 on stdout AND stderr so Unicode characters (em-dash, arrows, box
8571
+ drawing) survive on Windows where the default console codec is cp1252 (BUG-1).
8572
+
8573
+ stderr matters as much as stdout: progress/warn/gap status text is emitted there,
8574
+ and a cp1252 encoder turned its em-dashes into the replacement char (field-eval).
8575
+ Best-effort and idempotent — a stream without ``reconfigure`` (already wrapped, or a
8576
+ test buffer) is skipped silently.
8577
+ """
8578
+ for stream in (sys.stdout, sys.stderr):
8579
+ if hasattr(stream, "reconfigure"):
8580
+ try:
8581
+ stream.reconfigure(encoding="utf-8")
8582
+ except Exception:
8583
+ pass
8584
+
8585
+
8389
8586
  def main_entry() -> None:
8390
8587
  """CLI entry point for both the canonical ``ask`` command and the deprecated
8391
8588
  ``sourcecode`` compat alias — one implementation, no duplication.
@@ -8395,13 +8592,7 @@ def main_entry() -> None:
8395
8592
  can consume them as positional arguments (which would prevent subcommand
8396
8593
  routing for tokens like 'version' or 'config').
8397
8594
  """
8398
- # Force UTF-8 on stdout so Unicode characters (arrows, etc.) survive on
8399
- # Windows where the default console codec is cp1252 (BUG-1).
8400
- if hasattr(sys.stdout, "reconfigure"):
8401
- try:
8402
- sys.stdout.reconfigure(encoding="utf-8")
8403
- except Exception:
8404
- pass
8595
+ _force_utf8_streams()
8405
8596
  # Deprecation notice when invoked through the legacy `sourcecode` alias.
8406
8597
  # One line, and only on an interactive terminal — pipes/agents that still call
8407
8598
  # `sourcecode` keep clean stderr (error envelopes are JSON on stderr), so the
@@ -620,6 +620,46 @@ def get_or_build_cir(
620
620
  return cir, KnowledgeLookup(hit=False, enabled=True, build_ms=build_ms)
621
621
 
622
622
 
623
+ def peek_cir(
624
+ repo_root: Path,
625
+ *,
626
+ since: Optional[str] = None,
627
+ ) -> Optional[Any]:
628
+ """Return the shared ``CanonicalRepositoryIR`` **only if already cached**.
629
+
630
+ Get-only sibling of :func:`get_or_build_cir`: it never builds and never
631
+ writes. A caller that holds only a *bounded scope* of the repo (e.g. the
632
+ review-pr git-first path) must use this — routing a scoped build through
633
+ ``get_or_build_cir`` would store a truncated CIR under the repo-wide
634
+ knowledge key and poison every command that reads it. On a hit the caller
635
+ gets the full repo-wide CIR for free (O(1) reconstruction from raw IR); on
636
+ a miss it gets ``None`` and keeps its own scoped build, unshared.
637
+
638
+ The reconstructed CIR is left repo-consistent (``file_paths=None`` derives
639
+ the file list from the cached nodes, not from any caller scope).
640
+ """
641
+ from sourcecode.canonical_ir import ir_dict_to_canonical, validate_canonical_ir
642
+
643
+ cache = ContextCache.for_repo(repo_root)
644
+ if not cache.enabled:
645
+ return None
646
+ key = cache.knowledge_key(SCOPE_JAVA_CIR, options={"since": since} if since else None)
647
+ cached = cache.get(key)
648
+ if cached is None:
649
+ return None
650
+ raw = cached.payload.get("raw_ir")
651
+ if not isinstance(raw, dict):
652
+ return None
653
+ try:
654
+ cir = ir_dict_to_canonical(raw, file_paths=None)
655
+ # validate_canonical_ir returns a LIST of problems — empty means valid.
656
+ if not validate_canonical_ir(cir):
657
+ return cir
658
+ except Exception:
659
+ pass # corrupt/incompatible payload — treat as miss
660
+ return None
661
+
662
+
623
663
  # ---------------------------------------------------------------------------
624
664
  # Module-level observability helper
625
665
  # ---------------------------------------------------------------------------
@@ -0,0 +1,173 @@
1
+ """filter_surface.py — servlet filter URL-pattern reconstruction (Fase 2).
2
+
3
+ Reconstructs the URL patterns that servlet filters are mapped to, from the two
4
+ **deterministic** sources the platform declares them in:
5
+
6
+ 1. ``@WebFilter(urlPatterns=… / value=…)`` annotation arguments in ``.java``;
7
+ 2. ``web.xml`` ``<filter-mapping><url-pattern>`` entries.
8
+
9
+ This is an EXTRACTOR (it reads source / XML), deliberately separate from the
10
+ Knowledge-Layer ``security_posture`` module (which never opens source). Its output
11
+ is a set of servlet URL patterns that the security posture then crosses against the
12
+ endpoint surface, so an endpoint that does NOT carry a per-method gate can still be
13
+ reported as *possibly covered* by a filter whose pattern matches its path — rather
14
+ than being over-flagged.
15
+
16
+ **Explicit scope (a measured limitation, not a bug).** Three further pattern sources
17
+ are NOT reconstructed here and are declared to the caller: Spring
18
+ ``FilterRegistrationBean.addUrlPatterns()``, the ``HttpSecurity``/``requestMatchers``
19
+ DSL, and AspectJ ``@Aspect`` pointcuts (which are not URL patterns at all). Absence of
20
+ a reconstructed pattern therefore never proves an endpoint is unprotected — it means
21
+ "no *declarative servlet* pattern matched"; the caller frames it as review, never as
22
+ "unsecured".
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import re
27
+ import xml.etree.ElementTree as ET
28
+ from dataclasses import dataclass, field
29
+ from pathlib import Path
30
+ from typing import Optional
31
+
32
+ # @WebFilter(...) argument block. We capture the whole paren body and pull quoted
33
+ # tokens from the urlPatterns/value members (both are the servlet-standard members).
34
+ _WEBFILTER_RE = re.compile(r"@WebFilter\s*\((?P<body>[^)]*)\)", re.DOTALL)
35
+ _QUOTED_RE = re.compile(r'"([^"]*)"')
36
+ # urlPatterns=… or value=… member (either names the patterns). If neither member
37
+ # name is present, a bare @WebFilter("/x") uses value implicitly → all quoted tokens.
38
+ _MEMBER_RE = re.compile(r"(urlPatterns|value)\s*=", re.DOTALL)
39
+
40
+
41
+ @dataclass
42
+ class FilterSurface:
43
+ """Reconstructed servlet filter URL patterns + their provenance."""
44
+
45
+ patterns: list[str] = field(default_factory=list)
46
+ # pattern → list of human-readable sources (filter class / web.xml) — evidence only
47
+ provenance: dict[str, list[str]] = field(default_factory=dict)
48
+
49
+ def is_empty(self) -> bool:
50
+ return not self.patterns
51
+
52
+ def matching_pattern(self, path: Optional[str]) -> Optional[str]:
53
+ """The first reconstructed servlet pattern that covers ``path`` (servlet
54
+ matching semantics), or None. Deterministic: patterns are checked in sorted
55
+ order so the result is stable."""
56
+ if not path:
57
+ return None
58
+ for pat in sorted(self.patterns):
59
+ if _servlet_pattern_matches(pat, path):
60
+ return pat
61
+ return None
62
+
63
+ def to_dict(self) -> dict:
64
+ return {
65
+ "patterns": sorted(self.patterns),
66
+ "provenance": {k: sorted(v) for k, v in sorted(self.provenance.items())},
67
+ }
68
+
69
+
70
+ def _servlet_pattern_matches(pattern: str, path: str) -> bool:
71
+ """Servlet-spec URL-pattern match (SRV.12.2), the semantics @WebFilter / web.xml
72
+ use — NOT Ant globbing:
73
+
74
+ * ``/*`` → matches every path (catch-all);
75
+ * ``/prefix/*`` → path-prefix: the prefix itself or anything under it;
76
+ * ``*.ext`` → extension match on the final path segment;
77
+ * exact string → equality.
78
+
79
+ ``/`` (the default-servlet mapping) is treated as exact — it does not blanket
80
+ every path here, to avoid over-claiming coverage.
81
+ """
82
+ if not pattern:
83
+ return False
84
+ p = path if path.startswith("/") else "/" + path
85
+ if pattern == "/*":
86
+ return True
87
+ if pattern.endswith("/*"):
88
+ prefix = pattern[:-2] # "/api/*" → "/api"
89
+ return p == prefix or p.startswith(prefix + "/")
90
+ if pattern.startswith("*."):
91
+ return p.rsplit("/", 1)[-1].endswith(pattern[1:]) # "*.do" → endswith ".do"
92
+ return p == pattern
93
+
94
+
95
+ def _patterns_from_webfilter(source: str) -> list[str]:
96
+ """Servlet URL patterns declared in ``@WebFilter(...)`` annotations in one file."""
97
+ out: list[str] = []
98
+ for m in _WEBFILTER_RE.finditer(source):
99
+ body = m.group("body")
100
+ if not body.strip():
101
+ continue
102
+ # If the body names urlPatterns=/value=, take quoted tokens AFTER that member;
103
+ # else (bare @WebFilter("/x")) take all quoted tokens. servletNames are not URL
104
+ # patterns, but they are rare and never start with '/'/'*', so filtering by shape
105
+ # keeps them out.
106
+ mem = _MEMBER_RE.search(body)
107
+ segment = body[mem.end():] if mem else body
108
+ for tok in _QUOTED_RE.findall(segment):
109
+ if tok and (tok.startswith("/") or tok.startswith("*.")):
110
+ out.append(tok)
111
+ return out
112
+
113
+
114
+ def _patterns_from_web_xml(xml_path: Path) -> list[str]:
115
+ """Servlet URL patterns from ``<filter-mapping><url-pattern>`` in one web.xml.
116
+ Namespace-agnostic; never raises on malformed XML (returns what it parsed)."""
117
+ out: list[str] = []
118
+ try:
119
+ root = ET.parse(str(xml_path)).getroot()
120
+ except Exception:
121
+ return out
122
+ for elem in root.iter():
123
+ tag = elem.tag.rsplit("}", 1)[-1] # strip any namespace
124
+ if tag != "filter-mapping":
125
+ continue
126
+ for child in elem.iter():
127
+ if child.tag.rsplit("}", 1)[-1] == "url-pattern" and (child.text or "").strip():
128
+ out.append(child.text.strip())
129
+ return out
130
+
131
+
132
+ def build_filter_surface(
133
+ root: Path, java_files: Optional[list[str]] = None
134
+ ) -> FilterSurface:
135
+ """Reconstruct the declarative servlet filter surface under ``root``.
136
+
137
+ ``java_files`` are repo-relative ``.java`` paths (as ``find_java_files`` yields);
138
+ when omitted they are discovered. web.xml files are located by glob. Best-effort:
139
+ an unreadable file is skipped, never fatal.
140
+ """
141
+ surface = FilterSurface()
142
+
143
+ if java_files is None:
144
+ try:
145
+ from sourcecode.repository_ir import find_java_files
146
+ java_files = find_java_files(root)
147
+ except Exception:
148
+ java_files = []
149
+
150
+ def _add(pattern: str, source: str) -> None:
151
+ if pattern not in surface.provenance:
152
+ surface.patterns.append(pattern)
153
+ surface.provenance[pattern] = []
154
+ if source not in surface.provenance[pattern]:
155
+ surface.provenance[pattern].append(source)
156
+
157
+ for rel in java_files:
158
+ abs_path = root / rel
159
+ try:
160
+ src = abs_path.read_text(encoding="utf-8", errors="replace")
161
+ except OSError:
162
+ continue
163
+ if "@WebFilter" not in src:
164
+ continue
165
+ for pat in _patterns_from_webfilter(src):
166
+ _add(pat, f"@WebFilter:{rel}")
167
+
168
+ for xml_path in sorted(root.rglob("web.xml")):
169
+ rel = xml_path.relative_to(root).as_posix()
170
+ for pat in _patterns_from_web_xml(xml_path):
171
+ _add(pat, f"web.xml:{rel}")
172
+
173
+ return surface
@@ -2155,6 +2155,16 @@ def run_migrate_check(
2155
2155
  },
2156
2156
  )
2157
2157
 
2158
+ # Stamp the shared, path-stable repo identity (the id the cache subsystem keys on)
2159
+ # so migrate-check no longer emits an empty repo_id while every other command emits
2160
+ # one. migrate-check is deliberately cir-free (its speed advantage), so it uses the
2161
+ # cache repo_id rather than building a CanonicalIR just to hash it.
2162
+ try:
2163
+ from sourcecode.cache import repo_id as _repo_id
2164
+ report.repo_id = _repo_id(root)
2165
+ except Exception:
2166
+ pass
2167
+
2158
2168
  try:
2159
2169
  import subprocess as _sub
2160
2170
  _r = _sub.run(
@@ -1964,8 +1964,28 @@ class TaskContextBuilder:
1964
1964
  if task_name == "review-pr" and _delta_files:
1965
1965
  from sourcecode.context_graph import ContextGraph
1966
1966
  from sourcecode.semantic_impact_engine import SemanticImpactEngine
1967
+ from sourcecode import context_cache as _ctxcache
1967
1968
  _changed_sorted = sorted(_delta_files)
1968
- _impact_engine = SemanticImpactEngine(ContextGraph.build(all_paths, self.root))
1969
+ # Reuse the shared repo-wide CIR when it is ALREADY WARM: the review-pr
1970
+ # path builds `all_paths` as a deliberately bounded scope (changed files
1971
+ # + sibling dirs, no full-repo traversal), so a fresh build over it must
1972
+ # never reach the shared cache — `peek_cir` only reads, never builds or
1973
+ # stores, so it can never poison explain/impact. On a hit the engine runs
1974
+ # over the full-repo graph (strictly more callers, closing the DI-mediated
1975
+ # blind spot) at O(1); on a miss we keep the current scoped build.
1976
+ _graph = None
1977
+ try:
1978
+ _repo_root = self.root.resolve()
1979
+ while not (_repo_root / ".git").exists() and _repo_root.parent != _repo_root:
1980
+ _repo_root = _repo_root.parent
1981
+ _cached_cir = _ctxcache.peek_cir(_repo_root)
1982
+ if _cached_cir is not None:
1983
+ _graph = ContextGraph.from_cir(_cached_cir)
1984
+ except Exception:
1985
+ _graph = None
1986
+ if _graph is None:
1987
+ _graph = ContextGraph.build(all_paths, self.root)
1988
+ _impact_engine = SemanticImpactEngine(_graph)
1969
1989
  _execution_paths = _impact_engine.execution_paths(_changed_sorted)
1970
1990
  _behavioral_impact = _impact_engine.behavioral_impact(_changed_sorted)
1971
1991
 
@@ -134,7 +134,13 @@ _ANN_WITH_ARGS_RE = re.compile(
134
134
  _CLASS_DECL_RE = re.compile(
135
135
  r'(?:^|(?<=\s))'
136
136
  r'(?P<kind>class|interface|enum|record|@interface)\s+'
137
- r'(?P<name>[A-Z]\w*)'
137
+ # P1-H: Java identifiers may start lowercase — the uppercase-only start
138
+ # (PascalCase convention) silently dropped valid lowercase-initial types
139
+ # (Broadleaf `iFieldMetadata`, `i18nUpdateCartServiceExtensionHandler`).
140
+ # A literal type keyword + terminal `{` already gates the match, so widening
141
+ # the name-start does not admit prose/string phantoms the uppercase form
142
+ # was not already exposed to (verified fleet A/B, no new symbols on controls).
143
+ r'(?P<name>[A-Za-z]\w*)'
138
144
  r'(?:\s*<[^{;]*?(?=>|\{))?'
139
145
  # P1-F: record component list `record Point(int x, int y)`. `[^{;]*` is greedy
140
146
  # to the LAST ')' before the body brace, so annotated components with
@@ -663,7 +669,22 @@ _ANN_NAME_RE = re.compile(r'^@\w+\s*')
663
669
  _STRING_LITERAL_RE = re.compile(r'"(?:[^"\\]|\\.)*"|\'(?:[^\'\\]|\\.)*\'')
664
670
 
665
671
  # Module-level cache for class-keyword detection (avoids recompilation per _extract_symbols call)
666
- _CLASS_KW_RE = re.compile(r'\b(?:class|interface|enum|record)\s+[A-Z]')
672
+ # P1-H: lowercase-initial start mirrors _CLASS_DECL_RE so multi-line lowercase-named
673
+ # declarations (`class i18nHandler extends A\n implements B {`) are joined too.
674
+ _CLASS_KW_RE = re.compile(r'\b(?:class|interface|enum|record)\s+[A-Za-z]')
675
+
676
+
677
+ def _blank_string_literals(text: str) -> str:
678
+ """Return `text` with each string/char literal replaced by equal-length spaces.
679
+
680
+ P1-H: type-declaration scans (_CLASS_DECL_RE / _CLASS_KW_RE) run on lines whose
681
+ comments are stripped but whose STRING content survives. A log/message literal
682
+ such as `log.debug("... advice class for {} ...")` then matches `class for {`
683
+ and mints a phantom type `for`. Blanking the literal content (length-preserving,
684
+ so match offsets stay aligned with the original line) removes the false surface
685
+ without touching genuine declarations, which never live inside a string.
686
+ """
687
+ return _STRING_LITERAL_RE.sub(lambda m: ' ' * len(m.group(0)), text)
667
688
 
668
689
 
669
690
  # ---------------------------------------------------------------------------
@@ -958,7 +979,8 @@ def _extract_symbols(
958
979
  _start = _i # 0-based source index where this joined entry begins
959
980
  _line = _raw_lines[_i]
960
981
  _stripped = _line.strip()
961
- if (_CLASS_KW_RE.search(_stripped) and '{' not in _stripped
982
+ _kw_scan = _blank_string_literals(_stripped)
983
+ if (_CLASS_KW_RE.search(_kw_scan) and '{' not in _kw_scan
962
984
  and not _stripped.startswith('//')
963
985
  and not _stripped.startswith('*')):
964
986
  # Continuation: join until we hit a line containing '{'
@@ -1043,7 +1065,10 @@ def _extract_symbols(
1043
1065
  _pop_closed(class_stack, depth)
1044
1066
  continue
1045
1067
 
1046
- cls_m = _CLASS_DECL_RE.search(stripped)
1068
+ # P1-H: search a string-blanked view so `class`/`interface` keywords that
1069
+ # appear inside string literals (log messages, templates) do not mint a
1070
+ # phantom type. Length-preserving, so cls_m offsets align with `stripped`.
1071
+ cls_m = _CLASS_DECL_RE.search(_blank_string_literals(stripped))
1047
1072
  if cls_m:
1048
1073
  kind_kw = cls_m.group("kind")
1049
1074
  name = cls_m.group("name")
@@ -2311,7 +2336,9 @@ def _extract_field_types(
2311
2336
  package = stripped[8:].rstrip(";").strip()
2312
2337
  continue
2313
2338
  net = _count_net_braces(stripped)
2314
- cls_m = _CLASS_DECL_RE.search(stripped)
2339
+ # P1-H: string-blanked search (see _blank_string_literals) — a keyword inside
2340
+ # a literal must not push a phantom nesting frame onto class_stack.
2341
+ cls_m = _CLASS_DECL_RE.search(_blank_string_literals(stripped))
2315
2342
  if cls_m:
2316
2343
  name = cls_m.group("name")
2317
2344
  fqn = f"{class_stack[-1][0]}.{name}" if class_stack else (
@@ -452,6 +452,10 @@ def gate_bearing_handlers(
452
452
  }
453
453
 
454
454
 
455
+ # Cap on the explicitly-listed escape set (rollup counts are always exact).
456
+ _GATE_COVERAGE_LIST_CAP = 200
457
+
458
+
455
459
  @dataclass
456
460
  class SecurityPostureResult:
457
461
  """Repo-level security-posture projection (P1-A)."""
@@ -462,6 +466,11 @@ class SecurityPostureResult:
462
466
  endpoint_verdicts: list[dict] = field(default_factory=list)
463
467
  rollup: dict = field(default_factory=dict)
464
468
  limitations: list[str] = field(default_factory=list)
469
+ # Gate-coverage projection (P1-A follow-up): when a custom gate IS the detected
470
+ # mechanism, which controller handlers do NOT carry it and are not standard-guarded
471
+ # — the residue that relies solely on a centralized filter, if one exists. None when
472
+ # no custom gate was detected (the question does not apply). Never "unsecured".
473
+ gate_coverage: Optional[dict] = None
465
474
 
466
475
  def gate_simple_names(self) -> set[str]:
467
476
  out: set[str] = set()
@@ -481,6 +490,7 @@ class SecurityPostureResult:
481
490
  ),
482
491
  "endpoints": self.endpoint_verdicts,
483
492
  "rollup": self.rollup,
493
+ **({"gate_coverage": self.gate_coverage} if self.gate_coverage else {}),
484
494
  "limitations": list(self.limitations),
485
495
  }
486
496
 
@@ -490,6 +500,7 @@ def infer_security_posture(
490
500
  *,
491
501
  root: Optional[Path] = None,
492
502
  coverage_threshold: float = _DEFAULT_COVERAGE,
503
+ filter_surface: Optional[Any] = None,
493
504
  ) -> SecurityPostureResult:
494
505
  """Infer the repo's endpoint security posture (P1-A).
495
506
 
@@ -499,6 +510,12 @@ def infer_security_posture(
499
510
  3. upgrade any that a config file confirms (Likely → Verified);
500
511
  4. classify every endpoint into the 4-way posture with its confidence.
501
512
 
513
+ ``filter_surface`` (a :class:`filter_surface.FilterSurface`) is an optional
514
+ reconstruction of declarative servlet filter URL patterns; when absent it is built
515
+ from ``root`` (best-effort). It is used only to *sharpen* the gate-coverage escape
516
+ set — marking a gate-less handler whose path a filter pattern covers — never to
517
+ assert protection. Parsing lives in the extractor module, not here.
518
+
502
519
  Never raises; never emits a categorical "unsecured" verdict.
503
520
  """
504
521
  security_model = str(cir.metadata.get("security_model", "unknown"))
@@ -535,6 +552,9 @@ def infer_security_posture(
535
552
  verdicts: list[dict] = []
536
553
  rollup = {"protected_standard": 0, "protected_custom": 0,
537
554
  "coverage_unknown": 0, "probably_exposed": 0}
555
+ # escape set: handlers that do NOT carry the detected custom gate and have no
556
+ # standard guard of their own — the residue that relies solely on a filter.
557
+ escaping: list[dict] = []
538
558
 
539
559
  for ep in cir.endpoints:
540
560
  policy = ep.security.policy if ep.security is not None else "none_detected"
@@ -585,6 +605,22 @@ def infer_security_posture(
585
605
  "policy": policy,
586
606
  })
587
607
 
608
+ # escape set (only meaningful when a custom gate is the mechanism): a handler
609
+ # that neither carries the gate nor a standard guard nor a config-custom policy.
610
+ if (
611
+ has_custom_gate
612
+ and not carries_gate
613
+ and policy != "custom"
614
+ and not _is_standard_guard_policy(policy)
615
+ ):
616
+ escaping.append({
617
+ "endpoint_id": ep.id,
618
+ "method": ep.method,
619
+ "path": ep.path,
620
+ "handler_symbol": ep.handler_symbol,
621
+ "policy": policy,
622
+ })
623
+
588
624
  limitations = [
589
625
  "Posture is inferred from static structure; a runtime security config "
590
626
  "(WebSecurity DSL, XML) may protect routes this projection marks unknown.",
@@ -595,6 +631,65 @@ def infer_security_posture(
595
631
  "it are reported 'coverage_unknown', never 'unsecured'."
596
632
  )
597
633
 
634
+ # Gate-coverage projection: the escape set relative to the detected custom gate.
635
+ # Turns a "N endpoints none_detected" non-signal into a reviewable list of the
636
+ # handlers that do NOT carry the gate the rest of the surface uses. Honest framing:
637
+ # a centralized filter (if present) MAY still cover them — its per-URL patterns are
638
+ # not reconstructed in this increment, so the verdict is review, never "unsecured".
639
+ gate_coverage: Optional[dict] = None
640
+ if has_custom_gate:
641
+ # Reconstruct declarative servlet filter patterns (extractor; best-effort) and
642
+ # cross each escaping handler against them. A match => the handler MAY be filter-
643
+ # covered (still review — pattern presence is not proof of enforcement); no match
644
+ # => no declarative servlet pattern covers it (the sharper escape signal).
645
+ if filter_surface is None and root is not None:
646
+ try:
647
+ from sourcecode.filter_surface import build_filter_surface
648
+ filter_surface = build_filter_surface(root)
649
+ except Exception:
650
+ filter_surface = None
651
+
652
+ possibly_covered = 0
653
+ no_pattern = 0
654
+ for h in escaping:
655
+ match = filter_surface.matching_pattern(h.get("path")) if filter_surface else None
656
+ if match:
657
+ h["filter_pattern_match"] = match
658
+ possibly_covered += 1
659
+ else:
660
+ no_pattern += 1
661
+
662
+ total_handlers = len(cir.endpoints)
663
+ not_covered = len(escaping)
664
+ _patterns = (
665
+ filter_surface.patterns if filter_surface and not filter_surface.is_empty() else []
666
+ )
667
+ gate_coverage = {
668
+ "gate_annotations": sorted(gate_names),
669
+ "total_controller_handlers": total_handlers,
670
+ "gate_bearing": len(gate_bearers),
671
+ "not_carrying_gate": not_covered,
672
+ "interception_present": interception is not None,
673
+ "reconstructed_filter_patterns": sorted(_patterns),
674
+ "possibly_filter_covered": possibly_covered,
675
+ "no_matching_filter_pattern": no_pattern,
676
+ "confidence": (
677
+ Confidence.REQUIRES_MANUAL_REVIEW.value if not_covered else Confidence.VERIFIED.value
678
+ ),
679
+ "handlers_without_gate": sorted(
680
+ escaping, key=lambda d: (d["path"] or "", d["method"] or "")
681
+ )[:_GATE_COVERAGE_LIST_CAP],
682
+ "note": (
683
+ "Handlers that do not carry the detected custom gate and have no standard "
684
+ "guard. 'filter_pattern_match' marks those a declarative servlet filter "
685
+ "(@WebFilter / web.xml) pattern covers — still review, pattern presence is "
686
+ "not proof of enforcement. Spring FilterRegistrationBean / HttpSecurity DSL "
687
+ "/ AspectJ pointcuts are NOT reconstructed, so a missing match is not proof "
688
+ "of exposure."
689
+ ),
690
+ "truncated": not_covered > _GATE_COVERAGE_LIST_CAP,
691
+ }
692
+
598
693
  result = SecurityPostureResult(
599
694
  security_model=security_model,
600
695
  custom_gates=custom_gates,
@@ -602,6 +697,7 @@ def infer_security_posture(
602
697
  endpoint_verdicts=verdicts,
603
698
  rollup=rollup,
604
699
  limitations=limitations,
700
+ gate_coverage=gate_coverage,
605
701
  )
606
702
  # keep reference to suppress-set so callers (SEC-001) can dedupe false alarms
607
703
  result._gate_bearing_handlers = gate_bearers # type: ignore[attr-defined]
@@ -119,7 +119,7 @@ class ImpactChainResult:
119
119
  """
120
120
  schema_version: str = _SCHEMA_VERSION
121
121
  symbol: str = "" # resolved FQN (or original input if not_found)
122
- resolution: str = "not_found" # "exact" | "class_expanded" | "partial" | "not_found"
122
+ resolution: str = "not_found" # exact | class_expanded | method_resolved | partial | not_found
123
123
  direct_callers: list[str] = field(default_factory=list)
124
124
  indirect_callers: list[str] = field(default_factory=list)
125
125
  # BUG #2: count of own-class members dropped from callers (members, not callers).
@@ -378,12 +378,15 @@ def _resolve_symbol(
378
378
  4. Not found.
379
379
 
380
380
  Resolution values:
381
- "exact" — full FQN provided and matched exactly.
382
- "class_expanded" — short class name matched one class by suffix; all its
383
- symbols included. Confidence stays high.
384
- "partial" ambiguous (multiple classes matched) or method not found
385
- on matched class. Confidence degrades to medium.
386
- "not_found" — no match.
381
+ "exact" — full FQN provided and matched exactly.
382
+ "class_expanded" — short class name (no method) matched one class by suffix;
383
+ all its symbols included. Confidence stays high.
384
+ "method_resolved" short class name + method matched one method node by
385
+ suffix; scope is method-precise (SIM-4: not an expansion).
386
+ Confidence stays high.
387
+ "partial" — ambiguous (multiple classes matched) or method not found
388
+ on matched class. Confidence degrades to medium.
389
+ "not_found" — no match.
387
390
 
388
391
  Returns (resolution, matched_fqns, warnings).
389
392
  Returned list is always deduplicated (preserving order).
@@ -437,7 +440,12 @@ def _resolve_symbol(
437
440
  if f"{class_input}#{method_input}" in cir_symbols:
438
441
  resolution = "exact"
439
442
  elif len(method_matched_classes) == 1:
440
- resolution = "class_expanded"
443
+ # SIM-4: the caller named a method and it resolved to exactly one
444
+ # method node — the scope is method-precise, nothing was widened.
445
+ # "class_expanded" wrongly signalled "I broadened your query to the
446
+ # whole class" over a correct, narrow answer. Distinguish it from
447
+ # the genuine class-expansion case (no method given, line above).
448
+ resolution = "method_resolved"
441
449
  else:
442
450
  resolution = "partial"
443
451
  return resolution, method_matched, []
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sourcecode
3
- Version: 2.6.0
3
+ Version: 2.6.2
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,6 +1,6 @@
1
- sourcecode/__init__.py,sha256=s8y5X8alEKKmNOYZLr8jO6piwAcBclhVBLSr-OgnCW8,308
1
+ sourcecode/__init__.py,sha256=Xl4vKr6-4l-yWpoN7FzgqDyXd54m2LAMPUtDWX2CdD0,308
2
2
  sourcecode/adaptive_scanner.py,sha256=yJBKjNpkY6bpueYJ2YnRezen3sYZDecEt7WaaNWdqug,9466
3
- sourcecode/archetype.py,sha256=d7yoN6Tj4OBj-VgSMPEKg4WF9H8FypnZ5A6AkUh5oIE,34484
3
+ sourcecode/archetype.py,sha256=UrmiONHfqLl7UfGzjECoQBO28hcM_1rvzn0hCt0EtjU,35033
4
4
  sourcecode/architectural_baseline.py,sha256=7QzJri4pbL3nzAn9gNutZW6mZR8HHD6H2C2H1EEwYPE,17904
5
5
  sourcecode/architectural_delta.py,sha256=E6MkyjWl-1ZgR4KSKnI785gUAfc8nq-lK-jd_rupQBU,12496
6
6
  sourcecode/architecture_analyzer.py,sha256=GFc4ek-s1IHWM7pl-0L32WahZ93AmDgrAMcOuBKA5Dk,61463
@@ -13,12 +13,12 @@ sourcecode/canonical_ir.py,sha256=LdP_Ri3rl0M5p-MY0ypVvQwhq1WuUF1FmaHid7zyzEg,29
13
13
  sourcecode/change_plan.py,sha256=MNgNyu4zrLGvBBaXPwyCh-T2ZGaUQX3Hm4W87uuhZ3w,7750
14
14
  sourcecode/cir_graphs.py,sha256=9G0HHj1kw2325IDyzo2OpX73BNswEckecf4MZUXB4JM,12078
15
15
  sourcecode/classifier.py,sha256=JBzPwSSrDG-tUHAbcKB678HRbjLpD-ohzbzzO62mgpo,20114
16
- sourcecode/cli.py,sha256=9Uq_eC6eD01arQE3FLFUmTiZKPxmdbMx3_k3V74r-SI,349315
16
+ sourcecode/cli.py,sha256=kivJiIF7Rw-Eal-3_m2CG5E9i66UUuOLLyquLgUM0N0,359485
17
17
  sourcecode/code_notes_analyzer.py,sha256=EJemNCNc9Dn-1RZYu-aNbK0ELzmsyC4s6FdHi3XyNEI,9392
18
18
  sourcecode/compare.py,sha256=jdePg0dNCxFpysiTGMsROL5XABLjy_zVUgVeySvdb0o,7514
19
19
  sourcecode/confidence_analyzer.py,sha256=vnbPI-20FnHdjO6STxHW8fbaxmB4A7y58io63ibFZjc,21586
20
20
  sourcecode/constraint_diff.py,sha256=hWIO5vsxvHjsgQx23K3r_JMy1W6wQ2Ca4DqB9R_iMnY,5831
21
- sourcecode/context_cache.py,sha256=maws79rLKDwyZ7c9Q2LwlIxqZHRFlaKzErtO3mglWoA,25036
21
+ sourcecode/context_cache.py,sha256=bQiKlZKA-3xpvp1eWG6vOPADTl2YkYVIO434zGyl02c,26648
22
22
  sourcecode/context_graph.py,sha256=i2kOX3XiaCqcR20ojbUnqoX-hJbMyLNYc7rl-n15bwE,46426
23
23
  sourcecode/context_scorer.py,sha256=QpChSpsmaAYz91rXA4Ue5xzQmNz_ZboZN09YOHScq1U,14679
24
24
  sourcecode/context_summarizer.py,sha256=cI2TZMvEhl0BEma12VtPaX6z03ZVBAetVzuK5GaCOvg,6852
@@ -38,6 +38,7 @@ sourcecode/evidence_provider.py,sha256=GSSL44JEaouO5AHks2sB3d1YvC9xIKIld1yBYxZpX
38
38
  sourcecode/explain.py,sha256=HnHWVTNNf9fzeR3FP-A-eXKeKZvBcUEuODgIO3rJQkQ,22312
39
39
  sourcecode/file_chunker.py,sha256=3vkM3mDQ5eE_yTPvUgjyjpGFBIjkW6_mrBmIbrylnA8,16444
40
40
  sourcecode/file_classifier.py,sha256=A0fEABqtfVu1MfoaxnPAvGpZgneGgVXlJDhT74NYXxE,15314
41
+ sourcecode/filter_surface.py,sha256=qunis3-yY_nhOIj5K3mVFtbSyKfc6ugIX23OSxTolwE,7079
41
42
  sourcecode/format_contract.py,sha256=U83J_LtrpMQSN3_H_HBBxpanlo7xnfvbZw7sP3ew8_M,3678
42
43
  sourcecode/fqn_utils.py,sha256=XLU7zDkNBXz_RZkIUNfpPmp1nekWtqP-fxV92tDV1vg,2158
43
44
  sourcecode/git_analyzer.py,sha256=JStxTQXNjBWi_wLdwhsZs9mT-v50cSJIz4Agzn6Kh9I,13362
@@ -48,14 +49,14 @@ sourcecode/jdk_exports.py,sha256=fCrlwNAXUT9gge_joq6kMnY3zJxYB2pxqy-0w3o3MJI,874
48
49
  sourcecode/license.py,sha256=keFuwNxdAtvK2Ds91Wl79GMYxuxWYnN5Wbw1qBpaoUI,24896
49
50
  sourcecode/mcp_nudge.py,sha256=yjwclsBVsDg1BtocGdEi6GYV4vzPo-SBlC9pIcNmlDM,2910
50
51
  sourcecode/metrics_analyzer.py,sha256=m0ENgtqKeBL17kUIK3fmGkgo7UfXBNHxCMj0H_Y5K7c,22750
51
- sourcecode/migrate_check.py,sha256=HJhvHuvXDGTJSRQU9_AMx0L94h2TImC1DCGs3m3RTu0,108199
52
+ sourcecode/migrate_check.py,sha256=hsKNwipl-bSqRsYqG1O_CLbXpMlqtnN-Bif3hpHpnjg,108676
52
53
  sourcecode/openapi_surface.py,sha256=BTt0K-woZbkbWTN77IkqeBm_Okag9owR0848fmot8sk,16207
53
54
  sourcecode/output_budget.py,sha256=Js9yUlfQtPhqBl9R6wn_9UHVjjJc3GtLcqyfjf5t50Q,9869
54
55
  sourcecode/path_filters.py,sha256=qPKO7kRmVp2y9zjLNSuAVCbcpZrIInHE4QulLUlzPFI,10412
55
56
  sourcecode/perf.py,sha256=GAcEoouPIlPMCQIcHNToxK6K3WdIR-lj9aFg4prOYJI,9743
56
57
  sourcecode/pr_comment_renderer.py,sha256=KmcjMruhR44gjzMDJwjBSkWP9QEvh8xWBLyxzxoRbj0,14542
57
58
  sourcecode/pr_impact.py,sha256=dCDVw83EDbyVf6F9ZmEQmsFz8ruVH7d4mpeKQCIZHM0,16805
58
- sourcecode/prepare_context.py,sha256=-Xu33qCETuqw-OFfT3ukvAtxde7krCjQpn7wR3pbdOg,224290
59
+ sourcecode/prepare_context.py,sha256=GkdI_a0RG8Y8dh8x6SpCNKZwWi7qs8sqAbFSYJegBxY,225473
59
60
  sourcecode/progress.py,sha256=qn30sWaHOkjTgXsSBmiPkz7Rsbwc5oSlIe6JNEMYp_k,3149
60
61
  sourcecode/ranking_engine.py,sha256=ZAucq_YX2KkWUuAZf4P0lhtQ_38vEFnUhuGtSZd1S0E,12970
61
62
  sourcecode/reconciliation.py,sha256=GU-1PTcVr8zcbtC7BASfpHcZndP9AdboXPNQiBc0fzo,34251
@@ -63,13 +64,13 @@ sourcecode/redactor.py,sha256=SB4hwIvg8h-hvcqKcDWaZvA-aSyn-at-BIRwa0tUv5E,3227
63
64
  sourcecode/relevance_scorer.py,sha256=0AgEt4KrV73nioMqBgjhGjtY7L2C7L7cSyKtj3IKcrw,9408
64
65
  sourcecode/rename_refactor.py,sha256=h6dNFlB9aZ_3q6heeHBkgXQeXaT03nvPSsYH6P8qxFg,12965
65
66
  sourcecode/repo_classifier.py,sha256=FG1vaWKdWXsWdl-S8hjVMiTqcwgaRXkDyvK4rPcOGtQ,22681
66
- sourcecode/repository_ir.py,sha256=WLOdF5iaxjj3EwQWQHl_DVweL-JL8fEJpgQkQy4O90A,319009
67
+ sourcecode/repository_ir.py,sha256=9ZGRWy8g4_PH_XvdCm6i1UOHYX-qO3sxxodXytf3m0Q,320870
67
68
  sourcecode/ris.py,sha256=Hw8TakTQ6hku-Abf2k8954NwkrH_sP73_8wVH8x5khc,22079
68
69
  sourcecode/runtime_classifier.py,sha256=uTAD6BDCiBLUZEDRfqk718kM4RTT_vAbfkcOI2_Xx58,18432
69
70
  sourcecode/scanner.py,sha256=z3CV0rcGunu0Y8mpNgp07wI7nxT0pxw1BkXRRtI0Rpo,9609
70
71
  sourcecode/schema.py,sha256=aHNXDf8LGyUC8ZDE_VS9kiskC2-Oswhi_WnpdGy6HDw,24897
71
72
  sourcecode/security_config.py,sha256=KblMEoRiEjrIE68YsPaUAFebxFp8UM7MS7lAk5CGD8U,3531
72
- sourcecode/security_posture.py,sha256=CAJw4oJD0aAW2yRewvd_fonkzi9_HpUjBZlIDZle00s,26034
73
+ sourcecode/security_posture.py,sha256=NNmYanFQZQ_hIIeTGQnvTAETMT6hxO5I56Bez0ABbBg,30945
73
74
  sourcecode/semantic_analyzer.py,sha256=bpgdC6m0_ftVtRf3rSdwhbhWjnZnGxRXaZVcfe4BbcQ,95414
74
75
  sourcecode/semantic_impact_engine.py,sha256=t09IirGC3JjQDy33JZd1_WKzQVKXkoNl3-XEUr5kjis,20563
75
76
  sourcecode/semantic_integration_engine.py,sha256=rxoCuP-uM_Y4-ELeBN0sz1a88e2eI1XTJDgsS57IOLI,15513
@@ -77,7 +78,7 @@ sourcecode/semantic_services.py,sha256=nbUuPv-F01USTt_9CHT8iy_ucCIw3fz4W3Aquea_p
77
78
  sourcecode/serializer.py,sha256=zmN3pOcfxaQdxCE-jKoKudFjof9Sc_q7jpcBzNIA0EY,129493
78
79
  sourcecode/spring_event_topology.py,sha256=5_ON_21Le5zbG-1GRc5GLIi5HJfy_QjcXLVPC5WeUGQ,18055
79
80
  sourcecode/spring_findings.py,sha256=EX7kLZLN74CFyR9iZPm3CI115BfFKNd4WRPuErNljZM,5729
80
- sourcecode/spring_impact.py,sha256=MkUI27OCOhkjvBoA4f9X1eDBIY0SmyPuLVCWIfZgOcE,72430
81
+ sourcecode/spring_impact.py,sha256=ATOa7jOg7kL78vW0uZiGBvtB-ph9bCiEGH0kRtABHxg,73108
81
82
  sourcecode/spring_model.py,sha256=zOAgFmrRbG4a6KLm1TJl55aWMyPNsz3OS3FSczqPG6A,16594
82
83
  sourcecode/spring_security_audit.py,sha256=Rk-aSohezdc7YDYbSoJquVnwpkDB8ty1BCD-4Hc4R5A,22832
83
84
  sourcecode/spring_semantic.py,sha256=jteQ1PkY9ArFJv0embg_jBIdbOxqrk9mQ2Xz8OF_FKA,14214
@@ -144,8 +145,8 @@ sourcecode/telemetry/consent.py,sha256=LIAO9ohJZF8OuZwM4u1VWtALlYfTCCKq4wV3Vwc7i
144
145
  sourcecode/telemetry/events.py,sha256=LtzYfaX9Ilckj5PTvAcTpDa9mLqDsYPDUiDkRa58piY,2580
145
146
  sourcecode/telemetry/filters.py,sha256=NHa5T-6DaZduQPFuC34jOqHWQgSizM-Ygq8aZ4j19ng,5834
146
147
  sourcecode/telemetry/transport.py,sha256=4gGHsq0WeY9VywEZXA3vUxykfiYnw9uuqfjAAec7F8o,1681
147
- sourcecode-2.6.0.dist-info/METADATA,sha256=feEoNfPCAvBT9uJ-dVf63qVTJTGr8BfYtFOKKgO8KBE,10851
148
- sourcecode-2.6.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
149
- sourcecode-2.6.0.dist-info/entry_points.txt,sha256=-JEAdChrK5We51kZcb7OaDcyil-dHBjBPL-NhuO-QY8,89
150
- sourcecode-2.6.0.dist-info/licenses/LICENSE,sha256=7DdHrU9Z_3e7dSvq4ISijZNjnuHo5NIHNiHDouMQ9JU,10491
151
- sourcecode-2.6.0.dist-info/RECORD,,
148
+ sourcecode-2.6.2.dist-info/METADATA,sha256=7dMltG6Rgrg_RmRX5tEh13LJZIYv1ouwALVAcjHHY2I,10851
149
+ sourcecode-2.6.2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
150
+ sourcecode-2.6.2.dist-info/entry_points.txt,sha256=-JEAdChrK5We51kZcb7OaDcyil-dHBjBPL-NhuO-QY8,89
151
+ sourcecode-2.6.2.dist-info/licenses/LICENSE,sha256=7DdHrU9Z_3e7dSvq4ISijZNjnuHo5NIHNiHDouMQ9JU,10491
152
+ sourcecode-2.6.2.dist-info/RECORD,,