sourcecode 2.5.19__tar.gz → 2.5.20__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 (165) hide show
  1. {sourcecode-2.5.19 → sourcecode-2.5.20}/CHANGELOG.md +28 -0
  2. {sourcecode-2.5.19 → sourcecode-2.5.20}/PKG-INFO +1 -1
  3. {sourcecode-2.5.19 → sourcecode-2.5.20}/pyproject.toml +1 -1
  4. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/__init__.py +1 -1
  5. sourcecode-2.5.20/src/sourcecode/architectural_delta.py +147 -0
  6. sourcecode-2.5.20/src/sourcecode/change_plan.py +195 -0
  7. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/cli.py +261 -0
  8. sourcecode-2.5.20/src/sourcecode/contract_diff.py +186 -0
  9. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/format_contract.py +2 -0
  10. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/repository_ir.py +33 -5
  11. {sourcecode-2.5.19 → sourcecode-2.5.20}/.github/workflows/build-windows.yml +0 -0
  12. {sourcecode-2.5.19 → sourcecode-2.5.20}/.gitignore +0 -0
  13. {sourcecode-2.5.19 → sourcecode-2.5.20}/.ruff.toml +0 -0
  14. {sourcecode-2.5.19 → sourcecode-2.5.20}/CONTRIBUTING.md +0 -0
  15. {sourcecode-2.5.19 → sourcecode-2.5.20}/LICENSE +0 -0
  16. {sourcecode-2.5.19 → sourcecode-2.5.20}/README.md +0 -0
  17. {sourcecode-2.5.19 → sourcecode-2.5.20}/SECURITY.md +0 -0
  18. {sourcecode-2.5.19 → sourcecode-2.5.20}/raw +0 -0
  19. {sourcecode-2.5.19 → sourcecode-2.5.20}/scripts/compare_integration_engines.py +0 -0
  20. {sourcecode-2.5.19 → sourcecode-2.5.20}/scripts/customer_smoke_test.sh +0 -0
  21. {sourcecode-2.5.19 → sourcecode-2.5.20}/scripts/generate_jdk_exports.py +0 -0
  22. {sourcecode-2.5.19 → sourcecode-2.5.20}/scripts/perf_harness.py +0 -0
  23. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/adaptive_scanner.py +0 -0
  24. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/archetype.py +0 -0
  25. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/architecture_analyzer.py +0 -0
  26. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/architecture_summary.py +0 -0
  27. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/ast_extractor.py +0 -0
  28. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/cache.py +0 -0
  29. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/call_surface.py +0 -0
  30. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/caller_metrics.py +0 -0
  31. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/canonical_ir.py +0 -0
  32. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/cir_graphs.py +0 -0
  33. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/classifier.py +0 -0
  34. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/code_notes_analyzer.py +0 -0
  35. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/confidence_analyzer.py +0 -0
  36. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/context_cache.py +0 -0
  37. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/context_graph.py +0 -0
  38. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/context_scorer.py +0 -0
  39. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/context_summarizer.py +0 -0
  40. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/contract_model.py +0 -0
  41. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/contract_pipeline.py +0 -0
  42. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/coverage_parser.py +0 -0
  43. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/dependency_analyzer.py +0 -0
  44. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/detectors/__init__.py +0 -0
  45. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/detectors/base.py +0 -0
  46. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/detectors/csproj_parser.py +0 -0
  47. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/detectors/dart.py +0 -0
  48. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/detectors/dotnet.py +0 -0
  49. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/detectors/elixir.py +0 -0
  50. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/detectors/go.py +0 -0
  51. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/detectors/heuristic.py +0 -0
  52. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/detectors/hybrid.py +0 -0
  53. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/detectors/java.py +0 -0
  54. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/detectors/jvm_ext.py +0 -0
  55. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/detectors/nodejs.py +0 -0
  56. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/detectors/parsers.py +0 -0
  57. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/detectors/php.py +0 -0
  58. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/detectors/project.py +0 -0
  59. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/detectors/python.py +0 -0
  60. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/detectors/ruby.py +0 -0
  61. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/detectors/rust.py +0 -0
  62. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/detectors/systems.py +0 -0
  63. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/detectors/terraform.py +0 -0
  64. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/detectors/tooling.py +0 -0
  65. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/doc_analyzer.py +0 -0
  66. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/dynamic_argument_surface.py +0 -0
  67. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/endpoint_literals.py +0 -0
  68. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/endpoint_metrics.py +0 -0
  69. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/entrypoint_classifier.py +0 -0
  70. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/env_analyzer.py +0 -0
  71. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/error_schema.py +0 -0
  72. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/evidence_provider.py +0 -0
  73. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/explain.py +0 -0
  74. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/file_chunker.py +0 -0
  75. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/file_classifier.py +0 -0
  76. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/fqn_utils.py +0 -0
  77. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/git_analyzer.py +0 -0
  78. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/graph_analyzer.py +0 -0
  79. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/graph_evidence.py +0 -0
  80. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/hibernate_strat.py +0 -0
  81. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/jdk_exports.py +0 -0
  82. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/license.py +0 -0
  83. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/mcp/__init__.py +0 -0
  84. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/mcp/onboarding/__init__.py +0 -0
  85. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/mcp/onboarding/applier.py +0 -0
  86. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/mcp/onboarding/backup.py +0 -0
  87. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/mcp/onboarding/detector.py +0 -0
  88. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/mcp/onboarding/planner.py +0 -0
  89. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/mcp/orchestrator.py +0 -0
  90. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/mcp/registry.py +0 -0
  91. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/mcp/runner.py +0 -0
  92. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/mcp/server.py +0 -0
  93. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/mcp_nudge.py +0 -0
  94. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/metrics_analyzer.py +0 -0
  95. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/migrate_check.py +0 -0
  96. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/openapi_surface.py +0 -0
  97. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/output_budget.py +0 -0
  98. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/path_filters.py +0 -0
  99. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/perf.py +0 -0
  100. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/pr_comment_renderer.py +0 -0
  101. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/pr_impact.py +0 -0
  102. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/prepare_context.py +0 -0
  103. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/progress.py +0 -0
  104. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/ranking_engine.py +0 -0
  105. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/reconciliation.py +0 -0
  106. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/redactor.py +0 -0
  107. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/relevance_scorer.py +0 -0
  108. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/rename_refactor.py +0 -0
  109. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/repo_classifier.py +0 -0
  110. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/retrieval/__init__.py +0 -0
  111. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/retrieval/context.py +0 -0
  112. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/retrieval/errors.py +0 -0
  113. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/retrieval/executor.py +0 -0
  114. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/retrieval/planner.py +0 -0
  115. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/retrieval/query.py +0 -0
  116. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/retrieval/request.py +0 -0
  117. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/retrieval/resolution.py +0 -0
  118. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/retrieval/result.py +0 -0
  119. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/retrieval/retriever.py +0 -0
  120. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/retrieval/runtime.py +0 -0
  121. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/retrieval/steps.py +0 -0
  122. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/retrieval/steps_endpoint.py +0 -0
  123. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/retrieval/steps_graph.py +0 -0
  124. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/retrieval/steps_impact.py +0 -0
  125. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/retrieval/steps_intf.py +0 -0
  126. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/retrieval/steps_struct.py +0 -0
  127. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/retrieval/steps_txsec.py +0 -0
  128. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/ris.py +0 -0
  129. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/runtime_classifier.py +0 -0
  130. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/scanner.py +0 -0
  131. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/schema.py +0 -0
  132. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/security_config.py +0 -0
  133. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/security_posture.py +0 -0
  134. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/semantic_analyzer.py +0 -0
  135. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/semantic_impact_engine.py +0 -0
  136. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/semantic_integration_engine.py +0 -0
  137. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/semantic_services.py +0 -0
  138. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/serializer.py +0 -0
  139. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/spring_event_topology.py +0 -0
  140. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/spring_findings.py +0 -0
  141. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/spring_impact.py +0 -0
  142. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/spring_model.py +0 -0
  143. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/spring_security_audit.py +0 -0
  144. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/spring_semantic.py +0 -0
  145. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/spring_tx_analyzer.py +0 -0
  146. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/summarizer.py +0 -0
  147. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/telemetry/__init__.py +0 -0
  148. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/telemetry/config.py +0 -0
  149. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/telemetry/consent.py +0 -0
  150. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/telemetry/events.py +0 -0
  151. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/telemetry/filters.py +0 -0
  152. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/telemetry/transport.py +0 -0
  153. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/tree_utils.py +0 -0
  154. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/type_usage_surface.py +0 -0
  155. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/validation_inference.py +0 -0
  156. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/validation_surface.py +0 -0
  157. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/version_check.py +0 -0
  158. {sourcecode-2.5.19 → sourcecode-2.5.20}/src/sourcecode/workspace.py +0 -0
  159. {sourcecode-2.5.19 → sourcecode-2.5.20}/supabase/functions/README.md +0 -0
  160. {sourcecode-2.5.19 → sourcecode-2.5.20}/supabase/functions/get-license/index.ts +0 -0
  161. {sourcecode-2.5.19 → sourcecode-2.5.20}/supabase/functions/lemonsqueezy-webhook/index.ts +0 -0
  162. {sourcecode-2.5.19 → sourcecode-2.5.20}/supabase/functions/telemetry/index.ts +0 -0
  163. {sourcecode-2.5.19 → sourcecode-2.5.20}/supabase/sql/license_event_ordering.sql +0 -0
  164. {sourcecode-2.5.19 → sourcecode-2.5.20}/supabase/sql/licensing_schema.sql +0 -0
  165. {sourcecode-2.5.19 → sourcecode-2.5.20}/supabase/sql/telemetry_events.sql +0 -0
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.5.20] — 2026-07-21
4
+
5
+ **Engineering Decision Support — Part A, F3: JAX-RS composed-path canonicalization.**
6
+ Composed `@Path` templates built on a constant whose own value is a concatenation
7
+ (`ROOT_PATH = "/{" + DB_PATH_PARAM_NAME + "}/query/v2"`) produced malformed endpoint
8
+ paths — the class prefix collapsed to `/{`, yielding `/{`, `/{/tx`, `/{/tx/{txId}`.
9
+ Correct endpoint identity is the key of every impact / blast-radius / delta decision,
10
+ so a malformed path corrupts each of them. A pure extraction-correctness fix (no new
11
+ inference). Full suite (3545 passed, 5 skipped) with zero control-repo drift.
12
+
13
+ ### Fixed
14
+
15
+ - **F3 — `_collect_file_constants` now folds concat / constant-ref constant values.**
16
+ The collector captured only the FIRST string literal of a `static final String`
17
+ declaration (`_STATIC_FINAL_STR_RE`), so a constant defined as an expression
18
+ (`"/{" + DB_PATH_PARAM_NAME + "}/query/v2"`) was stored truncated as `/{`. Every
19
+ `@Path(SomeResource.ROOT_PATH)` class prefix built on it then collapsed, and each
20
+ composed method path inherited the collapse. The collector now captures the full
21
+ right-hand side (`_STATIC_FINAL_STR_DECL_RE`, up to `;`) and resolves it through
22
+ the existing `_resolve_const_concat`, iterating to a fixpoint so a constant defined
23
+ in terms of another (in either declaration order) resolves. Inline method-level
24
+ concat (`@Path("tx/{" + TX_ID_PATH_PARAM_NAME + "}")`) already resolved via
25
+ `_resolve_ann_path_expr`; this was the one remaining gap. Measured on neo4j
26
+ (`QueryResource`): 8 malformed paths → canonical (`/{databaseName}/query/v2`,
27
+ `/{databaseName}/query/v2/tx/{txId}/commit`). Fleet A/B: neo4j corrected, the other
28
+ 7 control repos byte-identical — the fix only ever *completes* a truncated value,
29
+ never rewrites a literal one.
30
+
3
31
  ## [2.5.19] — 2026-07-20
