sourcecode 2.6.0__tar.gz → 2.6.2__tar.gz

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.
Files changed (169) hide show
  1. {sourcecode-2.6.0 → sourcecode-2.6.2}/CHANGELOG.md +64 -0
  2. {sourcecode-2.6.0 → sourcecode-2.6.2}/PKG-INFO +1 -1
  3. {sourcecode-2.6.0 → sourcecode-2.6.2}/pyproject.toml +1 -1
  4. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/__init__.py +1 -1
  5. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/archetype.py +12 -1
  6. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/cli.py +229 -38
  7. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/context_cache.py +40 -0
  8. sourcecode-2.6.2/src/sourcecode/filter_surface.py +173 -0
  9. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/migrate_check.py +10 -0
  10. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/prepare_context.py +21 -1
  11. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/repository_ir.py +32 -5
  12. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/security_posture.py +96 -0
  13. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/spring_impact.py +16 -8
  14. {sourcecode-2.6.0 → sourcecode-2.6.2}/.github/workflows/build-windows.yml +0 -0
  15. {sourcecode-2.6.0 → sourcecode-2.6.2}/.gitignore +0 -0
  16. {sourcecode-2.6.0 → sourcecode-2.6.2}/.ruff.toml +0 -0
  17. {sourcecode-2.6.0 → sourcecode-2.6.2}/CONTRIBUTING.md +0 -0
  18. {sourcecode-2.6.0 → sourcecode-2.6.2}/LICENSE +0 -0
  19. {sourcecode-2.6.0 → sourcecode-2.6.2}/README.md +0 -0
  20. {sourcecode-2.6.0 → sourcecode-2.6.2}/SECURITY.md +0 -0
  21. {sourcecode-2.6.0 → sourcecode-2.6.2}/raw +0 -0
  22. {sourcecode-2.6.0 → sourcecode-2.6.2}/scripts/compare_integration_engines.py +0 -0
  23. {sourcecode-2.6.0 → sourcecode-2.6.2}/scripts/customer_smoke_test.sh +0 -0
  24. {sourcecode-2.6.0 → sourcecode-2.6.2}/scripts/generate_jdk_exports.py +0 -0
  25. {sourcecode-2.6.0 → sourcecode-2.6.2}/scripts/perf_harness.py +0 -0
  26. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/adaptive_scanner.py +0 -0
  27. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/architectural_baseline.py +0 -0
  28. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/architectural_delta.py +0 -0
  29. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/architecture_analyzer.py +0 -0
  30. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/architecture_summary.py +0 -0
  31. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/ast_extractor.py +0 -0
  32. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/cache.py +0 -0
  33. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/call_surface.py +0 -0
  34. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/caller_metrics.py +0 -0
  35. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/canonical_ir.py +0 -0
  36. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/change_plan.py +0 -0
  37. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/cir_graphs.py +0 -0
  38. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/classifier.py +0 -0
  39. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/code_notes_analyzer.py +0 -0
  40. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/compare.py +0 -0
  41. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/confidence_analyzer.py +0 -0
  42. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/constraint_diff.py +0 -0
  43. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/context_graph.py +0 -0
  44. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/context_scorer.py +0 -0
  45. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/context_summarizer.py +0 -0
  46. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/contract_diff.py +0 -0
  47. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/contract_model.py +0 -0
  48. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/contract_pipeline.py +0 -0
  49. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/coverage_parser.py +0 -0
  50. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/dependency_analyzer.py +0 -0
  51. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/detectors/__init__.py +0 -0
  52. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/detectors/base.py +0 -0
  53. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/detectors/csproj_parser.py +0 -0
  54. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/detectors/dart.py +0 -0
  55. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/detectors/dotnet.py +0 -0
  56. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/detectors/elixir.py +0 -0
  57. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/detectors/go.py +0 -0
  58. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/detectors/heuristic.py +0 -0
  59. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/detectors/hybrid.py +0 -0
  60. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/detectors/java.py +0 -0
  61. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/detectors/jvm_ext.py +0 -0
  62. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/detectors/nodejs.py +0 -0
  63. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/detectors/parsers.py +0 -0
  64. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/detectors/php.py +0 -0
  65. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/detectors/project.py +0 -0
  66. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/detectors/python.py +0 -0
  67. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/detectors/ruby.py +0 -0
  68. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/detectors/rust.py +0 -0
  69. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/detectors/systems.py +0 -0
  70. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/detectors/terraform.py +0 -0
  71. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/detectors/tooling.py +0 -0
  72. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/doc_analyzer.py +0 -0
  73. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/dynamic_argument_surface.py +0 -0
  74. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/endpoint_literals.py +0 -0
  75. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/endpoint_metrics.py +0 -0
  76. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/entrypoint_classifier.py +0 -0
  77. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/env_analyzer.py +0 -0
  78. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/error_schema.py +0 -0
  79. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/evidence_provider.py +0 -0
  80. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/explain.py +0 -0
  81. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/file_chunker.py +0 -0
  82. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/file_classifier.py +0 -0
  83. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/format_contract.py +0 -0
  84. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/fqn_utils.py +0 -0
  85. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/git_analyzer.py +0 -0
  86. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/graph_analyzer.py +0 -0
  87. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/graph_evidence.py +0 -0
  88. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/hibernate_strat.py +0 -0
  89. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/jdk_exports.py +0 -0
  90. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/license.py +0 -0
  91. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/mcp/__init__.py +0 -0
  92. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/mcp/onboarding/__init__.py +0 -0
  93. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/mcp/onboarding/applier.py +0 -0
  94. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/mcp/onboarding/backup.py +0 -0
  95. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/mcp/onboarding/detector.py +0 -0
  96. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/mcp/onboarding/planner.py +0 -0
  97. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/mcp/orchestrator.py +0 -0
  98. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/mcp/registry.py +0 -0
  99. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/mcp/runner.py +0 -0
  100. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/mcp/server.py +0 -0
  101. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/mcp_nudge.py +0 -0
  102. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/metrics_analyzer.py +0 -0
  103. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/openapi_surface.py +0 -0
  104. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/output_budget.py +0 -0
  105. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/path_filters.py +0 -0
  106. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/perf.py +0 -0
  107. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/pr_comment_renderer.py +0 -0
  108. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/pr_impact.py +0 -0
  109. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/progress.py +0 -0
  110. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/ranking_engine.py +0 -0
  111. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/reconciliation.py +0 -0
  112. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/redactor.py +0 -0
  113. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/relevance_scorer.py +0 -0
  114. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/rename_refactor.py +0 -0
  115. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/repo_classifier.py +0 -0
  116. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/retrieval/__init__.py +0 -0
  117. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/retrieval/context.py +0 -0
  118. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/retrieval/errors.py +0 -0
  119. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/retrieval/executor.py +0 -0
  120. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/retrieval/planner.py +0 -0
  121. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/retrieval/query.py +0 -0
  122. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/retrieval/request.py +0 -0
  123. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/retrieval/resolution.py +0 -0
  124. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/retrieval/result.py +0 -0
  125. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/retrieval/retriever.py +0 -0
  126. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/retrieval/runtime.py +0 -0
  127. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/retrieval/steps.py +0 -0
  128. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/retrieval/steps_endpoint.py +0 -0
  129. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/retrieval/steps_graph.py +0 -0
  130. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/retrieval/steps_impact.py +0 -0
  131. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/retrieval/steps_intf.py +0 -0
  132. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/retrieval/steps_struct.py +0 -0
  133. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/retrieval/steps_txsec.py +0 -0
  134. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/ris.py +0 -0
  135. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/runtime_classifier.py +0 -0
  136. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/scanner.py +0 -0
  137. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/schema.py +0 -0
  138. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/security_config.py +0 -0
  139. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/semantic_analyzer.py +0 -0
  140. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/semantic_impact_engine.py +0 -0
  141. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/semantic_integration_engine.py +0 -0
  142. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/semantic_services.py +0 -0
  143. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/serializer.py +0 -0
  144. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/spring_event_topology.py +0 -0
  145. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/spring_findings.py +0 -0
  146. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/spring_model.py +0 -0
  147. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/spring_security_audit.py +0 -0
  148. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/spring_semantic.py +0 -0
  149. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/spring_tx_analyzer.py +0 -0
  150. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/summarizer.py +0 -0
  151. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/telemetry/__init__.py +0 -0
  152. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/telemetry/config.py +0 -0
  153. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/telemetry/consent.py +0 -0
  154. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/telemetry/events.py +0 -0
  155. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/telemetry/filters.py +0 -0
  156. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/telemetry/transport.py +0 -0
  157. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/tree_utils.py +0 -0
  158. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/type_usage_surface.py +0 -0
  159. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/validation_inference.py +0 -0
  160. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/validation_surface.py +0 -0
  161. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/version_check.py +0 -0
  162. {sourcecode-2.6.0 → sourcecode-2.6.2}/src/sourcecode/workspace.py +0 -0
  163. {sourcecode-2.6.0 → sourcecode-2.6.2}/supabase/functions/README.md +0 -0
  164. {sourcecode-2.6.0 → sourcecode-2.6.2}/supabase/functions/get-license/index.ts +0 -0
  165. {sourcecode-2.6.0 → sourcecode-2.6.2}/supabase/functions/lemonsqueezy-webhook/index.ts +0 -0
  166. {sourcecode-2.6.0 → sourcecode-2.6.2}/supabase/functions/telemetry/index.ts +0 -0
  167. {sourcecode-2.6.0 → sourcecode-2.6.2}/supabase/sql/license_event_ordering.sql +0 -0
  168. {sourcecode-2.6.0 → sourcecode-2.6.2}/supabase/sql/licensing_schema.sql +0 -0
  169. {sourcecode-2.6.0 → sourcecode-2.6.2}/supabase/sql/telemetry_events.sql +0 -0
