claude-dev-env 1.70.0 → 1.72.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/CLAUDE.md +8 -0
- package/_shared/pr-loop/scripts/code_rules_gate.py +5 -3
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +39 -0
- package/agents/clean-coder.md +1 -0
- 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/docs/CODE_RULES.md +1 -1
- package/hooks/blocking/claude_md_orphan_file_blocker.py +39 -7
- package/hooks/blocking/code_rules_docstrings.py +60 -0
- package/hooks/blocking/code_rules_enforcer.py +4 -0
- package/hooks/blocking/code_rules_test_assertions.py +152 -1
- package/hooks/blocking/code_rules_type_escape.py +447 -2
- package/hooks/blocking/test_claude_md_orphan_file_blocker.py +36 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_no_consumer.py +93 -0
- package/hooks/blocking/test_code_rules_enforcer_object_parameter.py +499 -0
- package/hooks/blocking/test_code_rules_enforcer_stale_test_name.py +103 -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/blocking_check_limits.py +14 -0
- package/hooks/hooks_constants/claude_md_orphan_file_blocker_constants.py +13 -2
- package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +1 -1
- package/package.json +1 -1
- package/rules/docstring-prose-matches-implementation.md +5 -2
- package/scripts/CLAUDE.md +1 -0
- package/scripts/Show-Asset.ps1 +106 -0
- package/skills/autoconverge/SKILL.md +30 -3
- package/skills/autoconverge/reference/convergence.md +41 -1
- package/skills/autoconverge/workflow/converge.contract.test.mjs +90 -0
- package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +98 -0
- package/skills/autoconverge/workflow/converge.mjs +176 -6
- package/skills/bugteam/scripts/bugteam_code_rules_gate.py +47 -3
- package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +34 -0
|
@@ -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
|
|
@@ -29,6 +29,20 @@ MAX_E2E_TEST_NAMING_ISSUES: int = 3
|
|
|
29
29
|
DOCSTRING_TRIVIAL_FUNCTION_BODY_LINE_LIMIT: int = 3
|
|
30
30
|
MAX_DOCSTRING_FALLBACK_BRANCH_ISSUES: int = 3
|
|
31
31
|
DOCSTRING_FALLBACK_BRANCH_MINIMUM_ROUTE_COUNT: int = 2
|
|
32
|
+
MAX_DOCSTRING_NO_CONSUMER_CLAIM_ISSUES: int = 3
|
|
33
|
+
MAX_STALE_TEST_NAME_TARGET_ISSUES: int = 3
|
|
34
|
+
STALE_TEST_NAME_MINIMUM_SHARED_TOKEN_COUNT: int = 2
|
|
35
|
+
|
|
36
|
+
ALL_DOCSTRING_NO_CONSUMER_CLAIM_PHRASES: tuple[str, ...] = (
|
|
37
|
+
"no consumer reads",
|
|
38
|
+
"no consumer yet",
|
|
39
|
+
"no submission-run consumer reads",
|
|
40
|
+
"producer-only artifact",
|
|
41
|
+
"no reader consumes",
|
|
42
|
+
"nothing reads it yet",
|
|
43
|
+
"no one reads it yet",
|
|
44
|
+
"not yet read by any consumer",
|
|
45
|
+
)
|
|
32
46
|
|
|
33
47
|
ALL_DOCSTRING_EXCLUSIVE_SCOPE_PHRASES: tuple[str, ...] = (
|
|
34
48
|
"only when",
|
|
@@ -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
|
|
@@ -8,7 +8,7 @@ from pathlib import Path
|
|
|
8
8
|
|
|
9
9
|
GIT_DASH_C_COMMIT_PATTERN: str = r"git\s+-C\s+[\"']?[^\"';&|]+?[\"']?\s+commit\b"
|
|
10
10
|
GIT_COMMAND_TIMEOUT_SECONDS: int = 5
|
|
11
|
-
GATE_TIMEOUT_SECONDS: int =
|
|
11
|
+
GATE_TIMEOUT_SECONDS: int = 240
|
|
12
12
|
GATE_RELATIVE_PATH: Path = Path("_shared") / "pr-loop" / "scripts" / "code_rules_gate.py"
|
|
13
13
|
ALL_STAGED_PYTHON_FILES_COMMAND: tuple[str, ...] = (
|
|
14
14
|
"git",
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
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
|
|
|
7
7
|
When a docstring enumerates the behaviors a body applies, the enumeration covers every behavior the body applies. A reader trusts the list to be complete: an item the code applies but the prose omits is a silent gap that misleads every future reader and reviewer.
|
|
8
8
|
|
|
9
|
-
The gate validator `check_docstring_args_match_signature` covers the `Args:` section parameter names.
|
|
9
|
+
The gate validator `check_docstring_args_match_signature` covers the `Args:` section parameter names. Three more gate validators each cover one deterministic slice of the free-form prose. `check_docstring_fallback_branch_coverage` covers a summary that scopes a fallback to a single condition (`only when`, `falls back to ... when`) while the body routes to that same fallback call from two or more distinct early-return guards. `check_class_docstring_names_public_methods` covers a class whose docstring is a single summary line while the class exposes two or more public methods whose names the summary never spells out — the drift where a one-line class summary keeps naming its first feature after the class grows a second public entry point. `check_docstring_no_consumer_claim` covers a producer docstring asserting that no consumer reads its output yet (`producer-only artifact`, `no submission-run consumer reads it yet`) — a transitional claim that drifts the moment a reader lands and contradicts any companion `SKILL.md` that documents the consumer; this is the deterministic slice of the O8 companion-doc producer/consumer drift below. The remaining free-form prose — `"a field counts as read when ..."`, `"resolves to shared temp only"`, `"strip ceremony, then drop blockquotes"`, and module-level responsibility paragraphs — has no signature, method roster, or single structural shape to compare against, so the gate cannot catch its drift. This rule is the judgment standard for that prose; the audit lane below is the enforcement for everything outside the four gated slices.
|
|
10
10
|
|
|
11
11
|
## What to check before you write the docstring
|
|
12
12
|
|
|
@@ -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.
|
package/scripts/CLAUDE.md
CHANGED
|
@@ -18,6 +18,7 @@ Utility scripts installed into `~/.claude/scripts/` by `bin/install.mjs`. Each s
|
|
|
18
18
|
| `Migrate-ShellPolicy.ps1` | Applies automated fixes for common shell-policy violations found by the audit script |
|
|
19
19
|
| `Install-SweepEmptyDirs.ps1` | Registers `sweep_empty_dirs.py` as a scheduled task on Windows |
|
|
20
20
|
| `check.ps1` | Runs the full code-quality check suite |
|
|
21
|
+
| `Show-Asset.ps1` | Opens files on screen, sizing each image window to the image's pixel dimensions (scaled to fit the screen); non-image files open in their default application |
|
|
21
22
|
|
|
22
23
|
## Subdirectories
|
|
23
24
|
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
<#
|
|
2
|
+
.SYNOPSIS
|
|
3
|
+
Opens files on screen, sizing each image window to the image's own dimensions.
|
|
4
|
+
|
|
5
|
+
.DESCRIPTION
|
|
6
|
+
For every path given, an image opens in a window whose client area matches the
|
|
7
|
+
image's pixel size, scaled down to fit the primary screen's working area when the
|
|
8
|
+
image is larger than the screen. A small image gets a usable minimum window with
|
|
9
|
+
the picture centered at native size. Non-image files open in their registered
|
|
10
|
+
default application, and any file that cannot be loaded as an image falls back to
|
|
11
|
+
that default application too. Escape or the close button dismisses a window; the
|
|
12
|
+
process exits once every window is closed.
|
|
13
|
+
|
|
14
|
+
.PARAMETER Paths
|
|
15
|
+
One or more file paths to open.
|
|
16
|
+
#>
|
|
17
|
+
param(
|
|
18
|
+
[Parameter(ValueFromRemainingArguments = $true)]
|
|
19
|
+
[string[]]$Paths
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
23
|
+
Add-Type -AssemblyName System.Drawing
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
[System.Windows.Forms.Application]::SetHighDpiMode([System.Windows.Forms.HighDpiMode]::PerMonitorV2) | Out-Null
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
$null = $_
|
|
30
|
+
}
|
|
31
|
+
[System.Windows.Forms.Application]::EnableVisualStyles()
|
|
32
|
+
|
|
33
|
+
$imageExtensions = @('.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.tif', '.tiff', '.ico')
|
|
34
|
+
$screenMargin = 80
|
|
35
|
+
$minimumClientWidth = 220
|
|
36
|
+
$minimumClientHeight = 160
|
|
37
|
+
$openWindowCount = 0
|
|
38
|
+
|
|
39
|
+
foreach ($path in $Paths) {
|
|
40
|
+
if (-not (Test-Path -LiteralPath $path)) { continue }
|
|
41
|
+
$fullPath = (Resolve-Path -LiteralPath $path).Path
|
|
42
|
+
$extension = [System.IO.Path]::GetExtension($fullPath).ToLowerInvariant()
|
|
43
|
+
|
|
44
|
+
if ($imageExtensions -notcontains $extension) {
|
|
45
|
+
Invoke-Item -LiteralPath $fullPath
|
|
46
|
+
continue
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
$imageBytes = [System.IO.File]::ReadAllBytes($fullPath)
|
|
51
|
+
$imageStream = New-Object System.IO.MemoryStream(, $imageBytes)
|
|
52
|
+
$loadedImage = [System.Drawing.Image]::FromStream($imageStream)
|
|
53
|
+
$image = New-Object System.Drawing.Bitmap($loadedImage)
|
|
54
|
+
$loadedImage.Dispose()
|
|
55
|
+
$imageStream.Dispose()
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
Invoke-Item -LiteralPath $fullPath
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
$workingArea = [System.Windows.Forms.Screen]::PrimaryScreen.WorkingArea
|
|
63
|
+
$maximumWidth = $workingArea.Width - $screenMargin
|
|
64
|
+
$maximumHeight = $workingArea.Height - $screenMargin
|
|
65
|
+
$scale = [Math]::Min(1.0, [Math]::Min($maximumWidth / $image.Width, $maximumHeight / $image.Height))
|
|
66
|
+
|
|
67
|
+
$pictureBox = New-Object System.Windows.Forms.PictureBox
|
|
68
|
+
$pictureBox.Dock = [System.Windows.Forms.DockStyle]::Fill
|
|
69
|
+
$pictureBox.Image = $image
|
|
70
|
+
|
|
71
|
+
if ($scale -lt 1.0) {
|
|
72
|
+
$pictureBox.SizeMode = [System.Windows.Forms.PictureBoxSizeMode]::Zoom
|
|
73
|
+
$clientWidth = [int][Math]::Round($image.Width * $scale)
|
|
74
|
+
$clientHeight = [int][Math]::Round($image.Height * $scale)
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
$pictureBox.SizeMode = [System.Windows.Forms.PictureBoxSizeMode]::CenterImage
|
|
78
|
+
$clientWidth = [Math]::Max($minimumClientWidth, $image.Width)
|
|
79
|
+
$clientHeight = [Math]::Max($minimumClientHeight, $image.Height)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
$form = New-Object System.Windows.Forms.Form
|
|
83
|
+
$form.Text = [System.IO.Path]::GetFileName($fullPath)
|
|
84
|
+
$form.AutoScaleMode = [System.Windows.Forms.AutoScaleMode]::None
|
|
85
|
+
$form.StartPosition = [System.Windows.Forms.FormStartPosition]::CenterScreen
|
|
86
|
+
$form.ClientSize = New-Object System.Drawing.Size($clientWidth, $clientHeight)
|
|
87
|
+
$form.KeyPreview = $true
|
|
88
|
+
$form.BackColor = [System.Drawing.Color]::FromArgb(24, 24, 24)
|
|
89
|
+
$form.Controls.Add($pictureBox)
|
|
90
|
+
|
|
91
|
+
$form.Add_KeyDown({
|
|
92
|
+
param($sender, $eventArguments)
|
|
93
|
+
if ($eventArguments.KeyCode -eq [System.Windows.Forms.Keys]::Escape) { $sender.Close() }
|
|
94
|
+
})
|
|
95
|
+
$form.Add_FormClosed({
|
|
96
|
+
$script:openWindowCount--
|
|
97
|
+
if ($script:openWindowCount -le 0) { [System.Windows.Forms.Application]::Exit() }
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
$openWindowCount++
|
|
101
|
+
$form.Show()
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if ($openWindowCount -gt 0) {
|
|
105
|
+
[System.Windows.Forms.Application]::Run()
|
|
106
|
+
}
|
|
@@ -101,7 +101,7 @@ own. The workflow runs in the background and notifies this session on
|
|
|
101
101
|
completion. Watch live progress with `/workflows`.
|
|
102
102
|
|
|
103
103
|
The workflow returns
|
|
104
|
-
`{ converged, rounds, finalSha, blocker, standardsNote, copilotNote }`.
|
|
104
|
+
`{ converged, rounds, finalSha, blocker, standardsNote, copilotNote, reuseNote }`.
|
|
105
105
|
|
|
106
106
|
## Budget-aware round boundaries
|
|
107
107
|
|
|
@@ -207,8 +207,31 @@ round records nothing resumable and replays dirty.
|
|
|
207
207
|
Blocker: <blocker> # only when blocked
|
|
208
208
|
Standards: <standardsNote> # only when a round deferred code-standard findings
|
|
209
209
|
Copilot: <copilotNote> # only when Copilot was down or out of quota
|
|
210
|
+
Reuse: <reuseNote> # only when the reuse pass identified an improvement
|
|
210
211
|
```
|
|
211
212
|
|
|
213
|
+
## Reuse pass (before convergence)
|
|
214
|
+
|
|
215
|
+
Before the first round, one reuse lens (`code-quality-agent`) scans the full
|
|
216
|
+
`origin/main...HEAD` diff for places the PR re-implements behavior the codebase
|
|
217
|
+
already provides. It reports a reuse improvement only when all three criteria
|
|
218
|
+
hold, and drops any case where even one is in doubt:
|
|
219
|
+
|
|
220
|
+
- **Certain** — an existing symbol or module unquestionably covers the new
|
|
221
|
+
code's behavior, cited at `file:line`.
|
|
222
|
+
- **Behaviorally identical** — swapping the new code for the existing one
|
|
223
|
+
changes no observable behavior: same inputs, outputs, side effects, and error
|
|
224
|
+
handling.
|
|
225
|
+
- **Autonomously implementable** — the replacement is a mechanical edit (import
|
|
226
|
+
and call the existing symbol, delete the duplicate) needing no product
|
|
227
|
+
decision and no human judgment.
|
|
228
|
+
|
|
229
|
+
The reuse lens reports without editing. Qualifying improvements then run through
|
|
230
|
+
the same edit → verify → commit fix flow the rounds use, so they land in one
|
|
231
|
+
verified commit before convergence starts. The pass is best-effort: when no case
|
|
232
|
+
clears all three criteria, the run proceeds straight to convergence, and
|
|
233
|
+
`reuseNote` records what landed.
|
|
234
|
+
|
|
212
235
|
## What the workflow does each round
|
|
213
236
|
|
|
214
237
|
See [`reference/convergence.md`](reference/convergence.md) for the full round
|
|
@@ -227,8 +250,12 @@ suite (`python -m pytest`) and keep scratch work in ephemeral temp dirs.
|
|
|
227
250
|
- **Converge:** `parallel([Bugbot lens, code-review lens, bug-audit lens])` on
|
|
228
251
|
the current HEAD, full `origin/main...HEAD` diff. Dedup findings; one
|
|
229
252
|
`clean-coder` applies all fixes in a single commit, pushes, replies to and
|
|
230
|
-
resolves any bot threads; re-verify next round on the new HEAD.
|
|
231
|
-
|
|
253
|
+
resolves any bot threads; re-verify next round on the new HEAD. Every edit
|
|
254
|
+
step ends with a pre-commit gate check: before its turn ends, the fixer
|
|
255
|
+
dry-runs the CODE_RULES commit gate (`code_rules_gate.py --staged`) and keeps
|
|
256
|
+
fixing until that gate would accept the commit — it makes no commit itself.
|
|
257
|
+
When all three are clean on a stable HEAD, post the CLEAN bugteam audit
|
|
258
|
+
artifact.
|
|
232
259
|
A round whose findings are ALL code-standard violations (pure CODE_RULES/style,
|
|
233
260
|
no behavioral impact) passes for convergence purposes: the workflow files a
|
|
234
261
|
follow-up issue listing the findings, opens a draft environment-hardening PR
|
|
@@ -1,5 +1,42 @@
|
|
|
1
1
|
# Convergence — round shape and the ready definition
|
|
2
2
|
|
|
3
|
+
## Pre-flight: clear merge conflicts
|
|
4
|
+
|
|
5
|
+
Before the first round, the workflow checks once whether the PR branch conflicts
|
|
6
|
+
with `origin/main`. When GitHub reports a conflict (`mergeable` false or
|
|
7
|
+
`mergeable_state` dirty), one `clean-coder` rebases the branch onto `origin/main`
|
|
8
|
+
and resolves every conflict — gated the same way as every other code change: the
|
|
9
|
+
edit leaves the rebase in the working tree, a `code-verifier` binds a verdict to
|
|
10
|
+
it, and the commit step force-pushes with lease. The bug checks then run on a
|
|
11
|
+
conflict-free diff.
|
|
12
|
+
|
|
13
|
+
A PR that merges cleanly skips the rebase. A conflict that surfaces mid-run, when
|
|
14
|
+
`origin/main` advances during a later round, is caught by the convergence repair
|
|
15
|
+
at the end of the loop, which also rebases.
|
|
16
|
+
|
|
17
|
+
## Reuse pass (runs after the conflict pre-flight, before convergence)
|
|
18
|
+
|
|
19
|
+
One reuse lens (`code-quality-agent`) reviews the full `origin/main...HEAD` diff
|
|
20
|
+
for code that re-implements behavior the repository already provides. It reports a
|
|
21
|
+
reuse improvement only when all three criteria hold together, and omits any case
|
|
22
|
+
where even one is in doubt:
|
|
23
|
+
|
|
24
|
+
1. **Certain** — an existing symbol or module unquestionably covers the new
|
|
25
|
+
code's behavior, cited at `file:line`.
|
|
26
|
+
2. **Behaviorally the same** — swapping the new code for the existing one
|
|
27
|
+
changes no observable behavior: same inputs, outputs, side effects, and
|
|
28
|
+
error handling.
|
|
29
|
+
3. **Autonomously implementable** — the replacement is a mechanical edit (import
|
|
30
|
+
and call the existing symbol, drop the duplicate) needing no product
|
|
31
|
+
decision and no human judgment.
|
|
32
|
+
|
|
33
|
+
The lens reports without editing. Each qualifying improvement runs through the
|
|
34
|
+
same edit → verify → commit fix flow the rounds use, landing in one verified
|
|
35
|
+
commit before convergence begins. The pass is best-effort: when no case clears
|
|
36
|
+
all three criteria the run proceeds straight to convergence. Whatever the reuse
|
|
37
|
+
pass surfaces also joins the round findings, so the code-review lens re-checks
|
|
38
|
+
any improvement that did not land.
|
|
39
|
+
|
|
3
40
|
## The round loop
|
|
4
41
|
|
|
5
42
|
The workflow holds three states and moves between them until the PR is ready or
|
|
@@ -26,7 +63,10 @@ tracks CONVERGE passes only and is never the cap.
|
|
|
26
63
|
colliding threads.
|
|
27
64
|
4. **Any findings** → one `clean-coder` applies every fix in a single test-first
|
|
28
65
|
commit, pushes, then replies to and resolves each finding that carries a
|
|
29
|
-
GitHub review thread.
|
|
66
|
+
GitHub review thread. Before its turn ends, the edit step dry-runs the
|
|
67
|
+
CODE_RULES commit gate (`code_rules_gate.py --staged`) over its staged
|
|
68
|
+
changes and keeps fixing until that gate would accept the commit, so the
|
|
69
|
+
later commit step never hits a gate rejection. A round progresses when the fix lens lands a push that
|
|
30
70
|
moves HEAD, or when every finding was already addressed so no code change is
|
|
31
71
|
needed yet each finding thread is still resolved (the fix lens reports
|
|
32
72
|
`resolvedWithoutCommit` and the run re-converges on the unchanged HEAD). A
|