4
32
 
5
33
  **Engineering Decision Support — Part A, F2: manual validation (guard clauses) is
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sourcecode
3
- Version: 2.5.19
3
+ Version: 2.5.20
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.5.19"
7
+ version = "2.5.20"
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.5.19"
7
+ __version__ = "2.5.20"
@@ -0,0 +1,147 @@
1
+ """architectural_delta.py — D1 Architectural Delta (before/after outcome measurement).
2
+
3
+ Engineering Decision Support, Part B / D1. ASK measures repository *state*; this
4
+ module measures the *engineering outcome* of a change by diffing two already-built
5
+ canonical IR snapshots and reporting the DELTAS — files, symbols, endpoints,
6
+ dependency edges, HTTP-surface set changes, and per-symbol fan-in shifts.
7
+
8
+ Discipline (moat line): every number here is MEASURED and derived from the CIR
9
+ alone. This layer computes deltas and stops — it never opines "better/worse",
10
+ never assigns ROI, and never classifies a change as breaking/non-breaking (that is
11
+ D2's contract-diff, a distinct axis). A reviewer reads the deltas and decides.
12
+
13
+ Determinism: `extract_metrics` reads only the CIR; `diff_metrics` is a pure
14
+ function of two metric snapshots. Same two snapshots → byte-identical delta.
15
+
16
+ Increment scope (D1-a): counts + HTTP-surface set delta + fan-in shifts. Import
17
+ cycles and blast-radius deltas are deferred to a later increment (heavier compute,
18
+ own metric extractor) — omitted here rather than approximated.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ from dataclasses import dataclass
23
+ from typing import TYPE_CHECKING
24
+
25
+ if TYPE_CHECKING:
26
+ from sourcecode.canonical_ir import CanonicalRepositoryIR
27
+
28
+ # Frozen schema tag — versioned like perf-baseline-v1 so consumers can pin it.
29
+ ARCH_DELTA_SCHEMA: str = "architectural-delta-v1"
30
+
31
+ # Cap on the length of any per-item list in the payload (added/removed endpoints,
32
+ # fan-in shifts). The counts are always exact; the lists are a bounded sample.
33
+ _LIST_CAP: int = 50
34
+
35
+ # reverse_graph edge types that are NOT a dependency. `contained_in` is pure
36
+ # structural membership (a class "contains" its own methods/ctor) — counting it
37
+ # would make every class report a fan-in equal to its own member count (a
38
+ # controller nobody calls would read as fan-in 2). Every other type
39
+ # (calls/injects/extends/implements/imports/returns/instantiates/references) is a
40
+ # genuine reverse dependency and IS counted.
41
+ _FAN_IN_EXCLUDE_EDGE_TYPES: frozenset[str] = frozenset({"contained_in"})
42
+
43
+
44
+ def _fan_in_map(cir: "CanonicalRepositoryIR") -> dict[str, int]:
45
+ """symbol FQN → distinct reverse-dependant count, from the reverse graph.
46
+
47
+ reverse_graph is target → {edge_type → [dependant_fqn, ...]}. Fan-in is the
48
+ number of unique dependants across all DEPENDENCY edge types (a dependant that
49
+ both calls and injects a target counts once); structural containment is
50
+ excluded (see `_FAN_IN_EXCLUDE_EDGE_TYPES`)."""
51
+ out: dict[str, int] = {}
52
+ for target, by_type in (cir.reverse_graph or {}).items():
53
+ deps: set[str] = set()
54
+ for edge_type, dep_list in (by_type or {}).items():
55
+ if edge_type in _FAN_IN_EXCLUDE_EDGE_TYPES:
56
+ continue
57
+ deps.update(dep_list or ())
58
+ out[target] = len(deps)
59
+ return out
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class ArchMetrics:
64
+ """A deterministic architectural snapshot extracted from one CIR."""
65
+
66
+ cir_hash: str
67
+ file_count: int
68
+ symbol_count: int
69
+ endpoint_count: int
70
+ dependency_edge_count: int
71
+ endpoints: frozenset[tuple[str, str]] # (METHOD, path) — the HTTP contract surface
72
+ fan_in: dict[str, int] # symbol FQN → distinct caller count
73
+
74
+
75
+ def extract_metrics(cir: "CanonicalRepositoryIR") -> ArchMetrics:
76
+ """Project a CIR into its measured architectural metrics. Reads the CIR only."""
77
+ endpoints = frozenset(
78
+ (str(ep.method).upper(), str(ep.path)) for ep in (cir.endpoints or [])
79
+ )
80
+ return ArchMetrics(
81
+ cir_hash=cir.cir_hash,
82
+ file_count=len(cir.files or []),
83
+ symbol_count=len(cir.symbols or []),
84
+ endpoint_count=len(cir.endpoints or []),
85
+ dependency_edge_count=len(cir.dependencies or []),
86
+ endpoints=endpoints,
87
+ fan_in=_fan_in_map(cir),
88
+ )
89
+
90
+
91
+ def _count_block(base: int, head: int) -> dict:
92
+ return {"base": base, "head": head, "delta": head - base}
93
+
94
+
95
+ def diff_metrics(base: ArchMetrics, head: ArchMetrics) -> dict:
96
+ """Pure diff of two snapshots → the frozen architectural-delta-v1 payload.
97
+
98
+ Deterministic: added/removed endpoints are sorted; fan-in shifts are ranked by
99
+ magnitude then symbol name. Lists are capped; counts are exact."""
100
+ added = sorted(f"{m} {p}" for (m, p) in (head.endpoints - base.endpoints))
101
+ removed = sorted(f"{m} {p}" for (m, p) in (base.endpoints - head.endpoints))
102
+
103
+ # Fan-in shifts: any symbol whose distinct-caller count changed (present in
104
+ # either snapshot; absent = 0). Ranked by |delta| desc, then FQN asc.
105
+ shifts: list[dict] = []
106
+ for sym in set(base.fan_in) | set(head.fan_in):
107
+ b = base.fan_in.get(sym, 0)
108
+ h = head.fan_in.get(sym, 0)
109
+ if b != h:
110
+ shifts.append({"symbol": sym, "base": b, "head": h, "delta": h - b})
111
+ shifts.sort(key=lambda s: (-abs(s["delta"]), s["symbol"]))
112
+
113
+ return {
114
+ "schema": ARCH_DELTA_SCHEMA,
115
+ "base_cir_hash": base.cir_hash,
116
+ "head_cir_hash": head.cir_hash,
117
+ "totals": {
118
+ "files": _count_block(base.file_count, head.file_count),
119
+ "symbols": _count_block(base.symbol_count, head.symbol_count),
120
+ "endpoints": _count_block(base.endpoint_count, head.endpoint_count),
121
+ "dependency_edges": _count_block(
122
+ base.dependency_edge_count, head.dependency_edge_count
123
+ ),
124
+ },
125
+ "endpoint_surface": {
126
+ "added_count": len(added),
127
+ "removed_count": len(removed),
128
+ "added": added[:_LIST_CAP],
129
+ "removed": removed[:_LIST_CAP],
130
+ },
131
+ "fan_in_shifts": {
132
+ "changed_count": len(shifts),
133
+ "shifts": shifts[:_LIST_CAP],
134
+ },
135
+ "provenance": (
136
+ "architectural_delta (D1): measured deltas derived from the CIR of two "
137
+ "snapshots. No breaking/non-breaking classification (D2), no verdict, "
138
+ "no ROI — the reviewer weighs the numbers."
139
+ ),
140
+ }
141
+
142
+
143
+ def architectural_delta(
144
+ base_cir: "CanonicalRepositoryIR", head_cir: "CanonicalRepositoryIR"
145
+ ) -> dict:
146
+ """Convenience: extract both snapshots and diff them."""
147
+ return diff_metrics(extract_metrics(base_cir), extract_metrics(head_cir))
@@ -0,0 +1,195 @@
1
+ """change_plan.py — D3 Architectural planning artifact (no code generation).
2
+
3
+ Engineering Decision Support, Part B / D3. Given a change target, emits a
4
+ deterministic, code-free PLAN: what a change to that symbol touches — the affected
5
+ components (reverse-dependency closure), the tests that cover them, the endpoints
6
+ whose contract is in scope, the rollback surface (files involved), and a review
7
+ checklist. All of it is a PROJECTION of the graph + endpoint surface + test-path
8
+ membership.
9
+
10
+ Moat boundary (roadmap): this lists what to REVIEW; it emits no code, no
11
+ implementation sequence beyond dependency depth, and no recommendation. Every item
12
+ is a deterministic consequence of the model — "these components depend on X", not
13
+ "you should change X this way". The engineer/agent decides and writes.
14
+
15
+ Determinism: reads the CIR only; a bounded reverse-BFS with a fixed node cap and a
16
+ stable ordering → same repo + same target → same plan.
17
+
18
+ Reuse: target resolution + the endpoint blast come from `project_blast_radius`
19
+ (the same machinery `impact-chain`/`pr-impact` use, so the plan is consistent with
20
+ them). The affected-component / test / rollback views are derived from an uncapped
21
+ (node-bounded) reverse-dependency closure over the SAME reverse graph, using the
22
+ canonical `_all_callers_from_rg` traversal (skips structural containment + imports,
23
+ normalizes DI-injection owners).
24
+ """
25
+ from __future__ import annotations
26
+
27
+ from typing import TYPE_CHECKING
28
+
29
+ from sourcecode.canonical_ir import project_blast_radius
30
+ from sourcecode.repository_ir import _all_callers_from_rg
31
+ from sourcecode.security_posture import _graph_nodes
32
+ from sourcecode.path_filters import is_test_path
33
+
34
+ if TYPE_CHECKING:
35
+ from sourcecode.canonical_ir import CanonicalRepositoryIR
36
+
37
+ CHANGE_PLAN_SCHEMA: str = "change-plan-v1"
38
+
39
+ _LIST_CAP: int = 100
40
+ # Safety bound on the reverse closure so a hub target cannot blow up the traversal.
41
+ _NODE_CAP: int = 4000
42
+
43
+
44
+ def _dependents_closure(
45
+ reverse_graph: dict, seeds: list[str], max_depth: int, node_cap: int
46
+ ) -> dict[str, int]:
47
+ """Bounded reverse-dependency closure: dependant FQN → shallowest depth.
48
+
49
+ BFS over the reverse graph via the canonical `_all_callers_from_rg` traversal
50
+ (so containment/imports are excluded and DI owners normalized, exactly as
51
+ blast-radius/impact-chain compute reach). Seeds themselves are removed."""
52
+ affected: dict[str, int] = {}
53
+ seen: set[str] = set(seeds)
54
+ queue: list[tuple[str, int]] = [(s, 0) for s in seeds]
55
+ while queue and len(affected) < node_cap:
56
+ fqn, depth = queue.pop(0)
57
+ if depth >= max_depth:
58
+ continue
59
+ for dep in _all_callers_from_rg(fqn, reverse_graph):
60
+ if dep not in seen:
61
+ seen.add(dep)
62
+ affected[dep] = depth + 1
63
+ queue.append((dep, depth + 1))
64
+ for s in seeds:
65
+ affected.pop(s, None)
66
+ return affected
67
+
68
+
69
+ def _node_meta(cir: "CanonicalRepositoryIR") -> dict[str, dict]:
70
+ """fqn → {file, role, kind} for every graph node."""
71
+ out: dict[str, dict] = {}
72
+ for n in _graph_nodes(cir):
73
+ fqn = n.get("fqn")
74
+ if fqn:
75
+ out[str(fqn)] = {
76
+ "file": str(n.get("source_file") or ""),
77
+ "role": str(n.get("role") or "other"),
78
+ "kind": str(n.get("symbol_kind") or n.get("type") or ""),
79
+ }
80
+ return out
81
+
82
+
83
+ def build_change_plan(
84
+ cir: "CanonicalRepositoryIR", target: str, *, max_depth: int = 4
85
+ ) -> dict:
86
+ """Build the deterministic change plan for `target` over `cir`."""
87
+ blast = project_blast_radius(cir, target, max_depth=max_depth)
88
+ resolution = blast.get("resolution", "not_found")
89
+
90
+ # Unresolvable target → return a plan-shaped resolution notice (never crash).
91
+ if resolution in ("not_found", "ambiguous_path") or not blast.get("matched_fqns"):
92
+ return {
93
+ "schema": CHANGE_PLAN_SCHEMA,
94
+ "target": target,
95
+ "resolution": resolution,
96
+ "message": blast.get("message", f"Target {target!r} not found."),
97
+ "candidates": blast.get("candidates", []),
98
+ "affected_components": {"count": 0, "components": []},
99
+ "covering_tests": {"count": 0, "tests": []},
100
+ "affected_endpoints": {"count": 0, "endpoints": []},
101
+ "rollback_surface": {"file_count": 0, "files": []},
102
+ "review_checklist": [],
103
+ }
104
+
105
+ seeds = list(blast["matched_fqns"])
106
+ meta = _node_meta(cir)
107
+ affected = _dependents_closure(cir.reverse_graph or {}, seeds, max_depth, _NODE_CAP)
108
+
109
+ prod: list[dict] = []
110
+ tests: list[dict] = []
111
+ files: set[str] = set()
112
+ for fqn, depth in affected.items():
113
+ m = meta.get(fqn, {})
114
+ f = m.get("file", "")
115
+ files.add(f)
116
+ entry = {"symbol": fqn, "role": m.get("role", "other"),
117
+ "file": f, "depth": depth}
118
+ if is_test_path(f):
119
+ tests.append({"symbol": fqn, "file": f})
120
+ else:
121
+ prod.append(entry)
122
+ # Seed files are part of the rollback surface too (the target is being changed).
123
+ for s in seeds:
124
+ files.add(meta.get(s, {}).get("file", ""))
125
+ files.discard("")
126
+
127
+ prod.sort(key=lambda e: (e["depth"], e["symbol"]))
128
+ tests.sort(key=lambda e: e["symbol"])
129
+
130
+ endpoints_affected = blast.get("endpoints_affected", []) or []
131
+ ep_strings = sorted(
132
+ f"{e.get('method', '')} {e.get('path', '')}".strip()
133
+ for e in endpoints_affected if isinstance(e, dict)
134
+ )
135
+
136
+ rollback_files = sorted(files)
137
+
138
+ # Review checklist — structural facts phrased as review items, never advice.
139
+ checklist: list[str] = []
140
+ checklist.append(
141
+ f"Review {len(prod)} affected production component(s) reachable from the change."
142
+ )
143
+ if ep_strings:
144
+ checklist.append(
145
+ f"Re-verify the contract of {len(ep_strings)} endpoint(s) in the blast radius."
146
+ )
147
+ if tests:
148
+ checklist.append(
149
+ f"Run {len(tests)} covering test symbol(s) that reach the change."
150
+ )
151
+ else:
152
+ checklist.append(
153
+ "No covering tests reach the change — no existing test exercises this blast radius."
154
+ )
155
+ checklist.append(f"Rollback touches {len(rollback_files)} file(s).")
156
+ modules = blast.get("cross_module_impact") or []
157
+ if modules:
158
+ checklist.append(f"Change spans {len(modules)} module/subsystem(s).")
159
+ sec = blast.get("security_surface_affected") or []
160
+ if sec:
161
+ checklist.append(
162
+ f"{len(sec)} secured endpoint(s) in scope — re-check authorization."
163
+ )
164
+ txn = blast.get("transactional_boundaries_touched") or []
165
+ if txn:
166
+ checklist.append(f"{len(txn)} transactional boundary/boundaries in scope.")
167
+
168
+ return {
169
+ "schema": CHANGE_PLAN_SCHEMA,
170
+ "target": target,
171
+ "resolution": resolution,
172
+ "matched_symbols": sorted(seeds),
173
+ "affected_components": {
174
+ "count": len(prod),
175
+ "components": prod[:_LIST_CAP],
176
+ },
177
+ "covering_tests": {
178
+ "count": len(tests),
179
+ "tests": tests[:_LIST_CAP],
180
+ },
181
+ "affected_endpoints": {
182
+ "count": len(ep_strings),
183
+ "endpoints": ep_strings[:_LIST_CAP],
184
+ },
185
+ "rollback_surface": {
186
+ "file_count": len(rollback_files),
187
+ "files": rollback_files[:_LIST_CAP],
188
+ },
189
+ "review_checklist": checklist,
190
+ "provenance": (
191
+ "change_plan (D3): deterministic projection of the reverse-dependency "
192
+ "graph + endpoint surface + test-path membership. Lists what to review; "
193
+ "emits no code, no sequence beyond dependency depth, no recommendation."
194
+ ),
195
+ }
@@ -235,6 +235,10 @@ _SUBCOMMANDS: frozenset[str] = frozenset(
235
235
  "spring-audit",
236
236
  # Request-body validation surface
237
237
  "validation",
238
+ # D1 architectural delta (before/after outcome measurement)
239
+ "delta",
240
+ # D2 contract-break detection
241
+ "contract-diff",
238
242
  # Spring impact chain
239
243
  "impact-chain",
240
244
  # PR blast-radius report
@@ -4620,6 +4624,263 @@ def validation_cmd(
4620
4624
  _nudge()
4621
4625
 
4622
4626
 
4627
+ # ── D1 Architectural Delta ────────────────────────────────────────────────────
4628
+
4629
+
4630
+ @app.command("delta")
4631
+ def delta_cmd(
4632
+ base: Path = typer.Argument(
4633
+ ...,
4634
+ help="Repository checkout of the BEFORE state (base).",
4635
+ ),
4636
+ head: Path = typer.Argument(
4637
+ ...,
4638
+ help="Repository checkout of the AFTER state (head).",
4639
+ ),
4640
+ output_path: Optional[Path] = typer.Option(
4641
+ None, "--output", "-o",
4642
+ help="Write output to a file instead of stdout.",
4643
+ ),
4644
+ format: str = typer.Option(
4645
+ "json", "--format", "-f",
4646
+ help="Output format: json (default) or yaml.",
4647
+ show_default=True,
4648
+ ),
4649
+ copy: bool = typer.Option(
4650
+ False, "--copy", "-c",
4651
+ help="Copy output to system clipboard after a successful run.",
4652
+ ),
4653
+ ) -> None:
4654
+ """Measure the architectural OUTCOME of a change: diff two checkouts.
4655
+
4656
+ \b
4657
+ Builds the canonical IR for each checkout and reports the measured DELTAS —
4658
+ files, symbols, endpoints, dependency edges, the HTTP-surface set change
4659
+ (added/removed routes), and per-symbol fan-in shifts (e.g. "fan-in on
4660
+ QueryController 14→3").
4661
+
4662
+ \b
4663
+ This layer measures and stops. It emits no verdict, no ROI, and no
4664
+ breaking/non-breaking classification (that is a separate contract-diff) — a
4665
+ reviewer weighs the numbers. Deterministic: same two checkouts → same delta.
4666
+
4667
+ \b
4668
+ Examples:
4669
+ ask delta ./before ./after
4670
+ ask delta /tmp/base-worktree /tmp/head-worktree -o delta.json
4671
+ """
4672
+ _enforce_format("delta", format)
4673
+
4674
+ for label, target in (("base", base), ("head", head)):
4675
+ if not target.exists() or not target.is_dir():
4676
+ _emit_error_json(
4677
+ INVALID_INPUT_CODE,
4678
+ f"'{target}' ({label}) is not a valid directory.",
4679
+ path=str(target),
4680
+ hint="Pass two existing repository checkouts: ask delta <base> <head>.",
4681
+ expected="A directory path for each of base and head.",
4682
+ )
4683
+ raise typer.Exit(code=1)
4684
+
4685
+ from sourcecode.context_graph import ContextGraph
4686
+ from sourcecode.architectural_delta import architectural_delta
4687
+
4688
+ _prog = Progress()
4689
+ _prog.start("building base IR")
4690
+ _base_cir = ContextGraph.build_from_root(base.resolve()).cir
4691
+ _prog.update("building head IR")
4692
+ _head_cir = ContextGraph.build_from_root(head.resolve()).cir
4693
+ _prog.update("diffing snapshots")
4694
+ data = architectural_delta(_base_cir, _head_cir)
4695
+ _prog.finish()
4696
+
4697
+ _t = data["totals"]
4698
+ output = _serialize_dict(data, format)
4699
+ _emit_command_output(
4700
+ output, output_path, copy,
4701
+ success_msg=(
4702
+ f"Architectural delta written to {output_path} "
4703
+ f"(Δfiles {_t['files']['delta']:+d}, Δsymbols {_t['symbols']['delta']:+d}, "
4704
+ f"Δendpoints {_t['endpoints']['delta']:+d}, "
4705
+ f"{data['fan_in_shifts']['changed_count']} fan-in shifts)"
4706
+ ),
4707
+ )
4708
+
4709
+
4710
+ # ── D2 Contract-Break Detection ───────────────────────────────────────────────
4711
+
4712
+
4713
+ @app.command("contract-diff")
4714
+ def contract_diff_cmd(
4715
+ base: Path = typer.Argument(
4716
+ ...,
4717
+ help="Repository checkout of the BEFORE state (base).",
4718
+ ),
4719
+ head: Path = typer.Argument(
4720
+ ...,
4721
+ help="Repository checkout of the AFTER state (head).",
4722
+ ),
4723
+ output_path: Optional[Path] = typer.Option(
4724
+ None, "--output", "-o",
4725
+ help="Write output to a file instead of stdout.",
4726
+ ),
4727
+ format: str = typer.Option(
4728
+ "json", "--format", "-f",
4729
+ help="Output format: json (default) or yaml.",
4730
+ show_default=True,
4731
+ ),
4732
+ copy: bool = typer.Option(
4733
+ False, "--copy", "-c",
4734
+ help="Copy output to system clipboard after a successful run.",
4735
+ ),
4736
+ ) -> None:
4737
+ """Did this change break the public contract? Diff two checkouts.
4738
+
4739
+ \b
4740
+ Projects the public contract (HTTP endpoints + public method signatures) for
4741
+ each checkout and classifies every structural delta:
4742
+ * breaking — a removed endpoint/method, or a changed signature
4743
+ * additive — a new endpoint/method (backward compatible)
4744
+ * non_breaking — reserved for constraint-loosening analysis (not yet emitted)
4745
+
4746
+ \b
4747
+ Structural only: a changed signature is flagged breaking because the shape
4748
+ differs — behavioural equivalence is NOT claimed. No ROI, no quality verdict.
4749
+ Deterministic: same two checkouts → same result.
4750
+
4751
+ \b
4752
+ Examples:
4753
+ ask contract-diff ./before ./after
4754
+ ask contract-diff /tmp/base-worktree /tmp/head-worktree -o contract.json
4755
+ """
4756
+ _enforce_format("contract-diff", format)
4757
+
4758
+ for label, target in (("base", base), ("head", head)):
4759
+ if not target.exists() or not target.is_dir():
4760
+ _emit_error_json(
4761
+ INVALID_INPUT_CODE,
4762
+ f"'{target}' ({label}) is not a valid directory.",
4763
+ path=str(target),
4764
+ hint="Pass two existing repository checkouts: ask contract-diff <base> <head>.",
4765
+ expected="A directory path for each of base and head.",
4766
+ )
4767
+ raise typer.Exit(code=1)
4768
+
4769
+ from sourcecode.context_graph import ContextGraph
4770
+ from sourcecode.contract_diff import contract_diff as _contract_diff
4771
+
4772
+ _prog = Progress()
4773
+ _prog.start("building base IR")
4774
+ _base_cir = ContextGraph.build_from_root(base.resolve()).cir
4775
+ _prog.update("building head IR")
4776
+ _head_cir = ContextGraph.build_from_root(head.resolve()).cir
4777
+ _prog.update("diffing contracts")
4778
+ data = _contract_diff(_base_cir, _head_cir)
4779
+ _prog.finish()
4780
+
4781
+ _s = data["summary"]
4782
+ output = _serialize_dict(data, format)
4783
+ _emit_command_output(
4784
+ output, output_path, copy,
4785
+ success_msg=(
4786
+ f"Contract diff written to {output_path} "
4787
+ f"(status: {data['contract_status']}; "
4788
+ f"{_s['breaking']} breaking, {_s['additive']} additive)"
4789
+ ),
4790
+ )
4791
+
4792
+
4793
+ # ── D3 Change Plan ────────────────────────────────────────────────────────────
4794
+
4795
+
4796
+ @app.command("plan")
4797
+ def plan_cmd(
4798
+ target: str = typer.Argument(
4799
+ ...,
4800
+ help="Change target: class name (simple or FQN) or file path. "
4801
+ "Examples: UserService, org.example.UserService, UserService.java",
4802
+ ),
4803
+ path: Path = typer.Argument(
4804
+ Path("."),
4805
+ help="Repository root to analyze (default: current directory).",
4806
+ ),
4807
+ depth: int = typer.Option(
4808
+ 4, "--depth",
4809
+ help="Reverse-dependency BFS depth (default: 4).",
4810
+ min=1, max=8,
4811
+ ),
4812
+ output_path: Optional[Path] = typer.Option(
4813
+ None, "--output", "-o",
4814
+ help="Write output to a file instead of stdout.",
4815
+ ),
4816
+ format: str = typer.Option(
4817
+ "json", "--format", "-f",
4818
+ help="Output format: json (default) or yaml.",
4819
+ show_default=True,
4820
+ ),
4821
+ copy: bool = typer.Option(
4822
+ False, "--copy", "-c",
4823
+ help="Copy output to system clipboard after a successful run.",
4824
+ ),
4825
+ ) -> None:
4826
+ """Plan a change: what to review to implement a change to a target (no code).
4827
+
4828
+ \b
4829
+ Projects a deterministic, code-free plan from the dependency graph:
4830
+ * affected components — the reverse-dependency closure of the target
4831
+ * covering tests — existing tests that reach the change
4832
+ * affected endpoints — routes whose contract is in the blast radius
4833
+ * rollback surface — the files a change/rollback touches
4834
+ * review checklist — structural review items (facts, not advice)
4835
+
4836
+ \b
4837
+ Emits no code and no implementation sequence beyond dependency depth. Every
4838
+ item is a deterministic consequence of the model. Deterministic: same repo +
4839
+ target → same plan.
4840
+
4841
+ \b
4842
+ Examples:
4843
+ ask plan UserService .
4844
+ ask plan org.example.OrderService ./repo --depth 3
4845
+ """
4846
+ _enforce_format("plan", format)
4847
+
4848
+ target_dir = path.resolve()
4849
+ if not target_dir.exists() or not target_dir.is_dir():
4850
+ _emit_error_json(
4851
+ INVALID_INPUT_CODE,
4852
+ f"'{target_dir}' is not a valid directory.",
4853
+ path=str(target_dir),
4854
+ hint="Pass an existing repository directory: ask plan <target> <path>.",
4855
+ expected="A directory path.",
4856
+ )
4857
+ raise typer.Exit(code=1)
4858
+
4859
+ from sourcecode.context_graph import ContextGraph
4860
+ from sourcecode.change_plan import build_change_plan
4861
+
4862
+ _prog = Progress()
4863
+ _prog.start("building IR")
4864
+ _cir = ContextGraph.build_from_root(target_dir).cir
4865
+ _prog.update("planning change")
4866
+ data = build_change_plan(_cir, target, max_depth=depth)
4867
+ _prog.finish()
4868
+
4869
+ if data.get("resolution") in ("not_found", "ambiguous_path"):
4870
+ typer.echo(f"Note: {data.get('message', 'target not resolved')}", err=True)
4871
+
4872
+ output = _serialize_dict(data, format)
4873
+ _emit_command_output(
4874
+ output, output_path, copy,
4875
+ success_msg=(
4876
+ f"Change plan written to {output_path} "
4877
+ f"({data['affected_components']['count']} components, "
4878
+ f"{data['covering_tests']['count']} covering tests, "
4879
+ f"{data['affected_endpoints']['count']} endpoints)"
4880
+ ),
4881
+ )
4882
+
4883
+
4623
4884
  # ── Spring Semantic Audit ─────────────────────────────────────────────────────
4624
4885
 
4625
4886