sourcecode 2.5.18__py3-none-any.whl → 2.5.20__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.

Potentially problematic release.


This version of sourcecode might be problematic. Click here for more details.

sourcecode/__init__.py CHANGED
@@ -4,4 +4,4 @@ ASK Engine is the product. ``ask`` is the canonical CLI command; ``sourcecode``
4
4
  the legacy compatibility alias and the Python/PyPI package name. See
5
5
  docs/PRODUCT_IDENTITY.md (normative)."""
6
6
 
7
- __version__ = "2.5.18"
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
+ }
sourcecode/cli.py CHANGED
@@ -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
 
@@ -0,0 +1,186 @@
1
+ """contract_diff.py — D2 Compatibility & behavioural-contract break detection.
2
+
3
+ Engineering Decision Support, Part B / D2. A change that COMPILES can still break
4
+ the public contract — a removed endpoint, a renamed public method, a changed
5
+ signature. This module projects a `public-contract` view from two CIR snapshots
6
+ and classifies each structural delta as breaking / additive (with a `non_breaking`
7
+ bucket reserved for the D2-b constraint-loosening analysis).
8
+
9
+ Honesty boundary (roadmap): this reports STRUCTURAL contract deltas only. It does
10
+ NOT prove behavioural equivalence — a "signature changed" is flagged breaking
11
+ because the shape differs, not because we proved a caller breaks; and a `non_breaking`
12
+ verdict is never invented from a structural change we cannot prove is safe. It is a
13
+ diff, not a semantic-compatibility prover.
14
+
15
+ Determinism: `extract_public_contract` reads the CIR only; `diff_contract` is a
16
+ pure function of two contract snapshots; same two snapshots → byte-identical output.
17
+
18
+ Contract surface (increment D2-a):
19
+ * endpoints — the HTTP surface, keyed (METHOD, path); value = handler
20
+ signature. The route IS the contract, so a handler rename
21
+ with an identical signature is NOT a contract change.
22
+ * public methods — public, non-test methods/constructors, keyed by FQN; value
23
+ = signature. Endpoint handlers are excluded here (their
24
+ contract is the route, already covered above — no double count).
25
+
26
+ Deferred to D2-b: validation-constraint tightening/loosening (the source of most
27
+ real `non_breaking` verdicts) and parameter optionalization. The `non_breaking`
28
+ bucket is declared now so consumers can rely on the taxonomy; it is populated when
29
+ that analysis lands.
30
+ """
31
+ from __future__ import annotations
32
+
33
+ from dataclasses import dataclass
34
+ from typing import TYPE_CHECKING
35
+
36
+ from sourcecode.path_filters import is_test_path
37
+ from sourcecode.security_posture import _graph_nodes
38
+
39
+ if TYPE_CHECKING:
40
+ from sourcecode.canonical_ir import CanonicalRepositoryIR
41
+
42
+ CONTRACT_DIFF_SCHEMA: str = "contract-diff-v1"
43
+
44
+ _LIST_CAP: int = 50
45
+
46
+ # Callable member kinds that form the public API surface. `endpoint` is excluded
47
+ # on purpose — an HTTP handler's contract is its ROUTE (covered by the endpoint
48
+ # surface), not its Java FQN, so listing it here too would double-report.
49
+ _API_METHOD_KINDS: frozenset[str] = frozenset({"method", "constructor"})
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class PublicContract:
54
+ """The externally-observable contract projected from one CIR."""
55
+
56
+ # (METHOD, path) → handler signature string, e.g. "(String)->String"
57
+ endpoints: dict[tuple[str, str], str]
58
+ # public method/ctor FQN → signature string
59
+ public_methods: dict[str, str]
60
+
61
+
62
+ def _node_signatures(cir: "CanonicalRepositoryIR") -> dict[str, str]:
63
+ """fqn → signature for every graph node (used to resolve handler signatures)."""
64
+ return {
65
+ str(n.get("fqn")): str(n.get("signature") or "")
66
+ for n in _graph_nodes(cir)
67
+ if n.get("fqn")
68
+ }
69
+
70
+
71
+ def extract_public_contract(cir: "CanonicalRepositoryIR") -> PublicContract:
72
+ """Project a CIR into its public contract. Reads the CIR only."""
73
+ sig_by_fqn = _node_signatures(cir)
74
+
75
+ endpoints: dict[tuple[str, str], str] = {}
76
+ for ep in (cir.endpoints or []):
77
+ key = (str(ep.method).upper(), str(ep.path))
78
+ endpoints[key] = sig_by_fqn.get(ep.handler_symbol, "")
79
+
80
+ public_methods: dict[str, str] = {}
81
+ for n in _graph_nodes(cir):
82
+ if n.get("type") != "method":
83
+ continue
84
+ if n.get("symbol_kind") not in _API_METHOD_KINDS:
85
+ continue
86
+ if "public" not in (n.get("modifiers") or []):
87
+ continue
88
+ if is_test_path(str(n.get("source_file") or "")):
89
+ continue
90
+ fqn = str(n.get("fqn") or "")
91
+ if fqn:
92
+ public_methods[fqn] = str(n.get("signature") or "")
93
+
94
+ return PublicContract(endpoints=endpoints, public_methods=public_methods)
95
+
96
+
97
+ def _finding(element_type: str, ident: str, change: str,
98
+ base_sig: str, head_sig: str, classification: str) -> dict:
99
+ out: dict = {
100
+ "element": element_type,
101
+ "id": ident,
102
+ "change": change,
103
+ "classification": classification,
104
+ }
105
+ if base_sig or head_sig:
106
+ out["base_signature"] = base_sig
107
+ out["head_signature"] = head_sig
108
+ return out
109
+
110
+
111
+ def diff_contract(base: PublicContract, head: PublicContract) -> dict:
112
+ """Pure diff of two contracts → the frozen contract-diff-v1 payload.
113
+
114
+ Classification (structural, conservative):
115
+ removed → breaking (the element is gone)
116
+ signature_changed → breaking (shape differs; behavioural compat NOT claimed)
117
+ added → additive (backward-compatible addition)
118
+ `non_breaking` stays empty until the D2-b constraint-loosening analysis lands.
119
+ """
120
+ breaking: list[dict] = []
121
+ additive: list[dict] = []
122
+ non_breaking: list[dict] = [] # reserved (D2-b)
123
+
124
+ # --- endpoints (keyed by METHOD path) ---
125
+ b_eps, h_eps = base.endpoints, head.endpoints
126
+ for key in b_eps.keys() - h_eps.keys():
127
+ breaking.append(_finding("endpoint", f"{key[0]} {key[1]}", "removed", "", "", "breaking"))
128
+ for key in h_eps.keys() - b_eps.keys():
129
+ additive.append(_finding("endpoint", f"{key[0]} {key[1]}", "added", "", "", "additive"))
130
+ for key in b_eps.keys() & h_eps.keys():
131
+ if b_eps[key] != h_eps[key]:
132
+ breaking.append(_finding(
133
+ "endpoint", f"{key[0]} {key[1]}", "signature_changed",
134
+ b_eps[key], h_eps[key], "breaking",
135
+ ))
136
+
137
+ # --- public methods (keyed by FQN) ---
138
+ b_m, h_m = base.public_methods, head.public_methods
139
+ for fqn in b_m.keys() - h_m.keys():
140
+ breaking.append(_finding("method", fqn, "removed", "", "", "breaking"))
141
+ for fqn in h_m.keys() - b_m.keys():
142
+ additive.append(_finding("method", fqn, "added", "", "", "additive"))
143
+ for fqn in b_m.keys() & h_m.keys():
144
+ if b_m[fqn] != h_m[fqn]:
145
+ breaking.append(_finding(
146
+ "method", fqn, "signature_changed", b_m[fqn], h_m[fqn], "breaking",
147
+ ))
148
+
149
+ breaking.sort(key=lambda f: (f["element"], f["change"], f["id"]))
150
+ additive.sort(key=lambda f: (f["element"], f["change"], f["id"]))
151
+
152
+ # Repo-level structural status: a measured fact (≥1 breaking change exists),
153
+ # not an ROI/quality opinion. "breaking" > "additive" > "unchanged".
154
+ if breaking:
155
+ status = "breaking"
156
+ elif additive:
157
+ status = "additive"
158
+ else:
159
+ status = "unchanged"
160
+
161
+ return {
162
+ "schema": CONTRACT_DIFF_SCHEMA,
163
+ "contract_status": status,
164
+ "summary": {
165
+ "breaking": len(breaking),
166
+ "additive": len(additive),
167
+ "non_breaking": len(non_breaking),
168
+ },
169
+ "breaking": breaking[:_LIST_CAP],
170
+ "additive": additive[:_LIST_CAP],
171
+ "non_breaking": non_breaking,
172
+ "provenance": (
173
+ "contract_diff (D2): STRUCTURAL public-contract diff of two CIR "
174
+ "snapshots (endpoints + public methods). A signature change is flagged "
175
+ "breaking because the shape differs — behavioural equivalence is NOT "
176
+ "claimed. non_breaking (constraint loosening / param optionalization) "
177
+ "is reserved for D2-b. No ROI, no quality verdict."
178
+ ),
179
+ }
180
+
181
+
182
+ def contract_diff(
183
+ base_cir: "CanonicalRepositoryIR", head_cir: "CanonicalRepositoryIR"
184
+ ) -> dict:
185
+ """Convenience: extract both contracts and diff them."""
186
+ return diff_contract(extract_public_contract(base_cir), extract_public_contract(head_cir))
@@ -29,6 +29,8 @@ FORMAT_REGISTRY: "dict[str, tuple[str, ...]]" = {
29
29
  "endpoints": ("json", "yaml"),
30
30
  "export": ("json", "yaml"),
31
31
  "validation": ("json", "yaml"),
32
+ "delta": ("json", "yaml"),
33
+ "contract-diff": ("json", "yaml"),
32
34
  "impact-chain": ("json", "yaml"),
33
35
  "pr-impact": ("text", "json"),
34
36
  "migrate-check": ("json", "text"),
@@ -190,6 +190,13 @@ _STATIC_FINAL_STR_RE = re.compile(
190
190
  r'(?:public|protected|private)?\s*static\s+final\s+String\s+(\w+)\s*=\s*"([^"]*)"'
191
191
  )
192
192
 
193
+ # Full right-hand side up to the terminating ';' — captures concat/const-ref
194
+ # expressions (`"/{" + DB_PATH_PARAM_NAME + "}/query/v2"`) the literal-only
195
+ # regex above truncates. Resolved by _resolve_const_concat in _collect_file_constants.
196
+ _STATIC_FINAL_STR_DECL_RE = re.compile(
197
+ r'(?:public|protected|private)?\s*static\s+final\s+String\s+(\w+)\s*=\s*([^;]+);'
198
+ )
199
+
193
200
  _ENDPOINT_ANNOTATIONS: frozenset[str] = frozenset({
194
201
  # Spring MVC
195
202
  "@GetMapping", "@PostMapping", "@PutMapping", "@DeleteMapping",
@@ -2475,10 +2482,20 @@ def _scan_guard_clauses(body: str) -> list[dict]:
2475
2482
 
2476
2483
 
2477
2484
  def _extract_guard_facts(symbols: list[SymbolRecord], source: str) -> dict[str, list[dict]]:
2478
- """Ordered `guard_clause` atoms per method/constructor FQN. Reuses the SAME
2479
- `_method_body` extraction as the other body facts; the scanner keeps only the
2480
- structural guard shape (β), on its own occurrence axis."""
2481
- callers = [s for s in symbols if s.symbol_kind in ("method", "constructor") and s.line]
2485
+ """Ordered `guard_clause` atoms per method/constructor/endpoint FQN. Reuses the
2486
+ SAME `_method_body` extraction as the other body facts; the scanner keeps only
2487
+ the structural guard shape (β), on its own occurrence axis.
2488
+
2489
+ Endpoint-kind bodies are included (F2): an annotation-classified handler
2490
+ (`@PostMapping …`, symbol_kind="endpoint") is still a method body, and a guard
2491
+ that throws on a bad request parameter (`if (x == null || x.isBlank()) throw`)
2492
+ is exactly the manual-validation shape the validation surface must see. The
2493
+ scope was previously method/constructor only, which silently dropped every
2494
+ guard living in a handler body — the atom exists, the coverage did not."""
2495
+ callers = [
2496
+ s for s in symbols
2497
+ if s.symbol_kind in ("method", "constructor", "endpoint") and s.line
2498
+ ]
2482
2499
  if not callers:
2483
2500
  return {}
2484
2501
  raw_lines = source.splitlines()
@@ -3117,14 +3134,35 @@ def _collect_file_constants(source: str) -> dict[str, str]:
3117
3134
  if 'static final String' not in source:
3118
3135
  return {}
3119
3136
  # Scan only candidate lines (skips full-source regex over 100KB files).
3120
- # Running _STATIC_FINAL_STR_RE over the whole source is O(source_size) due to
3121
- # optional modifier group backtracking; per-line match is far cheaper.
3122
- constants: dict[str, str] = {}
3137
+ # Running the regex over the whole source is O(source_size) due to optional
3138
+ # modifier group backtracking; per-line match is far cheaper.
3139
+ #
3140
+ # F3: a constant value may itself be a concat / constant-ref expression
3141
+ # (`ROOT_PATH = "/{" + DB_PATH_PARAM_NAME + "}/query/v2"`). Capturing only the
3142
+ # first string literal truncates it (`/{`) and collapses every composed
3143
+ # @Path built on it. Capture the full RHS, then fold via _resolve_const_concat
3144
+ # to a fixpoint so constants defined in terms of earlier/later constants resolve.
3145
+ raw: dict[str, str] = {}
3123
3146
  for line in source.splitlines():
3124
3147
  if 'static' in line and 'final' in line and 'String' in line and '=' in line and '"' in line:
3125
- m = _STATIC_FINAL_STR_RE.search(line)
3148
+ m = _STATIC_FINAL_STR_DECL_RE.search(line)
3126
3149
  if m:
3127
- constants[m.group(1)] = m.group(2)
3150
+ raw[m.group(1)] = m.group(2).strip()
3151
+
3152
+ constants: dict[str, str] = {}
3153
+ # Fixpoint: each pass resolves any raw expr whose referenced constants are
3154
+ # now known. Bounded by the number of constants (chains resolve one hop/pass).
3155
+ for _ in range(len(raw) + 1):
3156
+ progress = False
3157
+ for name, expr in raw.items():
3158
+ if name in constants:
3159
+ continue
3160
+ val = _resolve_const_concat(expr, constants)
3161
+ if val is not None:
3162
+ constants[name] = val
3163
+ progress = True
3164
+ if len(constants) == len(raw) or not progress:
3165
+ break
3128
3166
  return constants
3129
3167
 
3130
3168
 
@@ -15,20 +15,23 @@ VAI note (§0.5): the only names this module keys on are **published open
15
15
  standards** (`jakarta.validation`, `javax.validation`, the bean-validation
16
16
  annotation vocabulary). No client / convention name enters a predicate.
17
17
 
18
- ARCHITECTURAL DIVERGENCE — manual_validation (design §2, deferred)
19
- -----------------------------------------------------------------
20
- Design §2 also asks for a `manual_validation` class detected by the **β
21
- GuardClause** shape on handler bodies (a guard that throws on a bad parameter).
22
- That path assumes guard atoms cover handlers. They do NOT: `_extract_guard_facts`
23
- (repository_ir.py) scans only `symbol_kind in {"method", "constructor"}`, and a
24
- mapped handler surfaces as kind `"endpoint"` — so `guard_facts` is empty for
25
- every handler body. Design §8 classifies §2 as *Incremental / no new primitive*;
26
- against the current architecture that is false. Rather than smuggle in an IR
27
- change under an "Incremental" increment, `manual_validation` is deferred and the
28
- conflict is documented in the design doc (EPV / INV-2: build the endpoint-scoped
29
- guard capability only when this consumer is ratified). The three classes that ARE
30
- reuse-only (active / available-unused / none) ship here and already resolve the
31
- field defect.
18
+ manual_validation (design §2) — RESOLVED (F2, roadmap Engineering Decision Support)
19
+ -----------------------------------------------------------------------------------
20
+ Design §2 asks for a `manual_validation` class detected by the **β GuardClause**
21
+ shape on handler bodies (a guard that throws/returns on a bad parameter). This was
22
+ previously deferred because `_extract_guard_facts` scanned only
23
+ `symbol_kind in {"method","constructor"}` and a mapped handler surfaces as kind
24
+ `"endpoint"`, so `guard_facts` was empty for every handler body (EPV/INV-2: build
25
+ the endpoint-scoped guard capability only when this consumer is ratified).
26
+
27
+ Roadmap F2 ratifies the consumer. `_extract_guard_facts` now includes endpoint-kind
28
+ bodies (repository_ir.py) — the SAME β scan widened to a symbol_kind it silently
29
+ skipped, no new atom type — so a handler's entry guards are visible here. This
30
+ module now classifies `manual_validation` from those atoms, scoped to
31
+ null/empty-check guards (β's `other` test_kind lumps range checks with
32
+ non-validation guards like memoization/preconditions, so counting it would inflate
33
+ false positives; range validation is therefore not separately claimed — an honest
34
+ under-report, labelled Likely never Verified).
32
35
  """
