sourcecode 2.6.2__py3-none-any.whl → 2.6.3__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/cli.py +81 -0
- sourcecode/mcp/registry.py +49 -1
- sourcecode/parse_cache.py +138 -0
- sourcecode/repository_ir.py +46 -12
- sourcecode/verify_edit.py +588 -0
- sourcecode/verify_rules.py +304 -0
- {sourcecode-2.6.2.dist-info → sourcecode-2.6.3.dist-info}/METADATA +1 -1
- {sourcecode-2.6.2.dist-info → sourcecode-2.6.3.dist-info}/RECORD +12 -9
- {sourcecode-2.6.2.dist-info → sourcecode-2.6.3.dist-info}/WHEEL +0 -0
- {sourcecode-2.6.2.dist-info → sourcecode-2.6.3.dist-info}/entry_points.txt +0 -0
- {sourcecode-2.6.2.dist-info → sourcecode-2.6.3.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
"""verify-edit — Fase V0 plumbing spike.
|
|
2
|
+
|
|
3
|
+
The pivot (see .planning/ROADMAP-VERIFY-EDIT.md): from read-context provider to an
|
|
4
|
+
in-loop semantic-diff gate. The one hard technical unknown is producing the diff
|
|
5
|
+
substrate — ``(CIR_HEAD, CIR_working)`` — deterministically and cheaply, *in place*
|
|
6
|
+
(working tree vs HEAD, no user-supplied snapshots). Existing ``delta`` /
|
|
7
|
+
``contract-diff`` take two directory snapshots; this closes that gap.
|
|
8
|
+
|
|
9
|
+
This module is the internal spike: it builds that pair and measures cold vs warm.
|
|
10
|
+
No public command and no verdict layer yet (those are Fase V1). What it must prove:
|
|
11
|
+
|
|
12
|
+
* the pair is a **pure function of the two tree states** — same trees ⇒ identical
|
|
13
|
+
CIR hashes across runs (determinism is the whole product promise);
|
|
14
|
+
* the HEAD CIR is cached by **HEAD commit sha** (stable while the working tree is
|
|
15
|
+
dirty), so warm < cold;
|
|
16
|
+
* nothing scoped is ever written under the repo-wide shared-CIR key (that key is
|
|
17
|
+
the ``explain``/``impact`` cache — poisoning it is the 2.6.2 lesson). The HEAD
|
|
18
|
+
CIR uses a **separate, HEAD-sha-anchored** cache entry.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import shutil
|
|
23
|
+
import subprocess
|
|
24
|
+
import tempfile
|
|
25
|
+
import time
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any, Optional
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class VerifyEditError(RuntimeError):
|
|
32
|
+
"""The working tree cannot be verified (not a git repo, no HEAD, git absent)."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class HeadVsWorking:
|
|
37
|
+
"""The diff substrate for one working tree, plus provenance for measurement.
|
|
38
|
+
|
|
39
|
+
``cir_head`` / ``cir_working`` are ``CanonicalRepositoryIR``. ``changed_files``
|
|
40
|
+
are the ``.java`` paths that differ from HEAD (tracked edits + untracked adds;
|
|
41
|
+
deletions included even though the file is gone from the working tree, because a
|
|
42
|
+
removed public type is a contract change the verdict layer must see).
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
cir_head: Any
|
|
46
|
+
cir_working: Any
|
|
47
|
+
changed_files: tuple[str, ...]
|
|
48
|
+
head_sha: str
|
|
49
|
+
head_cache_hit: bool
|
|
50
|
+
timings_ms: dict[str, float] = field(default_factory=dict)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# Git env vars that REDIRECT git at where it operates. When verify-edit runs INSIDE a
|
|
54
|
+
# git hook (V3), git exports these pointing at the in-progress commit's transient index/
|
|
55
|
+
# dir — a nested `git worktree add` then fails ("`.git/index`: Not a directory"). We
|
|
56
|
+
# always target the repo explicitly with `git -C root`, so scrubbing them is safe and
|
|
57
|
+
# makes the HEAD materialisation work from within a hook.
|
|
58
|
+
_SCRUBBED_GIT_ENV = (
|
|
59
|
+
"GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_INDEX_VERSION", "GIT_PREFIX",
|
|
60
|
+
"GIT_COMMON_DIR", "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _git_env() -> dict:
|
|
65
|
+
import os
|
|
66
|
+
env = {k: v for k, v in os.environ.items() if k not in _SCRUBBED_GIT_ENV}
|
|
67
|
+
return env
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ── git plumbing ───────────────────────────────────────────────────────────────
|
|
71
|
+
def _run_git(root: Path, *args: str, timeout: int = 60) -> subprocess.CompletedProcess:
|
|
72
|
+
return subprocess.run(
|
|
73
|
+
["git", "-C", str(root), *args],
|
|
74
|
+
capture_output=True,
|
|
75
|
+
text=True,
|
|
76
|
+
encoding="utf-8",
|
|
77
|
+
errors="replace",
|
|
78
|
+
timeout=timeout,
|
|
79
|
+
env=_git_env(),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _resolve_repo_root(path: Path) -> Path:
|
|
84
|
+
candidate = path.resolve()
|
|
85
|
+
while True:
|
|
86
|
+
if (candidate / ".git").exists():
|
|
87
|
+
return candidate
|
|
88
|
+
if candidate.parent == candidate:
|
|
89
|
+
return path.resolve()
|
|
90
|
+
candidate = candidate.parent
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _head_sha(root: Path) -> str:
|
|
94
|
+
try:
|
|
95
|
+
r = _run_git(root, "rev-parse", "HEAD", timeout=10)
|
|
96
|
+
return r.stdout.strip() if r.returncode == 0 else ""
|
|
97
|
+
except Exception:
|
|
98
|
+
return ""
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _changed_java_files(root: Path) -> tuple[str, ...]:
|
|
102
|
+
"""`.java` paths that differ from HEAD: tracked diff ∪ untracked adds.
|
|
103
|
+
|
|
104
|
+
Deletions are kept (the path is gone but the contract change is real). Pure
|
|
105
|
+
git; never reads file contents.
|
|
106
|
+
"""
|
|
107
|
+
files: set[str] = set()
|
|
108
|
+
try:
|
|
109
|
+
r = _run_git(root, "diff", "--name-only", "HEAD")
|
|
110
|
+
if r.returncode == 0:
|
|
111
|
+
files.update(line.strip() for line in r.stdout.splitlines() if line.strip())
|
|
112
|
+
ru = _run_git(root, "ls-files", "--others", "--exclude-standard")
|
|
113
|
+
if ru.returncode == 0:
|
|
114
|
+
files.update(line.strip() for line in ru.stdout.splitlines() if line.strip())
|
|
115
|
+
except Exception:
|
|
116
|
+
pass
|
|
117
|
+
return tuple(sorted(f for f in files if f.endswith(".java")))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ── CIR construction ───────────────────────────────────────────────────────────
|
|
121
|
+
def _build_cir(root: Path) -> Any:
|
|
122
|
+
"""Full-repo CIR from an on-disk tree (V0: full scope; V4 scopes to the diff)."""
|
|
123
|
+
from sourcecode.canonical_ir import build_canonical_ir
|
|
124
|
+
from sourcecode.repository_ir import find_java_files
|
|
125
|
+
|
|
126
|
+
return build_canonical_ir(find_java_files(root), root)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _build_head_cir_via_worktree(root: Path) -> Any:
|
|
130
|
+
"""Materialise HEAD in a throwaway detached worktree and build its CIR.
|
|
131
|
+
|
|
132
|
+
A worktree checkout gives an exact, clean HEAD tree regardless of how dirty the
|
|
133
|
+
live working tree is. The temp path never leaks into the CIR — file paths are
|
|
134
|
+
relative to the worktree root, and ``cir_hash`` is content-derived — so the HEAD
|
|
135
|
+
CIR is identical whatever the temp dir is called (asserted by the determinism test).
|
|
136
|
+
"""
|
|
137
|
+
tmp = Path(tempfile.mkdtemp(prefix="ask-head-"))
|
|
138
|
+
wt = tmp / "wt"
|
|
139
|
+
try:
|
|
140
|
+
r = _run_git(root, "worktree", "add", "--detach", "--quiet", str(wt), "HEAD")
|
|
141
|
+
if r.returncode != 0:
|
|
142
|
+
raise VerifyEditError(f"git worktree add failed: {r.stderr.strip()}")
|
|
143
|
+
return _build_cir(wt)
|
|
144
|
+
finally:
|
|
145
|
+
# Best-effort teardown: prune the worktree registration, then the temp dir.
|
|
146
|
+
try:
|
|
147
|
+
_run_git(root, "worktree", "remove", "--force", str(wt), timeout=30)
|
|
148
|
+
except Exception:
|
|
149
|
+
pass
|
|
150
|
+
_run_git(root, "worktree", "prune", timeout=10)
|
|
151
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _head_cir(root: Path, head_sha: str) -> tuple[Any, bool]:
|
|
155
|
+
"""Return ``(cir_head, cache_hit)``, caching the HEAD CIR by **HEAD sha**.
|
|
156
|
+
|
|
157
|
+
The cache signature is the commit sha, not the worktree signature, so the entry
|
|
158
|
+
stays valid while the working tree is dirty (which is the normal edit-loop state).
|
|
159
|
+
This is a *separate* entry from the shared repo-wide CIR — a scoped/HEAD build must
|
|
160
|
+
never land under that key.
|
|
161
|
+
"""
|
|
162
|
+
from sourcecode.canonical_ir import ir_dict_to_canonical, validate_canonical_ir
|
|
163
|
+
from sourcecode.context_cache import (
|
|
164
|
+
SCOPE_JAVA_CIR,
|
|
165
|
+
CachedContext,
|
|
166
|
+
ContextCache,
|
|
167
|
+
_cache_dir,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
cache = ContextCache(_cache_dir(root.resolve()), head_sha) # signature := HEAD sha
|
|
171
|
+
key = cache.knowledge_key(SCOPE_JAVA_CIR)
|
|
172
|
+
|
|
173
|
+
if cache.enabled:
|
|
174
|
+
cached = cache.get(key)
|
|
175
|
+
if cached is not None:
|
|
176
|
+
raw = cached.payload.get("raw_ir")
|
|
177
|
+
if isinstance(raw, dict):
|
|
178
|
+
try:
|
|
179
|
+
cir = ir_dict_to_canonical(raw, file_paths=None)
|
|
180
|
+
if not validate_canonical_ir(cir): # empty list ⇒ valid
|
|
181
|
+
return cir, True
|
|
182
|
+
except Exception:
|
|
183
|
+
pass # corrupt payload — rebuild
|
|
184
|
+
|
|
185
|
+
cir = _build_head_cir_via_worktree(root)
|
|
186
|
+
if cache.enabled:
|
|
187
|
+
try:
|
|
188
|
+
cache.put(
|
|
189
|
+
key,
|
|
190
|
+
CachedContext(
|
|
191
|
+
metadata={"scope": SCOPE_JAVA_CIR, "head_sha": head_sha, "cir_hash": cir.cir_hash},
|
|
192
|
+
graph_metrics={"symbol_count": len(cir.symbols), "endpoint_count": len(cir.endpoints)},
|
|
193
|
+
payload={"raw_ir": cir._raw_ir},
|
|
194
|
+
),
|
|
195
|
+
)
|
|
196
|
+
except Exception:
|
|
197
|
+
pass # caching is best-effort; correctness never depends on it
|
|
198
|
+
return cir, False
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# ── entry point ─────────────────────────────────────────────────────────────────
|
|
202
|
+
def head_vs_working(path: Path) -> HeadVsWorking:
|
|
203
|
+
"""Produce the ``(CIR_HEAD, CIR_working, changed_files)`` diff substrate.
|
|
204
|
+
|
|
205
|
+
Deterministic: the two CIRs are a pure function of the two tree states. The cache
|
|
206
|
+
only accelerates the HEAD side (keyed by HEAD sha); it never changes the result.
|
|
207
|
+
"""
|
|
208
|
+
root = _resolve_repo_root(Path(path))
|
|
209
|
+
head_sha = _head_sha(root)
|
|
210
|
+
if not head_sha:
|
|
211
|
+
raise VerifyEditError(f"{root} is not a git repository with a HEAD commit")
|
|
212
|
+
|
|
213
|
+
timings: dict[str, float] = {}
|
|
214
|
+
|
|
215
|
+
t0 = time.perf_counter()
|
|
216
|
+
changed = _changed_java_files(root)
|
|
217
|
+
timings["changed_files_ms"] = (time.perf_counter() - t0) * 1000.0
|
|
218
|
+
|
|
219
|
+
t1 = time.perf_counter()
|
|
220
|
+
cir_head, hit = _head_cir(root, head_sha)
|
|
221
|
+
timings["head_cir_ms"] = (time.perf_counter() - t1) * 1000.0
|
|
222
|
+
|
|
223
|
+
t2 = time.perf_counter()
|
|
224
|
+
cir_working = _build_cir(root)
|
|
225
|
+
timings["working_cir_ms"] = (time.perf_counter() - t2) * 1000.0
|
|
226
|
+
|
|
227
|
+
return HeadVsWorking(
|
|
228
|
+
cir_head=cir_head,
|
|
229
|
+
cir_working=cir_working,
|
|
230
|
+
changed_files=changed,
|
|
231
|
+
head_sha=head_sha,
|
|
232
|
+
head_cache_hit=hit,
|
|
233
|
+
timings_ms=timings,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
238
|
+
# Fase V1 — the verdict layer
|
|
239
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
240
|
+
#
|
|
241
|
+
# Each axis is a DIFF over the V0 substrate: what did the working tree gain versus
|
|
242
|
+
# HEAD? Honesty (F-1): an axis reports ``changed=True`` only on a proven regression,
|
|
243
|
+
# ``changed=False`` only when it actually computed and found none, and
|
|
244
|
+
# ``changed=None`` when it could not run (never a false "no change"). cir_hash is a
|
|
245
|
+
# coarse gate and is NOT used for detection — axes read structured symbol fields
|
|
246
|
+
# (the V0 finding) and reuse the existing analyzers, diffing their stable ids.
|
|
247
|
+
|
|
248
|
+
VERDICT_SCHEMA = "verify-edit-v1"
|
|
249
|
+
|
|
250
|
+
# Axes that gate a merge. blast_radius is informational (reach, not a regression).
|
|
251
|
+
# custom_rule_violated (Fase V5) enforces the client's own declared invariants; it is a
|
|
252
|
+
# no-op (clean) when no rules file exists, so adding it never blocks a repo that has none.
|
|
253
|
+
_BLOCKING_AXES = (
|
|
254
|
+
"contract_broken", "tx_boundary_changed", "security_delta", "new_orphan_bean",
|
|
255
|
+
"custom_rule_violated",
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@dataclass(frozen=True)
|
|
260
|
+
class AxisResult:
|
|
261
|
+
"""One verdict axis. ``changed``: True=regression proven, False=computed-clean,
|
|
262
|
+
None=could-not-compute (honest degrade — never counted as clean)."""
|
|
263
|
+
|
|
264
|
+
changed: Optional[bool]
|
|
265
|
+
detail: str = ""
|
|
266
|
+
items: tuple[dict, ...] = ()
|
|
267
|
+
|
|
268
|
+
def to_dict(self) -> dict:
|
|
269
|
+
d: dict = {"changed": self.changed}
|
|
270
|
+
if self.detail:
|
|
271
|
+
d["detail"] = self.detail
|
|
272
|
+
if self.items:
|
|
273
|
+
d["items"] = list(self.items)
|
|
274
|
+
return d
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@dataclass(frozen=True)
|
|
278
|
+
class VerifyVerdict:
|
|
279
|
+
verdict: str # "pass" | "break" | "unverified"
|
|
280
|
+
axes: dict[str, AxisResult]
|
|
281
|
+
changed_files: tuple[str, ...]
|
|
282
|
+
blast_radius: dict
|
|
283
|
+
reasons: tuple[str, ...]
|
|
284
|
+
exit_code: int
|
|
285
|
+
head_sha: str
|
|
286
|
+
|
|
287
|
+
def to_dict(self) -> dict:
|
|
288
|
+
return {
|
|
289
|
+
"schema_version": VERDICT_SCHEMA,
|
|
290
|
+
"verdict": self.verdict,
|
|
291
|
+
"head_sha": self.head_sha,
|
|
292
|
+
"changed_files": list(self.changed_files),
|
|
293
|
+
"axes": {name: ax.to_dict() for name, ax in self.axes.items()},
|
|
294
|
+
"blast_radius": self.blast_radius,
|
|
295
|
+
"reasons": list(self.reasons),
|
|
296
|
+
"exit_code": self.exit_code,
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
# ── structured symbol views (read the raw IR nodes, per the V0 finding) ─────────
|
|
301
|
+
def _nodes(cir: Any) -> list[dict]:
|
|
302
|
+
return cir._raw_ir.get("graph", {}).get("nodes", []) or []
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _nodes_by_fqn(cir: Any) -> dict[str, list[dict]]:
|
|
306
|
+
"""Group non-test graph nodes by fqn. A single fqn maps to MANY nodes when a method
|
|
307
|
+
or constructor is overloaded (``get(String)`` and ``get(Packet)`` share
|
|
308
|
+
``…#get``) — the fqn carries no signature. Callers must aggregate over the list, never
|
|
309
|
+
pick one node, or an order-dependent representative causes phantom diffs on an
|
|
310
|
+
unchanged tree (the eureka field-test FP). Test sources are excluded — breaking a
|
|
311
|
+
test's own API is not a contract change."""
|
|
312
|
+
from collections import defaultdict
|
|
313
|
+
by: dict[str, list[dict]] = defaultdict(list)
|
|
314
|
+
for n in _nodes(cir):
|
|
315
|
+
fqn = n.get("fqn")
|
|
316
|
+
if not fqn:
|
|
317
|
+
continue
|
|
318
|
+
low = (n.get("source_file") or "").lower()
|
|
319
|
+
if "/test/" in low or low.endswith("test.java") or "/src/test/" in low:
|
|
320
|
+
continue
|
|
321
|
+
by[fqn].append(n)
|
|
322
|
+
return by
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _any_public(nodes: list[dict]) -> bool:
|
|
326
|
+
"""A named symbol is on the public surface iff ANY overload is public — a call site
|
|
327
|
+
binding to the name still resolves while one public overload remains."""
|
|
328
|
+
return any("public" in (n.get("modifiers") or []) for n in nodes)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _contract_axis(cir_head: Any, cir_working: Any) -> AxisResult:
|
|
332
|
+
"""Public-surface regression: a public symbol removed, its visibility downgraded (no
|
|
333
|
+
public overload remains), or — when unambiguous — its signature changed.
|
|
334
|
+
|
|
335
|
+
Visibility is aggregated per fqn as *any overload public* (see ``_nodes_by_fqn``): the
|
|
336
|
+
old per-fqn last-wins keying reported a downgrade whenever a public name also had a
|
|
337
|
+
non-public overload, firing on identical HEAD/working trees. Signature change is only
|
|
338
|
+
reported for an unambiguous single-node-both-sides symbol; with overloads the fqn→
|
|
339
|
+
signature mapping is not well defined, so it is skipped rather than risk a false diff."""
|
|
340
|
+
head = _nodes_by_fqn(cir_head)
|
|
341
|
+
work = _nodes_by_fqn(cir_working)
|
|
342
|
+
|
|
343
|
+
items: list[dict] = []
|
|
344
|
+
for fqn, hnodes in sorted(head.items()):
|
|
345
|
+
if not _any_public(hnodes):
|
|
346
|
+
continue # not on HEAD's public surface — nothing to regress
|
|
347
|
+
kind = hnodes[0].get("symbol_kind") or hnodes[0].get("type") or ""
|
|
348
|
+
wnodes = work.get(fqn)
|
|
349
|
+
if not wnodes:
|
|
350
|
+
items.append({"symbol": fqn, "kind": kind, "change": "removed"})
|
|
351
|
+
elif not _any_public(wnodes):
|
|
352
|
+
mods = [m for n in wnodes for m in (n.get("modifiers") or []) if m in ("private", "protected")]
|
|
353
|
+
items.append({"symbol": fqn, "kind": kind, "change": "visibility_downgraded",
|
|
354
|
+
"to": sorted(set(mods)) or ["package-private"]})
|
|
355
|
+
elif len(hnodes) == 1 and len(wnodes) == 1:
|
|
356
|
+
hs, ws = hnodes[0].get("signature") or "", wnodes[0].get("signature") or ""
|
|
357
|
+
if hs and hs != ws:
|
|
358
|
+
items.append({"symbol": fqn, "kind": kind, "change": "signature_changed",
|
|
359
|
+
"from": hs, "to": ws})
|
|
360
|
+
|
|
361
|
+
if items:
|
|
362
|
+
return AxisResult(True, f"{len(items)} public API change(s)", tuple(items))
|
|
363
|
+
return AxisResult(False)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
# ── analyzer-diff axes (reuse existing detectors, diff stable finding ids) ──────
|
|
367
|
+
def _spring_finding_diff(cir_head: Any, cir_working: Any, root: Path, runner) -> AxisResult:
|
|
368
|
+
"""Run a spring analyzer on both trees; report findings the edit INTRODUCED
|
|
369
|
+
(present in working, absent in HEAD, keyed by the finding's deterministic id)."""
|
|
370
|
+
try:
|
|
371
|
+
head_res = runner(cir_head, root=root)
|
|
372
|
+
work_res = runner(cir_working, root=root)
|
|
373
|
+
except Exception as exc: # analyzer fault → could-not-compute, never false-clean
|
|
374
|
+
return AxisResult(None, f"analyzer error: {exc}")
|
|
375
|
+
head_ids = {f.id for f in head_res.findings}
|
|
376
|
+
introduced = [f for f in work_res.findings if f.id not in head_ids]
|
|
377
|
+
if introduced:
|
|
378
|
+
items = tuple(
|
|
379
|
+
{"pattern_id": f.pattern_id, "symbol": f.symbol, "severity": f.severity,
|
|
380
|
+
"title": f.title, "source_file": f.source_file}
|
|
381
|
+
for f in introduced
|
|
382
|
+
)
|
|
383
|
+
return AxisResult(True, f"{len(introduced)} new {introduced[0].category} finding(s)", items)
|
|
384
|
+
return AxisResult(False)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _tx_axis(cir_head: Any, cir_working: Any, root: Path) -> AxisResult:
|
|
388
|
+
from sourcecode.spring_tx_analyzer import run_tx_audit
|
|
389
|
+
return _spring_finding_diff(cir_head, cir_working, root, run_tx_audit)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _security_axis(cir_head: Any, cir_working: Any, root: Path) -> AxisResult:
|
|
393
|
+
from sourcecode.spring_security_audit import run_security_audit
|
|
394
|
+
return _spring_finding_diff(cir_head, cir_working, root, run_security_audit)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
# ── bean-wiring axis (injects-graph diff) ───────────────────────────────────────
|
|
398
|
+
def _inbound_injects(cir: Any) -> dict[str, int]:
|
|
399
|
+
"""Count injects edges pointing TO each type (who injects this bean)."""
|
|
400
|
+
counts: dict[str, int] = {}
|
|
401
|
+
for e in cir.call_graph:
|
|
402
|
+
if e.get("type") == "injects":
|
|
403
|
+
to = e.get("to")
|
|
404
|
+
if to:
|
|
405
|
+
counts[to] = counts.get(to, 0) + 1
|
|
406
|
+
return counts
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _orphan_bean_axis(cir_head: Any, cir_working: Any) -> AxisResult:
|
|
410
|
+
"""A bean that WAS injected at HEAD and has zero inbound injects in the working
|
|
411
|
+
tree — the edit severed its wiring. (New uninjected beans are NOT flagged: a
|
|
412
|
+
freshly-added bean wired nowhere yet is normal mid-edit — avoids the FP.)"""
|
|
413
|
+
head_in = _inbound_injects(cir_head)
|
|
414
|
+
work_in = _inbound_injects(cir_working)
|
|
415
|
+
work_fqns = {n.get("fqn") for n in _nodes(cir_working)}
|
|
416
|
+
items: list[dict] = []
|
|
417
|
+
for fqn, n_in in sorted(head_in.items()):
|
|
418
|
+
if n_in > 0 and work_in.get(fqn, 0) == 0 and fqn in work_fqns:
|
|
419
|
+
items.append({"symbol": fqn, "lost_injectors": n_in, "change": "wiring_severed"})
|
|
420
|
+
if items:
|
|
421
|
+
return AxisResult(True, f"{len(items)} bean(s) lost all injection wiring", tuple(items))
|
|
422
|
+
return AxisResult(False)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
# ── custom domain rules axis (Fase V5 — client-declared invariants) ─────────────
|
|
426
|
+
def _custom_rules_axis(cir_head: Any, cir_working: Any, root: Path) -> AxisResult:
|
|
427
|
+
"""Enforce the client's own rules (``.ask/verify-rules.json``), diffed like the
|
|
428
|
+
other axes: report only violations the edit INTRODUCED (present in the working tree,
|
|
429
|
+
absent at HEAD). No rules file ⇒ clean (never blocks). A malformed ruleset ⇒
|
|
430
|
+
could-not-compute (unverified), never a silent pass on a broken ruleset."""
|
|
431
|
+
from sourcecode import verify_rules as vr
|
|
432
|
+
|
|
433
|
+
try:
|
|
434
|
+
rules = vr.load_rules(root)
|
|
435
|
+
except vr.RulesError as exc:
|
|
436
|
+
return AxisResult(None, f"rules file error: {exc}")
|
|
437
|
+
if not rules:
|
|
438
|
+
return AxisResult(False, "no custom rules")
|
|
439
|
+
|
|
440
|
+
try:
|
|
441
|
+
head_ids = {v.id for v in vr.evaluate_all(rules, cir_head)}
|
|
442
|
+
work = vr.evaluate_all(rules, cir_working)
|
|
443
|
+
except Exception as exc: # evaluator fault → honest degrade, never false-clean
|
|
444
|
+
return AxisResult(None, f"rule evaluation error: {exc}")
|
|
445
|
+
|
|
446
|
+
introduced = [v for v in work if v.id not in head_ids]
|
|
447
|
+
if introduced:
|
|
448
|
+
items = tuple(v.to_dict() for v in introduced)
|
|
449
|
+
n = len({v.rule_id for v in introduced})
|
|
450
|
+
return AxisResult(True, f"{len(introduced)} new violation(s) of {n} custom rule(s)", items)
|
|
451
|
+
return AxisResult(False)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
# ── blast radius (reach over the changed files; informational, not a diff) ──────
|
|
455
|
+
def _blast_radius(cir_working: Any, changed_files: tuple[str, ...]) -> dict:
|
|
456
|
+
if not changed_files:
|
|
457
|
+
return {"affected_entry_points": 0, "confidence": "high", "note": "no changed files"}
|
|
458
|
+
try:
|
|
459
|
+
from sourcecode.context_graph import ContextGraph
|
|
460
|
+
from sourcecode.semantic_impact_engine import SemanticImpactEngine
|
|
461
|
+
engine = SemanticImpactEngine(ContextGraph.from_cir(cir_working))
|
|
462
|
+
# Each impact = one distinct entry point (endpoint/controller) reached by a
|
|
463
|
+
# changed type — the blast radius, not a single path.
|
|
464
|
+
impacts = engine.behavioral_impact(list(changed_files))
|
|
465
|
+
entry_points = [i.get("entry_point") for i in impacts if i.get("entry_point")]
|
|
466
|
+
return {
|
|
467
|
+
"affected_entry_points": len(entry_points),
|
|
468
|
+
"entry_points": entry_points[:10],
|
|
469
|
+
"confidence": impacts[0].get("confidence", "medium") if impacts else "medium",
|
|
470
|
+
"impacts": impacts[:3],
|
|
471
|
+
}
|
|
472
|
+
except Exception as exc:
|
|
473
|
+
return {"affected_entry_points": None, "confidence": "unknown",
|
|
474
|
+
"note": f"impact engine error: {exc}"}
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
# ── the verdict ─────────────────────────────────────────────────────────────────
|
|
478
|
+
def verdict(hvw: HeadVsWorking, root: Path) -> VerifyVerdict:
|
|
479
|
+
"""Fuse the axes over a V0 substrate into the ``verify-edit-v1`` verdict.
|
|
480
|
+
|
|
481
|
+
exit code: 0=pass, 1=break (a blocking axis proved a regression), 2=unverified
|
|
482
|
+
(a blocking axis could not be computed — honest, never a silent pass)."""
|
|
483
|
+
root = _resolve_repo_root(Path(root))
|
|
484
|
+
axes: dict[str, AxisResult] = {
|
|
485
|
+
"contract_broken": _contract_axis(hvw.cir_head, hvw.cir_working),
|
|
486
|
+
"tx_boundary_changed": _tx_axis(hvw.cir_head, hvw.cir_working, root),
|
|
487
|
+
"security_delta": _security_axis(hvw.cir_head, hvw.cir_working, root),
|
|
488
|
+
"new_orphan_bean": _orphan_bean_axis(hvw.cir_head, hvw.cir_working),
|
|
489
|
+
"custom_rule_violated": _custom_rules_axis(hvw.cir_head, hvw.cir_working, root),
|
|
490
|
+
}
|
|
491
|
+
blast = _blast_radius(hvw.cir_working, hvw.changed_files)
|
|
492
|
+
|
|
493
|
+
reasons: list[str] = []
|
|
494
|
+
broke = False
|
|
495
|
+
unverified = False
|
|
496
|
+
for name in _BLOCKING_AXES:
|
|
497
|
+
ax = axes[name]
|
|
498
|
+
if ax.changed is True:
|
|
499
|
+
broke = True
|
|
500
|
+
reasons.append(f"{name}: {ax.detail}")
|
|
501
|
+
elif ax.changed is None:
|
|
502
|
+
unverified = True
|
|
503
|
+
reasons.append(f"{name}: could not verify ({ax.detail})")
|
|
504
|
+
|
|
505
|
+
if broke:
|
|
506
|
+
verdict_str, code = "break", 1
|
|
507
|
+
elif unverified:
|
|
508
|
+
verdict_str, code = "unverified", 2
|
|
509
|
+
else:
|
|
510
|
+
verdict_str, code = "pass", 0
|
|
511
|
+
|
|
512
|
+
return VerifyVerdict(
|
|
513
|
+
verdict=verdict_str,
|
|
514
|
+
axes=axes,
|
|
515
|
+
changed_files=hvw.changed_files,
|
|
516
|
+
blast_radius=blast,
|
|
517
|
+
reasons=tuple(reasons),
|
|
518
|
+
exit_code=code,
|
|
519
|
+
head_sha=hvw.head_sha,
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def verify_edit(path: Path) -> VerifyVerdict:
|
|
524
|
+
"""End-to-end: build the substrate and return the verdict. The single entry the
|
|
525
|
+
CLI command and (Fase V2) the MCP tool call."""
|
|
526
|
+
root = _resolve_repo_root(Path(path))
|
|
527
|
+
return verdict(head_vs_working(root), root)
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
531
|
+
# Fase V3 — git hook (same binary, same verdict, in local and CI)
|
|
532
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
533
|
+
|
|
534
|
+
_HOOK_MARKER = "ask verify-edit gate"
|
|
535
|
+
HOOK_TYPES = ("pre-commit", "pre-push")
|
|
536
|
+
|
|
537
|
+
# `--ci` gates the exit code; a break/unverified verdict (exit 1/2) blocks the commit.
|
|
538
|
+
# `--no-verify` is the standard git escape hatch. The block is fenced by the marker so
|
|
539
|
+
# re-installs are idempotent and a foreign hook is never silently clobbered.
|
|
540
|
+
_HOOK_SCRIPT = """\
|
|
541
|
+
#!/bin/sh
|
|
542
|
+
# >>> {marker} >>>
|
|
543
|
+
# Blocks the commit when the working tree changed runtime behavior vs HEAD
|
|
544
|
+
# (public contract / @Transactional boundary / security / bean wiring).
|
|
545
|
+
# Installed by `ask verify-edit --install-hook`. Delete this block to disable.
|
|
546
|
+
ask verify-edit --ci --format json || {{
|
|
547
|
+
echo "" >&2
|
|
548
|
+
echo "verify-edit blocked this commit: runtime-behavior change vs HEAD (see verdict above)." >&2
|
|
549
|
+
echo "Fix the reported axes, or bypass with: git commit --no-verify" >&2
|
|
550
|
+
exit 1
|
|
551
|
+
}}
|
|
552
|
+
# <<< {marker} <<<
|
|
553
|
+
"""
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def install_git_hook(path: Path, hook_type: str = "pre-commit") -> Path:
|
|
557
|
+
"""Install a git ``hook_type`` hook that runs ``ask verify-edit --ci``.
|
|
558
|
+
|
|
559
|
+
Returns the hook path. Idempotent: re-installing over a hook we previously wrote
|
|
560
|
+
(detected by the marker) overwrites it; a hook we did NOT write is never clobbered
|
|
561
|
+
— that raises, so a project's existing hook is safe.
|
|
562
|
+
"""
|
|
563
|
+
if hook_type not in HOOK_TYPES:
|
|
564
|
+
raise VerifyEditError(f"unknown hook type {hook_type!r} (expected one of {', '.join(HOOK_TYPES)})")
|
|
565
|
+
root = _resolve_repo_root(Path(path))
|
|
566
|
+
if not _head_sha(root) and not (root / ".git").exists():
|
|
567
|
+
raise VerifyEditError(f"{root} is not a git repository")
|
|
568
|
+
# Resolve the real hooks dir via git so worktrees/submodules (where .git is a file
|
|
569
|
+
# or core.hooksPath is set) are handled correctly, not assumed to be .git/hooks.
|
|
570
|
+
r = _run_git(root, "rev-parse", "--git-path", "hooks", timeout=10)
|
|
571
|
+
if r.returncode != 0:
|
|
572
|
+
raise VerifyEditError(f"could not resolve the git hooks directory: {r.stderr.strip()}")
|
|
573
|
+
hooks_dir = (root / r.stdout.strip()).resolve()
|
|
574
|
+
hooks_dir.mkdir(parents=True, exist_ok=True)
|
|
575
|
+
hook_path = hooks_dir / hook_type
|
|
576
|
+
|
|
577
|
+
if hook_path.exists():
|
|
578
|
+
existing = hook_path.read_text(encoding="utf-8", errors="replace")
|
|
579
|
+
if _HOOK_MARKER not in existing:
|
|
580
|
+
raise VerifyEditError(
|
|
581
|
+
f"a {hook_type} hook already exists at {hook_path} and was not installed by "
|
|
582
|
+
f"verify-edit — not overwriting. Remove it first, or add the verify-edit call "
|
|
583
|
+
f"into it manually (`ask verify-edit --ci`)."
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
hook_path.write_text(_HOOK_SCRIPT.format(marker=_HOOK_MARKER), encoding="utf-8")
|
|
587
|
+
hook_path.chmod(0o755)
|
|
588
|
+
return hook_path
|