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.

@@ -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