33
36
  from __future__ import annotations
34
37
 
@@ -64,6 +67,30 @@ _VALIDATE_MARKERS: frozenset[str] = frozenset({"Valid", "Validated"})
64
67
  # Body-carrying HTTP verbs — the surface where request-body validation applies.
65
68
  _BODY_VERBS: frozenset[str] = frozenset({"POST", "PUT", "PATCH"})
66
69
 
70
+ # β GuardClause test kinds that read as manual input validation (F2). `other`
71
+ # is excluded on purpose: at β it lumps range checks with non-validation guards
72
+ # (memoization, preconditions), so it cannot be claimed as validation.
73
+ _MANUAL_GUARD_TESTS: frozenset[str] = frozenset({"null_check", "empty_check"})
74
+
75
+
76
+ def _manual_guarded_handlers(
77
+ cir: "CanonicalRepositoryIR", handler_symbols: "set[str]"
78
+ ) -> set[str]:
79
+ """Handler symbols carrying ≥1 null/empty β GuardClause that early-exits.
80
+
81
+ Every β atom already early-exits (return/throw — `_scan_guard_clauses` records
82
+ no other shape), so presence of a null/empty atom on a handler body IS the
83
+ manual-validation signal. Scoped to the handlers actually on the body surface
84
+ so a guard in an unrelated method never counts."""
85
+ gf = cir.guard_facts
86
+ out: set[str] = set()
87
+ for h in handler_symbols:
88
+ for atom in gf.get(h, ()) or ():
89
+ if atom.get("test_kind") in _MANUAL_GUARD_TESTS:
90
+ out.add(h)
91
+ break
92
+ return out
93
+
67
94
 
