sourcecode 2.5.19__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 +1 -1
- sourcecode/architectural_delta.py +147 -0
- sourcecode/change_plan.py +195 -0
- sourcecode/cli.py +261 -0
- sourcecode/contract_diff.py +186 -0
- sourcecode/format_contract.py +2 -0
- sourcecode/repository_ir.py +33 -5
- {sourcecode-2.5.19.dist-info → sourcecode-2.5.20.dist-info}/METADATA +1 -1
- {sourcecode-2.5.19.dist-info → sourcecode-2.5.20.dist-info}/RECORD +12 -9
- {sourcecode-2.5.19.dist-info → sourcecode-2.5.20.dist-info}/WHEEL +0 -0
- {sourcecode-2.5.19.dist-info → sourcecode-2.5.20.dist-info}/entry_points.txt +0 -0
- {sourcecode-2.5.19.dist-info → sourcecode-2.5.20.dist-info}/licenses/LICENSE +0 -0
sourcecode/__init__.py
CHANGED
|
@@ -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))
|
sourcecode/format_contract.py
CHANGED
|
@@ -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"),
|
sourcecode/repository_ir.py
CHANGED
|
@@ -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",
|
|
@@ -3127,14 +3134,35 @@ def _collect_file_constants(source: str) -> dict[str, str]:
|
|
|
3127
3134
|
if 'static final String' not in source:
|
|
3128
3135
|
return {}
|
|
3129
3136
|
# Scan only candidate lines (skips full-source regex over 100KB files).
|
|
3130
|
-
# Running
|
|
3131
|
-
#
|
|
3132
|
-
|
|
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] = {}
|
|
3133
3146
|
for line in source.splitlines():
|
|
3134
3147
|
if 'static' in line and 'final' in line and 'String' in line and '=' in line and '"' in line:
|
|
3135
|
-
m =
|
|
3148
|
+
m = _STATIC_FINAL_STR_DECL_RE.search(line)
|
|
3136
3149
|
if m:
|
|
3137
|
-
|
|
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
|
|
3138
3166
|
return constants
|
|
3139
3167
|
|
|
3140
3168
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
sourcecode/__init__.py,sha256=
|
|
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=
|
|
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=
|
|
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=
|
|
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
|
|
@@ -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.
|
|
142
|
-
sourcecode-2.5.
|
|
143
|
-
sourcecode-2.5.
|
|
144
|
-
sourcecode-2.5.
|
|
145
|
-
sourcecode-2.5.
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|