@@ -1,5 +1,69 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.6.2] — 2026-07-23
4
+
5
+ **Field-eval Fase 3 — cache reuse (C) + cold-scan honesty (E). No
6
+ functional-contract change; both are safe, measured additions.** Suite:
7
+ **3679 passed, 5 skipped, 1 deselected** (the deselected chunker test is a
8
+ pre-existing external-fixture flake).
9
+
10
+ ### Changed
11
+
12
+ - **C — `review-pr` reuses the shared repo-wide CIR when it is already warm.**
13
+ The `review-pr` behavioral-impact path builds a *deliberately bounded* scope
14
+ (changed files + sibling directories, git-first, no full-repo traversal — the
15
+ bound is what keeps it fast). It now consults the shared Canonical IR through
16
+ a new get-only `context_cache.peek_cir`: on a warm cache the impact engine
17
+ runs over the full repo-wide graph for free (strictly more callers — closes
18
+ the DI-mediated blind spot) at O(1); on a miss it keeps its own scoped build.
19
+ `peek_cir` **never builds and never writes**, so a scoped caller can never
20
+ poison the repo-wide knowledge key that `explain`/`impact` read from — the
21
+ reason the building `get_or_build_cir` was the wrong tool for this path.
22
+
23
+ ### Added
24
+
25
+ - **E — cold-scan advisory on large repositories.** A cache hit short-circuits
26
+ before the file scan, so a full *cold* scan is where the wait lives. On a
27
+ large repo (≥ `_LARGE_REPO_ADVISORY_FILES` files) the CLI now prints a one-line
28
+ advisory that cold analysis scales with repo size and is cached afterwards.
29
+ Honest by construction: it states the *shape* of the cost, never an absolute
30
+ latency, and is **TTY-gated** so it never reaches stdout (JSON) or an agent.
31
+
32
+ ## [2.6.1] — 2026-07-22
33
+
34
+ **Stability patch — extraction/label fidelity on the 2.6.x line. No new
35
+ capability, no functional-contract change.** Two isolated corrections where the
36
+ system produced a correct result but described it imperfectly (P1-H) or gave a
37
+ provably-correct answer a misleading label (SIM-4). `2.6.0` remains the stable
38
+ baseline; this is a fidelity patch on top of it. Full suite: **3637 passed, 5
39
+ skipped, 1 deselected** (the deselected chunker test is a pre-existing external
40
+ fixture flake).
41
+
42
+ ### Fixed
43
+
44
+ - **P1-H — lowercase-initial type declarations were silently dropped.**
45
+ `_CLASS_DECL_RE` assumed the PascalCase convention (`[A-Z]\w*`) for the
46
+ type-name start, so valid lowercase-initial Java types never entered the
47
+ symbol graph (e.g. `iFieldMetadata`, `i18nUpdateCartServiceExtensionHandler`;
48
+ JNA C-struct mirrors `passwd`/`group`/`pam_*`). The same assumption was
49
+ duplicated in the multi-line-join gate, so a lowercase declaration whose brace
50
+ sat on a continuation line stayed lost even after widening the primary regex.
51
+ Both name-start charsets widened to `[A-Za-z]\w*`, and a length-preserving
52
+ string-literal blanking guard was added at the declaration-scan sites so a
53
+ `class`/`interface` keyword appearing inside a string literal (e.g. a log
54
+ message `"... advice class for {} ..."`) can no longer mint a phantom type.
55
+ Fleet A/B over six real repositories: +9 real types recovered, zero phantoms,
56
+ zero symbols lost, control repositories byte-stable.
57
+ - **SIM-4 — a method-precise resolution was labelled `class_expanded`.** When a
58
+ `Class#method` query matched one class by short/suffix name and its method
59
+ resolved to exactly one method node, `impact-chain` returned the correct
60
+ method-scoped result but tagged it `class_expanded` — signalling "I broadened
61
+ your query to the whole class" over a narrow, correct answer. A distinct
62
+ resolution value `method_resolved` now describes that case (confidence stays
63
+ high; nothing was widened); the genuine no-method class-expansion case keeps
64
+ `class_expanded`. No behavioural change — the resolved symbol set, confidence,
65
+ and callers are identical; only the exposed label changed to match reality.
66
+
3
67
  ## [2.6.0] — 2026-07-22
4
68
 
5
69
  **Engineering Decision Support — Part B (capability milestone) lands, plus the
@@ -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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sourcecode"
7
- version = "2.6.0"
7
+ version = "2.6.2"
8
8
  description = "Persistent structural context and ultra-fast repeated analysis for AI coding agents"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -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"
@@ -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"
@@ -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
  # ---------------------------------------------------------------------------