68
95
  def _validation_available(cir: "CanonicalRepositoryIR") -> tuple[bool, list[str]]:
69
96
  """True when a published bean-validation framework is on the classpath,
@@ -133,10 +160,11 @@ def infer_validation_pattern(cir: "CanonicalRepositoryIR") -> ValidationInferenc
133
160
 
134
161
  Repo-level class (DESIGN §2):
135
162
  - ``bean_validation_active`` — @Valid present on body handlers.
163
+ - ``manual_validation`` — no @Valid, but body handlers guard
164
+ their input with null/empty β guard clauses that early-exit (Likely).
136
165
  - ``bean_validation_available_unused`` — validation on the classpath and
137
166
  body endpoints exist, but no handler validates → risk (Likely).
138
167
  - ``no_validation_detected`` — none of the above (Unknown).
139
- (``manual_validation`` deferred — see module docstring.)
140
168
 
141
169
  Never raises.
142
170
  """
@@ -145,15 +173,30 @@ def infer_validation_pattern(cir: "CanonicalRepositoryIR") -> ValidationInferenc
145
173
 
146
174
  body_eps = [ep for ep in cir.endpoints if str(ep.method).upper() in _BODY_VERBS]
147
175
  body_total = len(body_eps)
176
+ body_handlers = {ep.handler_symbol for ep in body_eps}
177
+ manual_handlers = _manual_guarded_handlers(cir, body_handlers)
148
178
  validated_body = sum(1 for ep in body_eps if ep.handler_symbol in validated_handlers)
