claude-dev-env 1.59.0 → 1.60.0
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.
- package/audit-rubrics/category_rubrics/category-b-selector-engine-compat.md +1 -1
- package/audit-rubrics/category_rubrics/category-e-dead-code.md +1 -0
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
- package/audit-rubrics/prompts/category-b-selector-engine-compat.md +2 -2
- package/hooks/blocking/code_rules_dead_module_constant.py +321 -0
- package/hooks/blocking/code_rules_duplicate_body.py +152 -0
- package/hooks/blocking/code_rules_enforcer.py +30 -15
- package/hooks/blocking/code_rules_typeddict_stub.py +172 -0
- package/hooks/blocking/config/__init__.py +5 -0
- package/hooks/blocking/config/verified_commit_constants.py +106 -0
- package/hooks/blocking/test_code_rules_enforcer_cross_skill_duplicate.py +146 -0
- package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +188 -0
- package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias.py +415 -0
- package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +156 -0
- package/hooks/blocking/test_verdict_directory_write_blocker.py +720 -0
- package/hooks/blocking/test_verification_verdict_store.py +278 -0
- package/hooks/blocking/test_verified_commit_gate.py +368 -0
- package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +131 -0
- package/hooks/blocking/test_verifier_verdict_minter.py +214 -0
- package/hooks/blocking/verdict_directory_write_blocker.py +667 -0
- package/hooks/blocking/verification_verdict_store.py +446 -0
- package/hooks/blocking/verified_commit_gate.py +523 -0
- package/hooks/blocking/verified_commit_message_accuracy_blocker.py +152 -0
- package/hooks/blocking/verifier_verdict_minter.py +299 -0
- package/hooks/diagnostic/test_hook_log_extractor.py +3 -3
- package/hooks/hooks.json +43 -1
- package/hooks/hooks_constants/blocking_check_limits.py +1 -0
- package/hooks/hooks_constants/dead_module_constant_constants.py +20 -0
- package/hooks/hooks_constants/duplicate_function_body_constants.py +22 -5
- package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +1 -1
- package/package.json +1 -1
- package/rules/file-global-constants.md +7 -1
- package/rules/no-cross-skill-duplicate-helpers.md +29 -0
- package/skills/_shared/pr-loop/scripts/preflight_worktree.py +392 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/preflight_constants.py +70 -0
- package/skills/_shared/pr-loop/scripts/test_preflight_worktree.py +263 -0
- package/skills/autoconverge/SKILL.md +54 -17
- package/skills/autoconverge/reference/closing-report.md +59 -17
- package/skills/autoconverge/workflow/aggregate_runs.py +371 -0
- package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +193 -76
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +76 -0
- package/skills/autoconverge/workflow/converge.contract.test.mjs +206 -206
- package/skills/autoconverge/workflow/converge.mjs +128 -6
- package/skills/autoconverge/workflow/convergence_summary.py +110 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ab1c2d3e4f5a6b7c8.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +7 -0
- package/skills/autoconverge/workflow/render_report.py +488 -397
- package/skills/autoconverge/workflow/test_aggregate_runs.py +134 -0
- package/skills/autoconverge/workflow/test_convergence_summary.py +132 -0
- package/skills/autoconverge/workflow/test_render_report.py +488 -259
- package/skills/pr-converge/reference/per-tick.md +28 -8
- package/skills/rebase/SKILL.md +2 -4
- package/system-prompts/software-engineer.xml +2 -6
- package/hooks/blocking/content_search_to_zoekt_redirector.py +0 -59
- package/hooks/blocking/content_search_zoekt_bash_block_reason.py +0 -25
- package/hooks/blocking/content_search_zoekt_block_payload.py +0 -21
- package/hooks/blocking/content_search_zoekt_indexed_paths.py +0 -24
- package/hooks/blocking/content_search_zoekt_indexed_roots_config.py +0 -131
- package/hooks/blocking/content_search_zoekt_redirect_guidance.py +0 -52
- package/hooks/blocking/test_content_search_to_zoekt_redirector_integration.py +0 -61
- package/hooks/blocking/test_content_search_to_zoekt_redirector_unit.py +0 -92
- package/hooks/blocking/test_content_search_zoekt_indexed_roots_config.py +0 -102
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
"""Shared verdict storage and branch-diff logic for the verified-commit gate.
|
|
2
|
+
|
|
3
|
+
The verified-commit workflow has two halves that must agree byte-for-byte on
|
|
4
|
+
what a verdict covers: ``verifier_verdict_minter.py`` (SubagentStop) writes a
|
|
5
|
+
verdict bound to the current change surface, and ``verified_commit_gate.py``
|
|
6
|
+
(PreToolUse on Bash) refuses ``git commit`` / ``git push`` unless a verdict
|
|
7
|
+
matching the live surface exists. This module owns that shared contract:
|
|
8
|
+
locating the repo, computing the canonical surface manifest and its hash,
|
|
9
|
+
deriving the verdict file path, deciding the mechanical docs-only exemption,
|
|
10
|
+
and reading/writing verdict files.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import ast
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
import time
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
blocking_directory = str(Path(__file__).resolve().parent)
|
|
24
|
+
if blocking_directory not in sys.path:
|
|
25
|
+
sys.path.insert(0, blocking_directory)
|
|
26
|
+
|
|
27
|
+
from config.verified_commit_constants import (
|
|
28
|
+
CLAUDE_HOME_DIRECTORY_NAME,
|
|
29
|
+
CONFTEST_FILE_NAME,
|
|
30
|
+
DOCS_ONLY_EXTENSIONS,
|
|
31
|
+
ALL_FALLBACK_BASE_REFERENCES,
|
|
32
|
+
GIT_TIMEOUT_SECONDS,
|
|
33
|
+
MINIMUM_STATUS_FIELD_COUNT,
|
|
34
|
+
PYTHON_EXTENSION,
|
|
35
|
+
ROOT_KEY_HEX_LENGTH,
|
|
36
|
+
TEST_FILE_PREFIX,
|
|
37
|
+
TEST_FILE_SUFFIX,
|
|
38
|
+
ALL_TOOLING_STATE_PREFIXES,
|
|
39
|
+
VERDICT_DIRECTORY_NAME,
|
|
40
|
+
VERDICT_JSON_INDENT,
|
|
41
|
+
VERDICT_KEY_ALL_PASS,
|
|
42
|
+
VERDICT_KEY_MANIFEST_SHA256,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def run_git(repo_directory: str, *git_arguments: str) -> str | None:
|
|
47
|
+
"""Run a git command and return its stdout, or None on any failure.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
repo_directory: Directory git runs in (``git -C``).
|
|
51
|
+
*git_arguments: The git subcommand and its arguments.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
Decoded stdout with trailing whitespace stripped, or None when git
|
|
55
|
+
exits nonzero, times out, or is not installed.
|
|
56
|
+
"""
|
|
57
|
+
try:
|
|
58
|
+
completed_process = subprocess.run(
|
|
59
|
+
["git", "-C", repo_directory, *git_arguments],
|
|
60
|
+
capture_output=True,
|
|
61
|
+
text=True,
|
|
62
|
+
encoding="utf-8",
|
|
63
|
+
errors="replace",
|
|
64
|
+
timeout=GIT_TIMEOUT_SECONDS,
|
|
65
|
+
check=False,
|
|
66
|
+
)
|
|
67
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
68
|
+
return None
|
|
69
|
+
if completed_process.returncode != 0:
|
|
70
|
+
return None
|
|
71
|
+
return completed_process.stdout.rstrip()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def resolve_repo_root(start_directory: str) -> str | None:
|
|
75
|
+
"""Resolve the repository top level for a directory.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
start_directory: Any directory inside (or outside) a work tree.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
The absolute repo root path, or None when the directory is not
|
|
82
|
+
inside a git work tree.
|
|
83
|
+
"""
|
|
84
|
+
return run_git(start_directory, "rev-parse", "--show-toplevel")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _tracked_upstream_reference(repo_root: str) -> str | None:
|
|
88
|
+
"""Read HEAD's configured upstream tracking reference.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
repo_root: The repository top-level directory.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
The upstream reference (``origin/develop`` and the like) when HEAD
|
|
95
|
+
tracks one, or None when no upstream is configured.
|
|
96
|
+
"""
|
|
97
|
+
return run_git(
|
|
98
|
+
repo_root, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def candidate_base_references(repo_root: str) -> tuple[str, ...]:
|
|
103
|
+
"""Collect the upstream references to probe for the merge base, in order.
|
|
104
|
+
|
|
105
|
+
Probes ``origin/HEAD`` first, then HEAD's configured upstream tracking
|
|
106
|
+
reference (so a non-standard default branch like ``origin/develop`` is
|
|
107
|
+
found regardless of its name), then the fixed ``origin/main`` /
|
|
108
|
+
``origin/master`` fallbacks for checkouts with no tracking ref set.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
repo_root: The repository top-level directory.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
The ordered upstream references to try, with duplicates removed.
|
|
115
|
+
"""
|
|
116
|
+
upstream_head = run_git(repo_root, "symbolic-ref", "--quiet", "refs/remotes/origin/HEAD")
|
|
117
|
+
tracked_upstream = _tracked_upstream_reference(repo_root)
|
|
118
|
+
ordered_references = (
|
|
119
|
+
((upstream_head,) if upstream_head else ())
|
|
120
|
+
+ ((tracked_upstream,) if tracked_upstream else ())
|
|
121
|
+
+ ALL_FALLBACK_BASE_REFERENCES
|
|
122
|
+
)
|
|
123
|
+
return tuple(dict.fromkeys(ordered_references))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def resolve_merge_base(repo_root: str) -> str | None:
|
|
127
|
+
"""Find the merge base between HEAD and the default upstream branch.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
repo_root: The repository top-level directory.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
The merge-base commit sha, or None when no upstream base resolves —
|
|
134
|
+
the caller decides how to treat base-less repositories.
|
|
135
|
+
"""
|
|
136
|
+
for each_reference in candidate_base_references(repo_root):
|
|
137
|
+
merge_base_sha = run_git(repo_root, "merge-base", "HEAD", each_reference)
|
|
138
|
+
if merge_base_sha:
|
|
139
|
+
return merge_base_sha
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def untracked_file_paths(repo_root: str) -> list[str] | None:
|
|
144
|
+
"""List untracked, non-ignored files outside tooling-state directories.
|
|
145
|
+
|
|
146
|
+
Paths under the transient tooling-state subtrees (the Claude and Cursor
|
|
147
|
+
scratch subdirectories named in ``ALL_TOOLING_STATE_PREFIXES`` —
|
|
148
|
+
verification verdicts, worktree copies, daemon and team session state)
|
|
149
|
+
are skipped: they hold session state and stale worktree copies, never
|
|
150
|
+
the branch's work, and in real checkouts they run to thousands of
|
|
151
|
+
files. Production hook, agent, and skill files tracked elsewhere under
|
|
152
|
+
``.claude/`` are kept, so a new untracked one still binds to the
|
|
153
|
+
verdict surface.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
repo_root: The repository top-level directory.
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
Sorted repo-relative paths, or None when git fails.
|
|
160
|
+
"""
|
|
161
|
+
listing_text = run_git(
|
|
162
|
+
repo_root, "-c", "core.quotePath=false", "ls-files", "--others", "--exclude-standard"
|
|
163
|
+
)
|
|
164
|
+
if listing_text is None:
|
|
165
|
+
return None
|
|
166
|
+
return sorted(
|
|
167
|
+
each_line
|
|
168
|
+
for each_line in listing_text.splitlines()
|
|
169
|
+
if each_line and not each_line.startswith(ALL_TOOLING_STATE_PREFIXES)
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def branch_surface_manifest(repo_root: str, merge_base_sha: str) -> str | None:
|
|
174
|
+
"""Compute the canonical change-surface manifest a verdict covers.
|
|
175
|
+
|
|
176
|
+
The surface is every path that differs from the merge base plus every
|
|
177
|
+
untracked file, each bound by a digest of its current work-tree
|
|
178
|
+
content. Binding paths and contents — not patch text or index state —
|
|
179
|
+
makes the hash invariant under ``git add`` and commit slicing, while
|
|
180
|
+
any content edit or new file after verification still changes it.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
repo_root: The repository top-level directory.
|
|
184
|
+
merge_base_sha: The merge-base commit sha the branch grew from.
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
One ``<path> sha256=<digest>`` line per surface file (deleted
|
|
188
|
+
files carry a ``deleted`` marker), or None when git or a file
|
|
189
|
+
read fails.
|
|
190
|
+
"""
|
|
191
|
+
changed_paths_text = run_git(
|
|
192
|
+
repo_root, "-c", "core.quotePath=false", "diff", "--name-only", "--no-renames",
|
|
193
|
+
merge_base_sha,
|
|
194
|
+
)
|
|
195
|
+
if changed_paths_text is None:
|
|
196
|
+
return None
|
|
197
|
+
untracked_paths = untracked_file_paths(repo_root)
|
|
198
|
+
if untracked_paths is None:
|
|
199
|
+
return None
|
|
200
|
+
surface_paths = sorted(
|
|
201
|
+
{each_path for each_path in changed_paths_text.splitlines() if each_path}
|
|
202
|
+
| set(untracked_paths)
|
|
203
|
+
)
|
|
204
|
+
manifest_lines = []
|
|
205
|
+
for each_path in surface_paths:
|
|
206
|
+
surface_file = Path(repo_root) / each_path
|
|
207
|
+
if not surface_file.is_file():
|
|
208
|
+
manifest_lines.append(f"{each_path} deleted")
|
|
209
|
+
continue
|
|
210
|
+
try:
|
|
211
|
+
content_digest = hashlib.sha256(surface_file.read_bytes()).hexdigest()
|
|
212
|
+
except OSError:
|
|
213
|
+
return None
|
|
214
|
+
manifest_lines.append(f"{each_path} sha256={content_digest}")
|
|
215
|
+
return "\n".join(manifest_lines)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def manifest_sha256(surface_manifest_text: str) -> str:
|
|
219
|
+
"""Hash a change-surface manifest.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
surface_manifest_text: The manifest from ``branch_surface_manifest``.
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
The hex sha256 digest of the encoded manifest text.
|
|
226
|
+
"""
|
|
227
|
+
return hashlib.sha256(surface_manifest_text.encode("utf-8")).hexdigest()
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def verdict_path_for_repo(repo_root: str) -> Path:
|
|
231
|
+
"""Derive the verdict file path for a repository work tree.
|
|
232
|
+
|
|
233
|
+
Verdicts live outside the repository (under the user's Claude home) so
|
|
234
|
+
no repo accumulates untracked files, keyed by a hash of the normalized
|
|
235
|
+
work-tree path so every worktree gets its own verdict.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
repo_root: The repository top-level directory.
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
The verdict file path for this work tree.
|
|
242
|
+
"""
|
|
243
|
+
normalized_root = str(Path(repo_root).resolve()).replace("\\", "/").lower()
|
|
244
|
+
root_key = hashlib.sha256(normalized_root.encode("utf-8")).hexdigest()[:ROOT_KEY_HEX_LENGTH]
|
|
245
|
+
return (
|
|
246
|
+
Path.home() / CLAUDE_HOME_DIRECTORY_NAME / VERDICT_DIRECTORY_NAME / f"{root_key}.json"
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def load_valid_verdict(repo_root: str, expected_manifest_sha256: str) -> dict | None:
|
|
251
|
+
"""Load the verdict for a repo when it passes and covers the live surface.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
repo_root: The repository top-level directory.
|
|
255
|
+
expected_manifest_sha256: Hash of the live surface manifest the
|
|
256
|
+
verdict must match exactly.
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
The verdict mapping when it exists, parses, reports ``all_pass``
|
|
260
|
+
true, and binds to the expected manifest hash; otherwise None.
|
|
261
|
+
"""
|
|
262
|
+
verdict_file = verdict_path_for_repo(repo_root)
|
|
263
|
+
try:
|
|
264
|
+
verdict_record = json.loads(verdict_file.read_text(encoding="utf-8"))
|
|
265
|
+
except (OSError, json.JSONDecodeError):
|
|
266
|
+
return None
|
|
267
|
+
if not isinstance(verdict_record, dict):
|
|
268
|
+
return None
|
|
269
|
+
if verdict_record.get(VERDICT_KEY_ALL_PASS) is not True:
|
|
270
|
+
return None
|
|
271
|
+
if verdict_record.get(VERDICT_KEY_MANIFEST_SHA256) != expected_manifest_sha256:
|
|
272
|
+
return None
|
|
273
|
+
return verdict_record
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def write_verdict(
|
|
277
|
+
repo_root: str,
|
|
278
|
+
bound_manifest_sha256: str,
|
|
279
|
+
is_all_pass: bool,
|
|
280
|
+
all_findings: list,
|
|
281
|
+
minted_from_agent_id: str,
|
|
282
|
+
) -> Path:
|
|
283
|
+
"""Write a verdict file binding a verification outcome to a surface hash.
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
repo_root: The repository top-level directory.
|
|
287
|
+
bound_manifest_sha256: Hash of the surface manifest the verdict covers.
|
|
288
|
+
is_all_pass: Whether the verifier reported a clean verdict.
|
|
289
|
+
all_findings: The verifier's findings list (empty when clean).
|
|
290
|
+
minted_from_agent_id: The subagent invocation id, kept for audit.
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
The path the verdict was written to.
|
|
294
|
+
"""
|
|
295
|
+
verdict_file = verdict_path_for_repo(repo_root)
|
|
296
|
+
verdict_file.parent.mkdir(parents=True, exist_ok=True)
|
|
297
|
+
verdict_record = {
|
|
298
|
+
VERDICT_KEY_ALL_PASS: is_all_pass,
|
|
299
|
+
VERDICT_KEY_MANIFEST_SHA256: bound_manifest_sha256,
|
|
300
|
+
"repo_root": repo_root,
|
|
301
|
+
"findings": all_findings,
|
|
302
|
+
"minted_from_agent_id": minted_from_agent_id,
|
|
303
|
+
"minted_at_epoch_seconds": int(time.time()),
|
|
304
|
+
}
|
|
305
|
+
verdict_file.write_text(
|
|
306
|
+
json.dumps(verdict_record, indent=VERDICT_JSON_INDENT), encoding="utf-8"
|
|
307
|
+
)
|
|
308
|
+
return verdict_file
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def stripped_ast_dump(python_source: str) -> str | None:
|
|
312
|
+
"""Dump a Python module's AST with every docstring removed.
|
|
313
|
+
|
|
314
|
+
Comments never reach the AST, so two sources with equal stripped dumps
|
|
315
|
+
differ only in docstrings, comments, or formatting — never in behavior.
|
|
316
|
+
|
|
317
|
+
Args:
|
|
318
|
+
python_source: The module source text.
|
|
319
|
+
|
|
320
|
+
Returns:
|
|
321
|
+
The ``ast.dump`` text of the stripped tree, or None when the source
|
|
322
|
+
does not parse (callers treat unparseable sources as changed).
|
|
323
|
+
"""
|
|
324
|
+
try:
|
|
325
|
+
module_tree = ast.parse(python_source)
|
|
326
|
+
except (SyntaxError, ValueError):
|
|
327
|
+
return None
|
|
328
|
+
for each_node in ast.walk(module_tree):
|
|
329
|
+
if not isinstance(
|
|
330
|
+
each_node, (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)
|
|
331
|
+
):
|
|
332
|
+
continue
|
|
333
|
+
node_body = each_node.body
|
|
334
|
+
if (
|
|
335
|
+
node_body
|
|
336
|
+
and isinstance(node_body[0], ast.Expr)
|
|
337
|
+
and isinstance(node_body[0].value, ast.Constant)
|
|
338
|
+
and isinstance(node_body[0].value.value, str)
|
|
339
|
+
):
|
|
340
|
+
each_node.body = node_body[1:] or [ast.Pass()]
|
|
341
|
+
return ast.dump(module_tree)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _is_python_change_docstring_only(
|
|
345
|
+
repo_root: str, merge_base_sha: str, repo_relative_path: str
|
|
346
|
+
) -> bool:
|
|
347
|
+
"""Decide whether one Python file changed only in docstrings or comments.
|
|
348
|
+
|
|
349
|
+
Args:
|
|
350
|
+
repo_root: The repository top-level directory.
|
|
351
|
+
merge_base_sha: The merge-base commit holding the old version.
|
|
352
|
+
repo_relative_path: The file's path relative to the repo root.
|
|
353
|
+
|
|
354
|
+
Returns:
|
|
355
|
+
True only when both versions parse and their docstring-stripped
|
|
356
|
+
ASTs match exactly.
|
|
357
|
+
"""
|
|
358
|
+
old_source = run_git(repo_root, "show", f"{merge_base_sha}:{repo_relative_path}")
|
|
359
|
+
if old_source is None:
|
|
360
|
+
return False
|
|
361
|
+
try:
|
|
362
|
+
new_source = (Path(repo_root) / repo_relative_path).read_text(
|
|
363
|
+
encoding="utf-8", errors="replace"
|
|
364
|
+
)
|
|
365
|
+
except OSError:
|
|
366
|
+
return False
|
|
367
|
+
old_dump = stripped_ast_dump(old_source)
|
|
368
|
+
new_dump = stripped_ast_dump(new_source)
|
|
369
|
+
return old_dump is not None and old_dump == new_dump
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _is_test_file_path(repo_relative_path: str) -> bool:
|
|
373
|
+
"""Decide whether a path names a pytest test file.
|
|
374
|
+
|
|
375
|
+
Args:
|
|
376
|
+
repo_relative_path: The file's path relative to the repo root.
|
|
377
|
+
|
|
378
|
+
Returns:
|
|
379
|
+
True when the file name follows a pytest collection convention
|
|
380
|
+
(``test_*.py``, ``*_test.py``, or ``conftest.py``).
|
|
381
|
+
"""
|
|
382
|
+
file_name = Path(repo_relative_path).name
|
|
383
|
+
if file_name == CONFTEST_FILE_NAME:
|
|
384
|
+
return True
|
|
385
|
+
if not file_name.endswith(PYTHON_EXTENSION):
|
|
386
|
+
return False
|
|
387
|
+
return file_name.startswith(TEST_FILE_PREFIX) or file_name.endswith(TEST_FILE_SUFFIX)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def is_verification_exempt_diff(repo_root: str, merge_base_sha: str) -> bool:
|
|
391
|
+
"""Decide the mechanical exemption: nothing production-behavioral changed.
|
|
392
|
+
|
|
393
|
+
A diff is exempt only when every changed file is a docs/image file (by
|
|
394
|
+
extension), a pytest test file (by name convention), or a Python file
|
|
395
|
+
whose docstring-stripped AST is unchanged. Untracked files count as
|
|
396
|
+
changes: only docs-extension and test-named ones are exempt, since an
|
|
397
|
+
untracked production Python file has no merge-base version to compare
|
|
398
|
+
against. Renames are decomposed into a delete plus an add
|
|
399
|
+
(``--no-renames``) so renaming code to a docs extension still gates
|
|
400
|
+
the deletion. Production edits key on a fact the diff author cannot
|
|
401
|
+
steer — any behavioral edit changes the AST and gets gated. Test files
|
|
402
|
+
are exempt by policy: a test-only surface cannot change production
|
|
403
|
+
behavior, and test quality is covered by review, not by the verifier.
|
|
404
|
+
|
|
405
|
+
Args:
|
|
406
|
+
repo_root: The repository top-level directory.
|
|
407
|
+
merge_base_sha: The merge-base commit sha the branch grew from.
|
|
408
|
+
|
|
409
|
+
Returns:
|
|
410
|
+
True when every change is exempt; False otherwise, and False
|
|
411
|
+
whenever git output cannot be read (fail closed).
|
|
412
|
+
"""
|
|
413
|
+
name_status_text = run_git(
|
|
414
|
+
repo_root, "-c", "core.quotePath=false", "diff", "--name-status", "--no-renames",
|
|
415
|
+
merge_base_sha,
|
|
416
|
+
)
|
|
417
|
+
if name_status_text is None:
|
|
418
|
+
return False
|
|
419
|
+
untracked_paths = untracked_file_paths(repo_root)
|
|
420
|
+
if untracked_paths is None:
|
|
421
|
+
return False
|
|
422
|
+
for each_untracked_path in untracked_paths:
|
|
423
|
+
if _is_test_file_path(each_untracked_path):
|
|
424
|
+
continue
|
|
425
|
+
if Path(each_untracked_path).suffix.lower() not in DOCS_ONLY_EXTENSIONS:
|
|
426
|
+
return False
|
|
427
|
+
if not name_status_text:
|
|
428
|
+
return True
|
|
429
|
+
for each_status_line in name_status_text.splitlines():
|
|
430
|
+
status_fields = each_status_line.split("\t")
|
|
431
|
+
if len(status_fields) < MINIMUM_STATUS_FIELD_COUNT:
|
|
432
|
+
return False
|
|
433
|
+
change_code = status_fields[0]
|
|
434
|
+
changed_path = status_fields[-1]
|
|
435
|
+
if _is_test_file_path(changed_path):
|
|
436
|
+
continue
|
|
437
|
+
file_extension = Path(changed_path).suffix.lower()
|
|
438
|
+
if file_extension in DOCS_ONLY_EXTENSIONS:
|
|
439
|
+
continue
|
|
440
|
+
if file_extension != PYTHON_EXTENSION:
|
|
441
|
+
return False
|
|
442
|
+
if not change_code.startswith("M"):
|
|
443
|
+
return False
|
|
444
|
+
if not _is_python_change_docstring_only(repo_root, merge_base_sha, changed_path):
|
|
445
|
+
return False
|
|
446
|
+
return True
|