sourcecode 2.6.1__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.1"
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(