179
+ # manual guards only "count" where bean validation is absent — a handler that
180
+ # both @Valid-s and hand-guards is bean-validated, not manual.
181
+ manual_body = sum(
182
+ 1 for ep in body_eps
183
+ if ep.handler_symbol in manual_handlers
184
+ and ep.handler_symbol not in validated_handlers
185
+ )
149
186
  unvalidated_body = body_total - validated_body
150
187
 
151
188
  # per-endpoint findings (only the body surface — where a request body applies)
152
189
  endpoint_findings: list[dict] = []
153
- rollup = {"validated": 0, "available_unused": 0, "unvalidated_unknown": 0}
190
+ rollup = {
191
+ "validated": 0, "manual_guard": 0,
192
+ "available_unused": 0, "unvalidated_unknown": 0,
193
+ }
154
194
  for ep in body_eps:
155
195
  if ep.handler_symbol in validated_handlers:
156
196
  klass, conf = "validated", Confidence.VERIFIED
197
+ elif ep.handler_symbol in manual_handlers:
198
+ # hand-written null/empty guard on the handler body → manual validation
199
+ klass, conf = "manual_guard", Confidence.LIKELY
157
200
  elif available:
158
201
  # @RequestBody-shaped verb, framework available, no @Valid → gap
159
202
  klass, conf = "available_unused", Confidence.LIKELY
