claude-dev-env 1.70.0 → 1.71.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-o-docstring-vs-impl-drift.md +1 -0
- package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +8 -4
- package/hooks/blocking/claude_md_orphan_file_blocker.py +39 -7
- package/hooks/blocking/test_claude_md_orphan_file_blocker.py +36 -0
- package/hooks/git-hooks/CLAUDE.md +1 -1
- package/hooks/git-hooks/git_hooks_constants/__init__.py +13 -0
- package/hooks/git-hooks/pre_push.py +74 -15
- package/hooks/git-hooks/test_pre_push.py +118 -0
- package/hooks/hooks_constants/claude_md_orphan_file_blocker_constants.py +13 -2
- package/package.json +1 -1
- package/rules/docstring-prose-matches-implementation.md +4 -1
|
@@ -27,6 +27,7 @@ Decomposition is by the **kind of docstring claim** that needs to be cross-check
|
|
|
27
27
|
| O5 | Named-sentinel / filename references | A docstring names a sentinel marker, environment variable, filename, or magic string. Confirm the named token actually exists in the module body or in the repo's naming convention. |
|
|
28
28
|
| O6 | Free-form `Args:`-adjacent claims | A docstring's `Returns:` / `Raises:` / `Note:` / `Example:` sections make claims (`returns shared-temp only`, `raises ValueError on missing key`). Verify each claim against the body. When a docstring enumerates the inputs a body counts (a "field counts as read when ..." list, a list of conditions treated as a match, a list of cases the body skips), list every union member and every suppressor the body applies (`read_names = a | b | c`, each early-return guard) and confirm each appears in the prose enumeration. A union member or suppressor the body applies but the prose omits is an O6 finding. The single-condition shared-fallback shape of this drift — a summary that scopes a fallback call to one condition while the body routes to that same call from two or more early-return guards — is gated deterministically at Write/Edit time by `check_docstring_fallback_branch_coverage`, so the audit lane focuses on the O6 shapes the gate cannot match. A `Returns:` that names the mechanism, tool, or output format the function produces (`instructing a StructuredOutput summary`, `returns a YAML document`, `emits a JSON object`) matches the artifact the body actually builds: a prompt body that asks the agent to "Return strictly a JSON object" while the docstring claims it "instruct[s] a StructuredOutput" summary is an O6 finding, because the named tool appears nowhere in the emitted text. See `../../rules/docstring-prose-matches-implementation.md`. |
|
|
29
29
|
| O7 | Module-doc-vs-split-module after refactor | When a refactor moves a responsibility to a sibling module, the originating module's docstring and the receiving module's docstring both describe the home of that responsibility. A module docstring should describe only the responsibilities it owns. |
|
|
30
|
+
| O8 | Companion-doc ordering/content vs producer | When a PR changes a producer function's ordering or union, read that skill's companion `SKILL.md` and sibling `.md` docs for any sentence naming the same produced artifact (a file path, a JSON key, a named list). A doc sentence that claims the artifact is `sorted` / `alphabetical` / `in sorted order`, or holds `just the at-risk names` / `only the current set`, while the producer merges stored names with new names and appends — preserving file order, not re-sorting the union — is an O8 finding on both counts (wrong order claim, hidden merged-in entries). The finding stands even when the PR diff never touched the `.md` file, because the behavior change orphaned the doc claim. See `../../rules/docstring-prose-matches-implementation.md`. |
|
|
30
31
|
|
|
31
32
|
---
|
|
32
33
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
Audit [REPO/ARTIFACT] [TARGET_ID] for **Category O only** (docstring / fixture-prose vs implementation drift). Skip A–N, P. Sub-bucket forced-exhaustion mode: Category O is decomposed into
|
|
1
|
+
Audit [REPO/ARTIFACT] [TARGET_ID] for **Category O only** (docstring / fixture-prose vs implementation drift). Skip A–N, P. Sub-bucket forced-exhaustion mode: Category O is decomposed into 8 sub-buckets below. Each sub-bucket REQUIRES at least one Shape A finding OR exactly one Shape B proof-of-absence with **at least 3 adversarial probes** specific to that sub-bucket. A sub-bucket returning neither is a protocol gap.
|
|
2
2
|
|
|
3
3
|
[ARTIFACT METADATA — include every changed module's docstring AND the exported symbols of that module so the audit can compare claim vs body]
|
|
4
4
|
|
|
@@ -47,9 +47,13 @@ ID prefix: `find`.
|
|
|
47
47
|
- When the diff includes a module split (one file becomes two), verify both modules' docstrings describe the responsibility each one actually owns after the split.
|
|
48
48
|
- Adversarial probes: (a) for each module in the split, list its exported symbols and compare to the docstring's claimed responsibilities; (b) grep the responsibility's verb against the originating module — does the originating docstring still claim what moved; (c) check for cross-module imports that reveal which file hosts each responsibility.
|
|
49
49
|
|
|
50
|
+
**O8. Companion-doc ordering/content vs producer**
|
|
51
|
+
- When the diff changes a producer function's ordering or union, read that skill's companion `SKILL.md` and sibling `.md` docs for any sentence naming the same produced artifact (a file path, a JSON key, a named list). A doc sentence that claims the artifact is `sorted` / `alphabetical` / `in sorted order`, or holds `just the at-risk names` / `only the current set`, while the producer merges stored names with new names and appends — preserving file order, not re-sorting the union — is an O8 finding on both counts (wrong order claim, hidden merged-in entries). The finding stands even when the diff never touched the `.md` file, because the behavior change orphaned the doc claim.
|
|
52
|
+
- Adversarial probes: (a) for each changed producer, name the artifact it builds and grep the skill's `SKILL.md` and sibling `.md` files for any sentence naming that artifact; (b) walk the producer body's build step — does it sort, or does it merge stored names and append in file order — and compare against the doc's order word (`sorted`, `alphabetical`); (c) check whether the doc's content claim (`just the at-risk names`, `only the current set`) hides merged-in prior entries the producer carries over from the stored file.
|
|
53
|
+
|
|
50
54
|
## Cross-bucket questions to answer at the end
|
|
51
55
|
|
|
52
|
-
Q1: Across all
|
|
56
|
+
Q1: Across all 8 sub-buckets, which docstring claim is the most misleading — i.e., a future maintainer reading only the docstring would write or change code that contradicts the body? Cite file:line of the docstring AND the body line(s) that contradict it.
|
|
53
57
|
|
|
54
58
|
Q2: Which docstring claim is at highest risk of becoming load-bearing — i.e., a future caller or test author would rely on the claim to skip reading the body? Cite the claim and the use case.
|
|
55
59
|
|
|
@@ -57,13 +61,13 @@ Q3: Of the changed docstrings, which one most clearly shows a refactor was incom
|
|
|
57
61
|
|
|
58
62
|
## Output
|
|
59
63
|
|
|
60
|
-
Lead: `Total: N (P0=N, P1=N, P2=N)`. For each sub-bucket O1-
|
|
64
|
+
Lead: `Total: N (P0=N, P1=N, P2=N)`. For each sub-bucket O1-O8, produce Shape A or Shape B (with ≥3 probes). Each Shape A finding must cite (a) the docstring file:line, (b) the body file:line that contradicts it, and (c) one sentence describing the contradiction in concrete terms. Cross-bucket Q1-Q3 answers after the per-sub-bucket walk. Adversarial second pass: "assume your first pass missed at least 3 module-level docstring claims whose implementation moved during a refactor — find them." Open Questions section for ambiguities. Read-only. No edits, no commits.
|
|
61
65
|
|
|
62
66
|
---
|
|
63
67
|
|
|
64
68
|
# Worked example: jl-cmd/claude-code-config PR #522
|
|
65
69
|
|
|
66
|
-
Audit jl-cmd/claude-code-config PR #522 for **Category O only** (docstring / fixture-prose vs implementation drift). Skip A-N, P. Sub-bucket forced-exhaustion mode: Category O is decomposed into
|
|
70
|
+
Audit jl-cmd/claude-code-config PR #522 for **Category O only** (docstring / fixture-prose vs implementation drift). Skip A-N, P. Sub-bucket forced-exhaustion mode: Category O is decomposed into 8 sub-buckets below.
|
|
67
71
|
|
|
68
72
|
PR #522 split `pr_description_command_parser.py` into two modules — the original parser and a new `pr_description_pr_number.py` — but the originating module's docstring still claims the PR-number recovery responsibility. A sibling change to `pr_description_body_audit.py` introduced a module docstring whose verb (`detects vague language`) overstates the module's actual responsibility (it only exposes `_extract_vague_scan_text()`; detection runs elsewhere).
|
|
69
73
|
|
|
@@ -24,6 +24,7 @@ if _hooks_dir not in sys.path:
|
|
|
24
24
|
sys.path.insert(0, _hooks_dir)
|
|
25
25
|
|
|
26
26
|
from hooks_constants.claude_md_orphan_file_blocker_constants import ( # noqa: E402
|
|
27
|
+
ALL_NOISE_DIRECTORY_NAMES,
|
|
27
28
|
ALL_REFERENCED_FILE_EXTENSIONS,
|
|
28
29
|
CLAUDE_MD_FILENAME,
|
|
29
30
|
CODE_FENCE_PATTERN,
|
|
@@ -237,13 +238,35 @@ class _SubtreeScan:
|
|
|
237
238
|
self.was_scan_complete = was_scan_complete
|
|
238
239
|
|
|
239
240
|
|
|
241
|
+
def _is_under_noise_directory(scan_root: Path, candidate_path: Path) -> bool:
|
|
242
|
+
"""Return whether *candidate_path* lies inside a pruned noise directory.
|
|
243
|
+
|
|
244
|
+
A noise directory (``.git``, ``__pycache__``, ``node_modules``, and the test
|
|
245
|
+
and lint caches) holds volatile generated files that no CLAUDE.md table
|
|
246
|
+
documents, so the walk skips them. This keeps generated files out of the
|
|
247
|
+
basename set and keeps them from consuming the scan budget.
|
|
248
|
+
|
|
249
|
+
Args:
|
|
250
|
+
scan_root: The directory the walk descends from.
|
|
251
|
+
candidate_path: A path the walk yielded under the scan root.
|
|
252
|
+
|
|
253
|
+
Returns:
|
|
254
|
+
True when any path segment below *scan_root* names a noise directory.
|
|
255
|
+
"""
|
|
256
|
+
try:
|
|
257
|
+
relative_segments = candidate_path.relative_to(scan_root).parts
|
|
258
|
+
except ValueError:
|
|
259
|
+
relative_segments = candidate_path.parts
|
|
260
|
+
return any(each_segment in ALL_NOISE_DIRECTORY_NAMES for each_segment in relative_segments)
|
|
261
|
+
|
|
262
|
+
|
|
240
263
|
def _scan_subtree_basenames(scan_root: Path) -> _SubtreeScan:
|
|
241
264
|
"""Return the bounded basename scan of *scan_root*, skipping unreadable entries.
|
|
242
265
|
|
|
243
266
|
Walks the subtree collecting each file's basename, stopping once the scan
|
|
244
|
-
budget is reached. A
|
|
245
|
-
|
|
246
|
-
set is authoritative.
|
|
267
|
+
budget is reached. A path inside a noise directory is pruned, and a per-entry
|
|
268
|
+
stat error skips that entry. The result records whether the walk completed
|
|
269
|
+
within the budget, so the caller knows whether the set is authoritative.
|
|
247
270
|
|
|
248
271
|
Args:
|
|
249
272
|
scan_root: The directory whose subtree bounds the existence search.
|
|
@@ -254,6 +277,8 @@ def _scan_subtree_basenames(scan_root: Path) -> _SubtreeScan:
|
|
|
254
277
|
all_basenames: set[str] = set()
|
|
255
278
|
scanned_count = 0
|
|
256
279
|
for each_path in scan_root.rglob("*"):
|
|
280
|
+
if _is_under_noise_directory(scan_root, each_path):
|
|
281
|
+
continue
|
|
257
282
|
try:
|
|
258
283
|
if not each_path.is_file():
|
|
259
284
|
continue
|
|
@@ -270,7 +295,9 @@ def _filename_exists_under(scan_root: Path, filename: str) -> bool:
|
|
|
270
295
|
"""Return whether a file with basename *filename* exists anywhere under root.
|
|
271
296
|
|
|
272
297
|
A direct probe that resolves one filename deterministically even when the
|
|
273
|
-
bounded subtree walk was truncated.
|
|
298
|
+
bounded subtree walk was truncated. A match inside a noise directory is pruned
|
|
299
|
+
so the probe agrees with the bounded walk, and an unreadable entry mid-walk is
|
|
300
|
+
skipped.
|
|
274
301
|
|
|
275
302
|
Args:
|
|
276
303
|
scan_root: The directory whose subtree bounds the existence search.
|
|
@@ -280,6 +307,8 @@ def _filename_exists_under(scan_root: Path, filename: str) -> bool:
|
|
|
280
307
|
True when at least one matching file is reachable under the scan root.
|
|
281
308
|
"""
|
|
282
309
|
for each_match in scan_root.rglob(filename):
|
|
310
|
+
if _is_under_noise_directory(scan_root, each_match):
|
|
311
|
+
continue
|
|
283
312
|
try:
|
|
284
313
|
if each_match.is_file():
|
|
285
314
|
return True
|
|
@@ -327,9 +356,10 @@ def find_missing_filenames(content: str, claude_md_directory: Path) -> list[str]
|
|
|
327
356
|
siblings. A table block that declares an explicit relative-path source (a
|
|
328
357
|
``../`` token in the block or the prose that introduces it) yields no findings
|
|
329
358
|
for that block's rows, since those files legitimately live elsewhere; an
|
|
330
|
-
unrelated block in the same file is still checked.
|
|
331
|
-
|
|
332
|
-
|
|
359
|
+
unrelated block in the same file is still checked. When the content references
|
|
360
|
+
no bare filename, no findings result and the subtree walk is skipped. A
|
|
361
|
+
filesystem error that halts the whole subtree walk yields no findings (fail
|
|
362
|
+
open), so an unreadable tree never blocks a write.
|
|
333
363
|
|
|
334
364
|
Args:
|
|
335
365
|
content: The CLAUDE.md content being written.
|
|
@@ -340,6 +370,8 @@ def find_missing_filenames(content: str, claude_md_directory: Path) -> list[str]
|
|
|
340
370
|
first-seen order with duplicates removed, capped at the issue budget.
|
|
341
371
|
"""
|
|
342
372
|
referenced_filenames = find_referenced_filenames(content)
|
|
373
|
+
if not referenced_filenames:
|
|
374
|
+
return []
|
|
343
375
|
scan_root = _resolve_scan_root(claude_md_directory)
|
|
344
376
|
try:
|
|
345
377
|
present_filenames = _present_referenced_filenames(referenced_filenames, scan_root)
|
|
@@ -576,6 +576,42 @@ def test_oserror_during_subtree_walk_fails_open(
|
|
|
576
576
|
assert missing_filenames == []
|
|
577
577
|
|
|
578
578
|
|
|
579
|
+
def test_no_referenced_filenames_performs_no_subtree_walk(
|
|
580
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
581
|
+
):
|
|
582
|
+
def _fail_if_walked(self, pattern):
|
|
583
|
+
raise AssertionError("rglob walked the subtree with no filenames to verify")
|
|
584
|
+
|
|
585
|
+
monkeypatch.setattr(Path, "rglob", _fail_if_walked)
|
|
586
|
+
claude_md_path = tmp_path / "CLAUDE.md"
|
|
587
|
+
content = (
|
|
588
|
+
"# example\n\n"
|
|
589
|
+
"| Task | Command |\n"
|
|
590
|
+
"|---|---|\n"
|
|
591
|
+
"| Run the tests | npm test |\n"
|
|
592
|
+
)
|
|
593
|
+
missing_filenames = find_missing_filenames(content, claude_md_path.parent)
|
|
594
|
+
assert missing_filenames == []
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def test_noise_directories_are_excluded_from_the_walk(tmp_path: Path):
|
|
598
|
+
package_directory = tmp_path / "package_directory"
|
|
599
|
+
package_directory.mkdir()
|
|
600
|
+
for each_noise_name in (".git", "__pycache__", "node_modules", ".pytest_cache", ".ruff_cache"):
|
|
601
|
+
noise_directory = tmp_path / each_noise_name
|
|
602
|
+
noise_directory.mkdir()
|
|
603
|
+
(noise_directory / "buried_target.py").write_text("x = 1\n", encoding="utf-8")
|
|
604
|
+
claude_md_path = package_directory / "CLAUDE.md"
|
|
605
|
+
content = (
|
|
606
|
+
"# example\n\n"
|
|
607
|
+
"| File | Note |\n"
|
|
608
|
+
"|---|---|\n"
|
|
609
|
+
"| `buried_target.py` | only present inside noise directories |\n"
|
|
610
|
+
)
|
|
611
|
+
missing_filenames = find_missing_filenames(content, claude_md_path.parent)
|
|
612
|
+
assert missing_filenames == ["buried_target.py"]
|
|
613
|
+
|
|
614
|
+
|
|
579
615
|
def test_blocker_module_has_no_collection_parameter_naming_violations():
|
|
580
616
|
blocker_source = Path(HOOK_SCRIPT_PATH).read_text(encoding="utf-8")
|
|
581
617
|
assert check_collection_prefix(blocker_source, HOOK_SCRIPT_PATH) == []
|
|
@@ -7,7 +7,7 @@ Native git hooks that run outside the Claude Code lifecycle — invoked directly
|
|
|
7
7
|
| File | Git hook | What it does |
|
|
8
8
|
|---|---|---|
|
|
9
9
|
| `pre_commit.py` | `pre-commit` | Runs the CODE_RULES gate (`precommit_code_rules_gate.py`) over staged changes; exits 1 when any staged file has a blocking violation |
|
|
10
|
-
| `pre_push.py` | `pre-push` |
|
|
10
|
+
| `pre_push.py` | `pre-push` | Blocks a push that would land a non-`main` local branch onto remote `main` (or `master`), then runs the CODE_RULES gate over the commits about to be pushed |
|
|
11
11
|
| `post_commit.py` | `post-commit` | Runs after a commit lands; performs any post-commit bookkeeping |
|
|
12
12
|
| `gate_utils.py` | — | Shared helpers: resolves the gate script path, checks that the path is a safe regular file |
|
|
13
13
|
| `test_config.py` | — | Test configuration helpers |
|
|
@@ -16,6 +16,8 @@ DEFAULT_REMOTE_BASE_REFERENCE: str = "origin/HEAD"
|
|
|
16
16
|
ALL_ZEROS_OBJECT_NAME_CHARACTER: str = "0"
|
|
17
17
|
STDIN_LINE_FIELD_COUNT: int = 4
|
|
18
18
|
STDIN_REMOTE_OBJECT_FIELD_INDEX: int = 3
|
|
19
|
+
LOCAL_REFERENCE_FIELD_INDEX: int = 0
|
|
20
|
+
REMOTE_REFERENCE_FIELD_INDEX: int = 2
|
|
19
21
|
GATE_PATH_OVERRIDE_ENV_VAR: str = "CODE_RULES_GATE_PATH"
|
|
20
22
|
CLAUDE_HOME_ENV_VAR: str = "CLAUDE_HOME"
|
|
21
23
|
CLAUDE_HOME_DEFAULT_SUBDIRECTORY: str = ".claude"
|
|
@@ -46,3 +48,14 @@ NO_PARSEABLE_STDIN_LINES_MESSAGE: str = (
|
|
|
46
48
|
"claude-dev-env pre-push: no parseable stdin lines; aborting"
|
|
47
49
|
)
|
|
48
50
|
NO_PARSEABLE_STDIN_LINES_SENTINEL: str = "__no_parseable_stdin_lines__"
|
|
51
|
+
LOCAL_BRANCH_REFERENCE_PREFIX: str = "refs/heads/"
|
|
52
|
+
ALL_PROTECTED_BRANCH_PUSH_NAMES: tuple[str, ...] = ("main", "master")
|
|
53
|
+
PROTECTED_BRANCH_PUSH_BLOCK_EXIT_CODE: int = 1
|
|
54
|
+
PROTECTED_BRANCH_PUSH_BLOCK_MESSAGE: str = (
|
|
55
|
+
"claude-dev-env pre-push: blocked a push of local branch {local_branch!r} "
|
|
56
|
+
"onto protected remote branch {remote_branch!r}.\n"
|
|
57
|
+
"A local branch that tracks origin/{remote_branch}, with push.default=upstream, "
|
|
58
|
+
"resolves a bare 'git push' to {remote_branch}.\n"
|
|
59
|
+
"To push the feature branch to its own ref, name the destination: "
|
|
60
|
+
"git push origin {local_branch}:refs/heads/{local_branch}"
|
|
61
|
+
)
|
|
@@ -1,20 +1,30 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Git pre-push hook:
|
|
2
|
+
"""Git pre-push hook: guard the push destination, then run the CODE_RULES gate.
|
|
3
3
|
|
|
4
4
|
Installed to the user's shared git-hooks directory via the claude-dev-env
|
|
5
5
|
installer; git invokes this file as `pre-push` (the installer strips the
|
|
6
6
|
`_` and `.py` suffix when copying into the live hooks path).
|
|
7
7
|
|
|
8
8
|
Protocol: git pre-push provides remote name and URL as argv, then writes
|
|
9
|
-
`<local-ref> <local-sha> <remote-ref> <remote-sha>` lines on stdin.
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
`<local-ref> <local-sha> <remote-ref> <remote-sha>` lines on stdin.
|
|
10
|
+
|
|
11
|
+
Destination guard: any line that pushes a local branch onto a protected
|
|
12
|
+
remote branch (`main` or `master`) whose name differs from the local
|
|
13
|
+
branch is blocked before the gate runs. This catches a branch that tracks
|
|
14
|
+
`origin/main` under `push.default=upstream`, where a bare `git push`
|
|
15
|
+
resolves to `main`. The guard runs whether or not the CODE_RULES gate is
|
|
16
|
+
installed; deletions and same-name pushes pass.
|
|
17
|
+
|
|
18
|
+
Gate base: the first non-zero remote-sha is used as the gate `--base`, so
|
|
19
|
+
violations are scoped to commits that are not already on the remote. When
|
|
20
|
+
every remote object name is zero (new branch) or stdin is empty, the gate
|
|
21
|
+
falls back to the remote's default branch symbolic ref.
|
|
14
22
|
|
|
15
23
|
Exit codes:
|
|
16
|
-
0 -
|
|
17
|
-
|
|
24
|
+
0 - the push destination is allowed and its commits pass the gate (or
|
|
25
|
+
the gate is not installed).
|
|
26
|
+
1 - the push would land a non-protected local branch onto a protected
|
|
27
|
+
remote branch, or a commit introduces a blocking violation.
|
|
18
28
|
2 - unexpected invocation failure (e.g., subprocess could not launch).
|
|
19
29
|
"""
|
|
20
30
|
|
|
@@ -25,16 +35,22 @@ import sys
|
|
|
25
35
|
from pathlib import Path
|
|
26
36
|
|
|
27
37
|
from git_hooks_constants import (
|
|
38
|
+
ALL_PROTECTED_BRANCH_PUSH_NAMES,
|
|
28
39
|
ALL_ZEROS_OBJECT_NAME_CHARACTER,
|
|
29
40
|
BASE_REFERENCE_ARGUMENT,
|
|
30
41
|
DEFAULT_REMOTE_BASE_REFERENCE,
|
|
31
42
|
GATE_INFRASTRUCTURE_FAILURE_EXIT_CODE,
|
|
32
43
|
INVOKE_GATE_FAILURE_MESSAGE,
|
|
44
|
+
LOCAL_BRANCH_REFERENCE_PREFIX,
|
|
45
|
+
LOCAL_REFERENCE_FIELD_INDEX,
|
|
33
46
|
LOCAL_SHA_FIELD_INDEX,
|
|
34
47
|
MALFORMED_STDIN_LINE_MESSAGE,
|
|
35
48
|
NO_PARSEABLE_STDIN_LINES_MESSAGE,
|
|
36
49
|
NO_PARSEABLE_STDIN_LINES_SENTINEL,
|
|
37
50
|
PRE_PUSH_GATE_SCRIPT_NOT_FOUND_MESSAGE,
|
|
51
|
+
PROTECTED_BRANCH_PUSH_BLOCK_EXIT_CODE,
|
|
52
|
+
PROTECTED_BRANCH_PUSH_BLOCK_MESSAGE,
|
|
53
|
+
REMOTE_REFERENCE_FIELD_INDEX,
|
|
38
54
|
STDIN_LINE_FIELD_COUNT,
|
|
39
55
|
STDIN_READ_FAILURE_MESSAGE,
|
|
40
56
|
STDIN_REMOTE_OBJECT_FIELD_INDEX,
|
|
@@ -88,6 +104,36 @@ def resolve_base_reference_from_stdin(stdin_text: str) -> str | None:
|
|
|
88
104
|
return default_remote_base_reference
|
|
89
105
|
|
|
90
106
|
|
|
107
|
+
def find_protected_branch_push_violation(stdin_text: str) -> tuple[str, str] | None:
|
|
108
|
+
stdin_line_field_count = STDIN_LINE_FIELD_COUNT
|
|
109
|
+
local_reference_field_index = LOCAL_REFERENCE_FIELD_INDEX
|
|
110
|
+
local_sha_field_index = LOCAL_SHA_FIELD_INDEX
|
|
111
|
+
remote_reference_field_index = REMOTE_REFERENCE_FIELD_INDEX
|
|
112
|
+
local_branch_reference_prefix = LOCAL_BRANCH_REFERENCE_PREFIX
|
|
113
|
+
protected_branch_push_names = ALL_PROTECTED_BRANCH_PUSH_NAMES
|
|
114
|
+
for each_line in stdin_text.splitlines():
|
|
115
|
+
stripped_line = each_line.strip()
|
|
116
|
+
if not stripped_line:
|
|
117
|
+
continue
|
|
118
|
+
fields = stripped_line.split()
|
|
119
|
+
if len(fields) < stdin_line_field_count:
|
|
120
|
+
continue
|
|
121
|
+
if is_all_zeros_object_name(fields[local_sha_field_index]):
|
|
122
|
+
continue
|
|
123
|
+
local_branch_name = fields[local_reference_field_index].removeprefix(
|
|
124
|
+
local_branch_reference_prefix
|
|
125
|
+
)
|
|
126
|
+
remote_branch_name = fields[remote_reference_field_index].removeprefix(
|
|
127
|
+
local_branch_reference_prefix
|
|
128
|
+
)
|
|
129
|
+
if (
|
|
130
|
+
remote_branch_name in protected_branch_push_names
|
|
131
|
+
and local_branch_name != remote_branch_name
|
|
132
|
+
):
|
|
133
|
+
return (local_branch_name, remote_branch_name)
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
|
|
91
137
|
def invoke_gate(gate_script_path: Path, base_reference: str) -> int:
|
|
92
138
|
base_reference_argument = BASE_REFERENCE_ARGUMENT
|
|
93
139
|
invoke_gate_failure_message = INVOKE_GATE_FAILURE_MESSAGE
|
|
@@ -118,13 +164,8 @@ def main() -> int:
|
|
|
118
164
|
pre_push_gate_script_not_found_message = PRE_PUSH_GATE_SCRIPT_NOT_FOUND_MESSAGE
|
|
119
165
|
no_parseable_stdin_lines_message = NO_PARSEABLE_STDIN_LINES_MESSAGE
|
|
120
166
|
no_parseable_stdin_lines_sentinel = NO_PARSEABLE_STDIN_LINES_SENTINEL
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
print(
|
|
124
|
-
pre_push_gate_script_not_found_message.format(path=gate_script_path),
|
|
125
|
-
file=sys.stderr,
|
|
126
|
-
)
|
|
127
|
-
return 0
|
|
167
|
+
protected_branch_push_block_message = PROTECTED_BRANCH_PUSH_BLOCK_MESSAGE
|
|
168
|
+
protected_branch_push_block_exit_code = PROTECTED_BRANCH_PUSH_BLOCK_EXIT_CODE
|
|
128
169
|
try:
|
|
129
170
|
stdin_text = sys.stdin.read()
|
|
130
171
|
except OSError as read_error:
|
|
@@ -133,6 +174,24 @@ def main() -> int:
|
|
|
133
174
|
file=sys.stderr,
|
|
134
175
|
)
|
|
135
176
|
return gate_infrastructure_failure_exit_code
|
|
177
|
+
protected_branch_push_violation = find_protected_branch_push_violation(stdin_text)
|
|
178
|
+
if protected_branch_push_violation is not None:
|
|
179
|
+
local_branch_name, remote_branch_name = protected_branch_push_violation
|
|
180
|
+
print(
|
|
181
|
+
protected_branch_push_block_message.format(
|
|
182
|
+
local_branch=local_branch_name,
|
|
183
|
+
remote_branch=remote_branch_name,
|
|
184
|
+
),
|
|
185
|
+
file=sys.stderr,
|
|
186
|
+
)
|
|
187
|
+
return protected_branch_push_block_exit_code
|
|
188
|
+
gate_script_path, exact_allowed_path = resolve_gate_script_path()
|
|
189
|
+
if not is_safe_regular_file(gate_script_path, exact_allowed_path):
|
|
190
|
+
print(
|
|
191
|
+
pre_push_gate_script_not_found_message.format(path=gate_script_path),
|
|
192
|
+
file=sys.stderr,
|
|
193
|
+
)
|
|
194
|
+
return 0
|
|
136
195
|
base_reference = resolve_base_reference_from_stdin(stdin_text)
|
|
137
196
|
if base_reference is None:
|
|
138
197
|
return 0
|
|
@@ -321,3 +321,121 @@ def test_invoke_gate_uses_resolved_path(
|
|
|
321
321
|
assert exit_code == 0
|
|
322
322
|
executed_path = recorded_path_file.read_text(encoding="utf-8")
|
|
323
323
|
assert executed_path == str(resolved_path)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def test_find_protected_branch_push_violation_flags_feature_branch_to_main() -> None:
|
|
327
|
+
stdin_text = (
|
|
328
|
+
f"refs/heads/feat/example {NON_ZERO_LOCAL_SHA} refs/heads/main {NON_ZERO_REMOTE_SHA_ONE}\n"
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
violation = pre_push.find_protected_branch_push_violation(stdin_text)
|
|
332
|
+
|
|
333
|
+
assert violation == ("feat/example", "main")
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def test_find_protected_branch_push_violation_flags_feature_branch_to_master() -> None:
|
|
337
|
+
stdin_text = (
|
|
338
|
+
f"refs/heads/topic {NON_ZERO_LOCAL_SHA} refs/heads/master {NON_ZERO_REMOTE_SHA_ONE}\n"
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
violation = pre_push.find_protected_branch_push_violation(stdin_text)
|
|
342
|
+
|
|
343
|
+
assert violation == ("topic", "master")
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def test_find_protected_branch_push_violation_allows_main_onto_main() -> None:
|
|
347
|
+
stdin_text = (
|
|
348
|
+
f"refs/heads/main {NON_ZERO_LOCAL_SHA} refs/heads/main {NON_ZERO_REMOTE_SHA_ONE}\n"
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
violation = pre_push.find_protected_branch_push_violation(stdin_text)
|
|
352
|
+
|
|
353
|
+
assert violation is None
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def test_find_protected_branch_push_violation_allows_feature_onto_own_ref() -> None:
|
|
357
|
+
stdin_text = (
|
|
358
|
+
f"refs/heads/feat/example {NON_ZERO_LOCAL_SHA} refs/heads/feat/example {NON_ZERO_REMOTE_SHA_ONE}\n"
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
violation = pre_push.find_protected_branch_push_violation(stdin_text)
|
|
362
|
+
|
|
363
|
+
assert violation is None
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def test_find_protected_branch_push_violation_ignores_deletion_of_main() -> None:
|
|
367
|
+
stdin_text = (
|
|
368
|
+
f"(delete) {ALL_ZEROS_OBJECT_NAME} refs/heads/main {NON_ZERO_REMOTE_SHA_ONE}\n"
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
violation = pre_push.find_protected_branch_push_violation(stdin_text)
|
|
372
|
+
|
|
373
|
+
assert violation is None
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def test_main_blocks_feature_branch_push_onto_main(
|
|
377
|
+
tmp_path: Path,
|
|
378
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
379
|
+
capsys: pytest.CaptureFixture[str],
|
|
380
|
+
) -> None:
|
|
381
|
+
passing_gate_path = tmp_path / "gate.py"
|
|
382
|
+
passing_gate_path.write_text("import sys\nsys.exit(0)\n", encoding="utf-8")
|
|
383
|
+
monkeypatch.setenv("CODE_RULES_GATE_PATH", str(passing_gate_path))
|
|
384
|
+
monkeypatch.setattr(
|
|
385
|
+
sys,
|
|
386
|
+
"stdin",
|
|
387
|
+
io.StringIO(
|
|
388
|
+
f"refs/heads/feat/example {NON_ZERO_LOCAL_SHA} refs/heads/main {NON_ZERO_REMOTE_SHA_ONE}\n"
|
|
389
|
+
),
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
exit_code = pre_push.main()
|
|
393
|
+
|
|
394
|
+
assert exit_code == git_hooks_constants.PROTECTED_BRANCH_PUSH_BLOCK_EXIT_CODE
|
|
395
|
+
captured = capsys.readouterr()
|
|
396
|
+
assert "feat/example" in captured.err
|
|
397
|
+
assert "main" in captured.err
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def test_main_blocks_protected_push_even_when_gate_script_missing(
|
|
401
|
+
tmp_path: Path,
|
|
402
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
403
|
+
capsys: pytest.CaptureFixture[str],
|
|
404
|
+
) -> None:
|
|
405
|
+
monkeypatch.setenv(
|
|
406
|
+
"CODE_RULES_GATE_PATH",
|
|
407
|
+
str(tmp_path / "does_not_exist.py"),
|
|
408
|
+
)
|
|
409
|
+
monkeypatch.setattr(
|
|
410
|
+
sys,
|
|
411
|
+
"stdin",
|
|
412
|
+
io.StringIO(
|
|
413
|
+
f"refs/heads/feat/example {NON_ZERO_LOCAL_SHA} refs/heads/main {NON_ZERO_REMOTE_SHA_ONE}\n"
|
|
414
|
+
),
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
exit_code = pre_push.main()
|
|
418
|
+
|
|
419
|
+
assert exit_code == git_hooks_constants.PROTECTED_BRANCH_PUSH_BLOCK_EXIT_CODE
|
|
420
|
+
captured = capsys.readouterr()
|
|
421
|
+
assert "feat/example" in captured.err
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def test_main_allows_main_onto_main_push(
|
|
425
|
+
tmp_path: Path,
|
|
426
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
427
|
+
) -> None:
|
|
428
|
+
passing_gate_path = tmp_path / "gate.py"
|
|
429
|
+
passing_gate_path.write_text("import sys\nsys.exit(0)\n", encoding="utf-8")
|
|
430
|
+
monkeypatch.setenv("CODE_RULES_GATE_PATH", str(passing_gate_path))
|
|
431
|
+
monkeypatch.setattr(
|
|
432
|
+
sys,
|
|
433
|
+
"stdin",
|
|
434
|
+
io.StringIO(
|
|
435
|
+
f"refs/heads/main {NON_ZERO_LOCAL_SHA} refs/heads/main {NON_ZERO_REMOTE_SHA_ONE}\n"
|
|
436
|
+
),
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
exit_code = pre_push.main()
|
|
440
|
+
|
|
441
|
+
assert exit_code == 0
|
|
@@ -8,8 +8,8 @@ subdirectories, and its siblings), the table points a reader at a file that is
|
|
|
8
8
|
not there. This module holds the patterns that find those cells, the filename
|
|
9
9
|
extensions that mark a cell as a file reference, the region-boundary marker that
|
|
10
10
|
scopes a prose region to one section, the relative-path marker that exempts a
|
|
11
|
-
cross-directory table block, the subtree
|
|
12
|
-
the hook emits.
|
|
11
|
+
cross-directory table block, the directory names the subtree walk prunes, the
|
|
12
|
+
subtree scan budget, and the block-message text the hook emits.
|
|
13
13
|
"""
|
|
14
14
|
|
|
15
15
|
import re
|
|
@@ -23,6 +23,7 @@ __all__ = [
|
|
|
23
23
|
"REGION_BOUNDARY_PATTERN",
|
|
24
24
|
"RELATIVE_PATH_SOURCE_PATTERN",
|
|
25
25
|
"ALL_REFERENCED_FILE_EXTENSIONS",
|
|
26
|
+
"ALL_NOISE_DIRECTORY_NAMES",
|
|
26
27
|
"MAX_SUBTREE_FILES_SCANNED",
|
|
27
28
|
"MAX_ORPHAN_FILE_ISSUES",
|
|
28
29
|
"ORPHAN_FILE_MESSAGE_TEMPLATE",
|
|
@@ -65,6 +66,16 @@ ALL_REFERENCED_FILE_EXTENSIONS: frozenset[str] = frozenset(
|
|
|
65
66
|
}
|
|
66
67
|
)
|
|
67
68
|
|
|
69
|
+
ALL_NOISE_DIRECTORY_NAMES: frozenset[str] = frozenset(
|
|
70
|
+
{
|
|
71
|
+
".git",
|
|
72
|
+
"__pycache__",
|
|
73
|
+
"node_modules",
|
|
74
|
+
".pytest_cache",
|
|
75
|
+
".ruff_cache",
|
|
76
|
+
}
|
|
77
|
+
)
|
|
78
|
+
|
|
68
79
|
MAX_SUBTREE_FILES_SCANNED: int = 5000
|
|
69
80
|
|
|
70
81
|
MAX_ORPHAN_FILE_ISSUES: int = 20
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Docstring Prose Matches Implementation
|
|
2
2
|
|
|
3
|
-
**When this applies:** Any Write or Edit to a public function, method, class, or module whose docstring prose makes an enumerable claim about behavior — a list of inputs the code handles, the conditions it treats as a match, the cases it skips, or the order of its steps.
|
|
3
|
+
**When this applies:** Any Write or Edit to a public function, method, class, or module whose docstring prose makes an enumerable claim about behavior — a list of inputs the code handles, the conditions it treats as a match, the cases it skips, or the order of its steps. It applies equally to a skill's companion `SKILL.md` (or any sibling `.md`) that describes a producer the skill's `scripts/` carry out: a doc sentence that claims a produced artifact's ordering or content is the prose this rule governs, and it tracks the producer function's own docstring and body.
|
|
4
4
|
|
|
5
5
|
## Rule
|
|
6
6
|
|
|
@@ -17,6 +17,7 @@ Read the body and the docstring side by side:
|
|
|
17
17
|
- **Shared fallback routes.** A summary that scopes a fallback call to one condition names every condition that reaches that call. When the body routes to the same fallback from two or more early-return guards (`if a is None: fallback(); return` and `if random() < p: fallback(); return`), the prose enumerates both guards. The `check_docstring_fallback_branch_coverage` gate blocks the single-condition form of this drift at Write/Edit time.
|
|
18
18
|
- **Step order.** A docstring that says `A then B then C` matches the call order in the body.
|
|
19
19
|
- **Predicate breadth.** A boolean helper whose prose promises a narrow check accepts only the inputs the prose names — no broader input class the name and prose do not mention.
|
|
20
|
+
- **Companion-doc ordering and content claims.** A `SKILL.md` (or sibling `.md`) sentence that names a produced artifact and claims its order (`sorted`, `alphabetical`, `in sorted order`) or its content (`the at-risk names`, `just the current set`) matches the producer function's docstring and body for that same artifact. A producer that builds the artifact by merging stored names with new names and appending — preserving file order, not re-sorting the union — leaves a doc that still says `sorted` drifted on both counts: the order claim is wrong, and the content claim hides the merged-in prior entries. When the producer's ordering or union changes, the same change updates the companion doc. The two move together in one commit, even when the producer edit does not touch the `.md` file.
|
|
20
21
|
|
|
21
22
|
When the body changes the set of behaviors it applies, the same edit updates the prose enumeration. The two move together in one commit.
|
|
22
23
|
|
|
@@ -39,6 +40,8 @@ A docstring that enumerates "attribute read, augmented-assignment target, class-
|
|
|
39
40
|
|
|
40
41
|
This drift class is sub-bucket **O6** in `packages/claude-dev-env/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md` (free-form `Note:` / `Returns:` / responsibility-list claims). The audit teammate lists every prose enumeration in a changed docstring and verifies each item against the body, and lists every union member / suppressor / step in the body and verifies each appears in the prose. A union member or suppressor in the body that the prose omits is an O6 finding. The single-condition shared-fallback shape of this drift is gated deterministically by `check_docstring_fallback_branch_coverage` (`packages/claude-dev-env/hooks/blocking/code_rules_docstrings.py`); the audit lane covers every O6 shape the gate cannot match.
|
|
41
42
|
|
|
43
|
+
When a changed PR touches a producer function whose ordering or union shifts, the O8 audit lane also reads that skill's companion `SKILL.md` and sibling `.md` docs for any sentence naming the same produced artifact. A doc sentence that claims the artifact is `sorted` or holds `just the at-risk names` while the producer merges prior names and appends without re-sorting is an O8 finding, even when the PR diff never touched the `.md` file — the behavior change orphaned the doc claim.
|
|
44
|
+
|
|
42
45
|
## Why
|
|
43
46
|
|
|
44
47
|
A docstring enumeration earns its place by being trustworthy. A complete list lets a reader reason about the function without scanning the body; a list missing one item is worse than no list, because it asserts completeness it does not have. Naming this standard makes the gap a first-class finding at write time and at audit, rather than a surprise a reader hits months later.
|