@@ -175,11 +218,11 @@ def infer_validation_pattern(cir: "CanonicalRepositoryIR") -> ValidationInferenc
175
218
  "the exact @RequestBody parameter count is not captured (no IR atom "
176
219
  "exposes it), so 'available_unused' may include body-less handlers."
177
220
  )
178
- manual_fn = (
179
- "manual_validation (a handler that throws on a bad parameter) is not "
180
- "detected — guard-clause atoms do not cover endpoint-kind handlers "
181
- "(architectural defer, see module docstring); such a repo classifies "
182
- "no_validation_detected here."
221
+ manual_range_fn = (
222
+ "manual_validation is detected from null/empty β guard clauses only; "
223
+ "range/other-shaped guards (e.g. `if (n < 0) throw`) are indistinguishable "
224
+ "at β from non-validation guards and are not counted, so a purely "
225
+ "range-guarded handler can under-report."
183
226
  )
184
227
 
185
228
  if validated_body > 0:
@@ -193,11 +236,38 @@ def infer_validation_pattern(cir: "CanonicalRepositoryIR") -> ValidationInferenc
193
236
  "validated_body_endpoints": validated_body,
194
237
  "body_endpoints": body_total,
195
238
  "unvalidated_body_endpoints": unvalidated_body,
239
+ "manual_guarded_body_endpoints": manual_body,
196
240
  "validation_available": available,
197
241
  },
198
242
  ),
199
243
  limitations=[common_limitation],
200
- fn_causes=[manual_fn],
244
+ fn_causes=[manual_range_fn],
245
+ )
246
+ elif manual_body > 0:
247
+ pattern = "manual_validation"
248
+ verdict = Verdict(
249
+ claim="Request bodies validated by hand-written guard clauses (no bean validation)",
250
+ confidence=Confidence.LIKELY,
251
+ evidence=Evidence(
252
+ atoms_used=[f"null/empty guard: {h}" for h in sorted(manual_handlers)][:50],
253
+ details={
254
+ "body_endpoints": body_total,
255
+ "manual_guarded_body_endpoints": manual_body,
256
+ "validated_body_endpoints": 0,
257
+ "validation_available": available,
258
+ },
259
+ ),
260
+ limitations=[
261
+ common_limitation,
262
+ "A null/empty guard is a likely input-validation signal, not a "
263
+ "proof — it could defend an internal invariant unrelated to the "
264
+ "request contract.",
265
+ ],
266
+ fp_causes=[
267
+ "A null/empty guard may protect against an internal null rather "
268
+ "than validate the request body.",
269
+ ],
270
+ fn_causes=[manual_range_fn],
201
271
  )
202
272
  elif available and body_total > 0:
203
273
  pattern = "bean_validation_available_unused"
@@ -222,7 +292,7 @@ def infer_validation_pattern(cir: "CanonicalRepositoryIR") -> ValidationInferenc
222
292
  "A body endpoint may be intentionally unvalidated (idempotent "
223
293
  "echo, internal-only), or validated manually.",
224
294
  ],
225
- fn_causes=[manual_fn],
295
+ fn_causes=[manual_range_fn],
226
296
  )
227
297
  else:
228
298
  pattern = "no_validation_detected"
@@ -238,7 +308,7 @@ def infer_validation_pattern(cir: "CanonicalRepositoryIR") -> ValidationInferenc
238
308
  "validation import, and no body endpoints, or validation is done "
239
309
  "by a mechanism this projection does not model.",
240
310
  ],
241
- fn_causes=[manual_fn],
311
+ fn_causes=[manual_range_fn],
242
312
  )
243
313
 
244
314
  return ValidationInferenceResult(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sourcecode
3
- Version: 2.5.18
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
@@ -1,6 +1,7 @@
1
- sourcecode/__init__.py,sha256=wslvpdjMsUV-sx7b3v1cpnzhBqIUNoLGcCwm8IheiSM,309
1
+ sourcecode/__init__.py,sha256=1cAIVnSZ0uksrKFoCUTDgBevYywvPmcJwO80o39gmwI,309
2
2
  sourcecode/adaptive_scanner.py,sha256=yJBKjNpkY6bpueYJ2YnRezen3sYZDecEt7WaaNWdqug,9466
3
3
  sourcecode/archetype.py,sha256=d7yoN6Tj4OBj-VgSMPEKg4WF9H8FypnZ5A6AkUh5oIE,34484
4
+ sourcecode/architectural_delta.py,sha256=jzlY1HFXAemewk_xnuD5Y2YnkxCUqfneWcMfvTTnyfE,6312
4
5
  sourcecode/architecture_analyzer.py,sha256=GFc4ek-s1IHWM7pl-0L32WahZ93AmDgrAMcOuBKA5Dk,61463
5
6
  sourcecode/architecture_summary.py,sha256=BVVRHd952cjRhjHnR6CPrvKgaa-tdM16l-pBi1yDCPs,32395
6
7
  sourcecode/ast_extractor.py,sha256=ZwHN3Y0iRp2gpv3t5Nm5uvX7Xw1KtULCJPZqKpZQRAE,51255
@@ -8,15 +9,17 @@ sourcecode/cache.py,sha256=1V3vsaODAa2UBJAC0xpvxpmRdriCezQx5Q8JCcfgziE,31892
8
9
  sourcecode/call_surface.py,sha256=fiqYfHooxN1fX9oQoysq1LS3LoZcobhyUNjAGEZKpwk,4148
9
10
  sourcecode/caller_metrics.py,sha256=HG8RCOkpzzsEGdnMRob6byrwjoj5__t0TEsBj4yR7ks,2574
10
11
  sourcecode/canonical_ir.py,sha256=LdP_Ri3rl0M5p-MY0ypVvQwhq1WuUF1FmaHid7zyzEg,29807
12
+ sourcecode/change_plan.py,sha256=MNgNyu4zrLGvBBaXPwyCh-T2ZGaUQX3Hm4W87uuhZ3w,7750
11
13
  sourcecode/cir_graphs.py,sha256=9G0HHj1kw2325IDyzo2OpX73BNswEckecf4MZUXB4JM,12078
12
14
  sourcecode/classifier.py,sha256=JBzPwSSrDG-tUHAbcKB678HRbjLpD-ohzbzzO62mgpo,20114
13
- sourcecode/cli.py,sha256=QH7K8Uw19z5o7eXFPvVcC8ZA1KNUHS0bVL_IdOYC2CQ,324702
15
+ sourcecode/cli.py,sha256=BfgF0cHLzBISeAJWbbggS9ZFG-6FchHP54xVXAYX6rs,334366
14
16
  sourcecode/code_notes_analyzer.py,sha256=EJemNCNc9Dn-1RZYu-aNbK0ELzmsyC4s6FdHi3XyNEI,9392
15
17
  sourcecode/confidence_analyzer.py,sha256=vnbPI-20FnHdjO6STxHW8fbaxmB4A7y58io63ibFZjc,21586
16
18
  sourcecode/context_cache.py,sha256=maws79rLKDwyZ7c9Q2LwlIxqZHRFlaKzErtO3mglWoA,25036
17
19
  sourcecode/context_graph.py,sha256=i2kOX3XiaCqcR20ojbUnqoX-hJbMyLNYc7rl-n15bwE,46426
18
20
  sourcecode/context_scorer.py,sha256=QpChSpsmaAYz91rXA4Ue5xzQmNz_ZboZN09YOHScq1U,14679
19
21
  sourcecode/context_summarizer.py,sha256=cI2TZMvEhl0BEma12VtPaX6z03ZVBAetVzuK5GaCOvg,6852
22
+ sourcecode/contract_diff.py,sha256=M4v2lY6QWwdtNqMAAavtAIJQfyzydw0lHnnPi_Q2Keg,7751
20
23
  sourcecode/contract_model.py,sha256=nRxJKPMs1VHwFTa8AVXhGmaLjti3Lr2sjHDpWgv1bfE,3917
21
24
  sourcecode/contract_pipeline.py,sha256=KfSgNkebcL5bYx3TDzEnvPLcTgm_JifJOAqqR8-0zb4,29774
22
25
  sourcecode/coverage_parser.py,sha256=X9scuUWxacahYTrTBGea2Ku-sf-WCVcnc9OEB35_3IU,19340
@@ -32,7 +35,7 @@ sourcecode/evidence_provider.py,sha256=GSSL44JEaouO5AHks2sB3d1YvC9xIKIld1yBYxZpX
32
35
  sourcecode/explain.py,sha256=HnHWVTNNf9fzeR3FP-A-eXKeKZvBcUEuODgIO3rJQkQ,22312
33
36
  sourcecode/file_chunker.py,sha256=3vkM3mDQ5eE_yTPvUgjyjpGFBIjkW6_mrBmIbrylnA8,16444
34
37
  sourcecode/file_classifier.py,sha256=A0fEABqtfVu1MfoaxnPAvGpZgneGgVXlJDhT74NYXxE,15314
35
- sourcecode/format_contract.py,sha256=1cTNqwP8geA2hbQoBHUPgX3_vSh3l8guJT_jmgEnFF8,3466
38
+ sourcecode/format_contract.py,sha256=G2e7alugMEZXHtzbW4VMEjPciHlA1OaeLIt60a77jWA,3536
36
39
  sourcecode/fqn_utils.py,sha256=XLU7zDkNBXz_RZkIUNfpPmp1nekWtqP-fxV92tDV1vg,2158
37
40
  sourcecode/git_analyzer.py,sha256=JStxTQXNjBWi_wLdwhsZs9mT-v50cSJIz4Agzn6Kh9I,13362
38
41
  sourcecode/graph_analyzer.py,sha256=lp0eB1PWC20BYF-GpPhAyegRpKrUKgOmXZIcZSIX_Ks,65777
@@ -57,7 +60,7 @@ sourcecode/redactor.py,sha256=SB4hwIvg8h-hvcqKcDWaZvA-aSyn-at-BIRwa0tUv5E,3227
57
60
  sourcecode/relevance_scorer.py,sha256=0AgEt4KrV73nioMqBgjhGjtY7L2C7L7cSyKtj3IKcrw,9408
58
61
  sourcecode/rename_refactor.py,sha256=h6dNFlB9aZ_3q6heeHBkgXQeXaT03nvPSsYH6P8qxFg,12965
59
62
  sourcecode/repo_classifier.py,sha256=FG1vaWKdWXsWdl-S8hjVMiTqcwgaRXkDyvK4rPcOGtQ,22681
60
- sourcecode/repository_ir.py,sha256=Q2lMpm5x8ZAIY4MVKWEcLZ-BP26kUoP1dRfdER6gSbo,312316
63
+ sourcecode/repository_ir.py,sha256=NwPEyYaDyJihLuyfIx-aIpZpY5OIXlJhvLS7QEnf-u0,314200
61
64
  sourcecode/ris.py,sha256=Hw8TakTQ6hku-Abf2k8954NwkrH_sP73_8wVH8x5khc,22079
62
65
  sourcecode/runtime_classifier.py,sha256=uTAD6BDCiBLUZEDRfqk718kM4RTT_vAbfkcOI2_Xx58,18432
63
66
  sourcecode/scanner.py,sha256=z3CV0rcGunu0Y8mpNgp07wI7nxT0pxw1BkXRRtI0Rpo,9609
@@ -79,7 +82,7 @@ sourcecode/spring_tx_analyzer.py,sha256=7qXwQR9QbpVxbxJC-mawBMXM9xzydtZeq5L18cP_
79
82
  sourcecode/summarizer.py,sha256=sr0-tfecFKCr-fSkPPWbl-t9HC7SY2ZxkjnnXX8DB2A,26621
80
83
  sourcecode/tree_utils.py,sha256=8GAkIfQAsvtEudIeW1l4ooH_oRtrWR8cpJQJsEa_Pfw,2093
81
84
  sourcecode/type_usage_surface.py,sha256=51IrKRQoIoRnlsiDjHnqpJBn2rc6E59aRhgS0HTzAF0,4428
82
- sourcecode/validation_inference.py,sha256=UtmYv-d4TEzT1ihGEipAhtXDrwq0XtVtjMpb3i7NsNc,10815
85
+ sourcecode/validation_inference.py,sha256=cQaEJxCBDRWcD0On6PapoGb4qOTEPPlJ1eD4raFHWp0,14099
83
86
  sourcecode/validation_surface.py,sha256=A0-FVswL__XOcw92wvIwghwLq5AWtIm2kzO3RjEb1b4,27325
84
87
  sourcecode/version_check.py,sha256=CHp6ZxTIfo8kyHPCBgJA1uFC0xQCoXMuuOfrW8QTL8o,4942
85
88
  sourcecode/workspace.py,sha256=X_6NmNnitvT3_38V-JDChydo_sR68s249hLFlrQskU0,8271
@@ -138,8 +141,8 @@ sourcecode/telemetry/consent.py,sha256=LIAO9ohJZF8OuZwM4u1VWtALlYfTCCKq4wV3Vwc7i
138
141
  sourcecode/telemetry/events.py,sha256=LtzYfaX9Ilckj5PTvAcTpDa9mLqDsYPDUiDkRa58piY,2580
139
142
  sourcecode/telemetry/filters.py,sha256=NHa5T-6DaZduQPFuC34jOqHWQgSizM-Ygq8aZ4j19ng,5834
140
143
  sourcecode/telemetry/transport.py,sha256=4gGHsq0WeY9VywEZXA3vUxykfiYnw9uuqfjAAec7F8o,1681
141
- sourcecode-2.5.18.dist-info/METADATA,sha256=Er3B54p5axMRW7yoIsZmmGYvVCH_uILX7C4ge3mMQj0,10852
142
- sourcecode-2.5.18.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
143
- sourcecode-2.5.18.dist-info/entry_points.txt,sha256=-JEAdChrK5We51kZcb7OaDcyil-dHBjBPL-NhuO-QY8,89
144
- sourcecode-2.5.18.dist-info/licenses/LICENSE,sha256=7DdHrU9Z_3e7dSvq4ISijZNjnuHo5NIHNiHDouMQ9JU,10491
145
- sourcecode-2.5.18.dist-info/RECORD,,
144
+ sourcecode-2.5.20.dist-info/METADATA,sha256=9dtf9qcmo6vo4bdkBGYGcRLAoSocKyRQoQ0ivpxbcTs,10852
145
+ sourcecode-2.5.20.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
146
+ sourcecode-2.5.20.dist-info/entry_points.txt,sha256=-JEAdChrK5We51kZcb7OaDcyil-dHBjBPL-NhuO-QY8,89
147
+ sourcecode-2.5.20.dist-info/licenses/LICENSE,sha256=7DdHrU9Z_3e7dSvq4ISijZNjnuHo5NIHNiHDouMQ9JU,10491
148
+ sourcecode-2.5.20.dist-info/RECORD,,