claude-dev-env 1.69.2 → 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 +1 -0
- package/hooks/blocking/claude_md_orphan_file_blocker.py +632 -0
- package/hooks/blocking/test_claude_md_orphan_file_blocker.py +623 -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.json +5 -0
- package/hooks/hooks_constants/CLAUDE.md +1 -0
- package/hooks/hooks_constants/claude_md_orphan_file_blocker_constants.py +107 -0
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/claude-md-orphan-file.md +24 -0
- package/rules/docstring-prose-matches-implementation.md +4 -1
- package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +36 -5
- package/skills/autoconverge/workflow/render_report.py +43 -5
- package/skills/autoconverge/workflow/test_render_report.py +43 -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
|
package/hooks/hooks.json
CHANGED
|
@@ -70,6 +70,11 @@
|
|
|
70
70
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/workflow_substitution_slot_blocker.py",
|
|
71
71
|
"timeout": 10
|
|
72
72
|
},
|
|
73
|
+
{
|
|
74
|
+
"type": "command",
|
|
75
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/claude_md_orphan_file_blocker.py",
|
|
76
|
+
"timeout": 10
|
|
77
|
+
},
|
|
73
78
|
{
|
|
74
79
|
"type": "command",
|
|
75
80
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/open_questions_in_plans_blocker.py",
|
|
@@ -11,6 +11,7 @@ Shared constant modules imported by hooks throughout the `hooks/` tree. Each fil
|
|
|
11
11
|
| `banned_identifiers_constants.py` | The set of banned short identifiers and banned function-name prefixes |
|
|
12
12
|
| `blocking_check_limits.py` | Max issue counts and preview lengths for blocking hooks |
|
|
13
13
|
| `bot_mention_comment_blocker_constants.py` | Patterns for detecting bot @-mentions in PR comments |
|
|
14
|
+
| `claude_md_orphan_file_blocker_constants.py` | Table patterns, file extensions, scan budget, and block-message text for the CLAUDE.md orphan-file blocker |
|
|
14
15
|
| `code_rules_enforcer_constants.py` | File-extension sets, test-path patterns, advisory line thresholds, boolean-name prefixes |
|
|
15
16
|
| `code_rules_path_utils_constants.py` | Path-matching helpers used by the code-rules check modules |
|
|
16
17
|
| `convergence_branch_constants.py` | Branch and worktree naming patterns for the convergence gate |
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Constants for the per-directory CLAUDE.md orphan-file-reference blocker.
|
|
2
|
+
|
|
3
|
+
A per-directory ``CLAUDE.md`` documents the files reachable from its own
|
|
4
|
+
directory in a markdown table whose first column names each file in backticks.
|
|
5
|
+
When a first-column cell names a bare filename that exists nowhere under the scan
|
|
6
|
+
root (the CLAUDE.md directory's parent, covering the directory, its
|
|
7
|
+
subdirectories, and its siblings), the table points a reader at a file that is
|
|
8
|
+
not there. This module holds the patterns that find those cells, the filename
|
|
9
|
+
extensions that mark a cell as a file reference, the region-boundary marker that
|
|
10
|
+
scopes a prose region to one section, the relative-path marker that exempts a
|
|
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
|
+
"""
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"CLAUDE_MD_FILENAME",
|
|
19
|
+
"TABLE_ROW_PATTERN",
|
|
20
|
+
"CODE_FENCE_PATTERN",
|
|
21
|
+
"FIRST_COLUMN_BACKTICK_PATTERN",
|
|
22
|
+
"SEPARATOR_CELL_PATTERN",
|
|
23
|
+
"REGION_BOUNDARY_PATTERN",
|
|
24
|
+
"RELATIVE_PATH_SOURCE_PATTERN",
|
|
25
|
+
"ALL_REFERENCED_FILE_EXTENSIONS",
|
|
26
|
+
"ALL_NOISE_DIRECTORY_NAMES",
|
|
27
|
+
"MAX_SUBTREE_FILES_SCANNED",
|
|
28
|
+
"MAX_ORPHAN_FILE_ISSUES",
|
|
29
|
+
"ORPHAN_FILE_MESSAGE_TEMPLATE",
|
|
30
|
+
"ORPHAN_FILE_SYSTEM_MESSAGE",
|
|
31
|
+
"ORPHAN_FILE_ADDITIONAL_CONTEXT",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
CLAUDE_MD_FILENAME: str = "CLAUDE.md"
|
|
35
|
+
|
|
36
|
+
TABLE_ROW_PATTERN: re.Pattern[str] = re.compile(r"^\s*\|")
|
|
37
|
+
|
|
38
|
+
CODE_FENCE_PATTERN: re.Pattern[str] = re.compile(r"^\s*(?:```|~~~)")
|
|
39
|
+
|
|
40
|
+
FIRST_COLUMN_BACKTICK_PATTERN: re.Pattern[str] = re.compile(r"`([^`]+)`")
|
|
41
|
+
|
|
42
|
+
SEPARATOR_CELL_PATTERN: re.Pattern[str] = re.compile(r"^[\s:\-]+$")
|
|
43
|
+
|
|
44
|
+
REGION_BOUNDARY_PATTERN: re.Pattern[str] = re.compile(r"^\s*#")
|
|
45
|
+
|
|
46
|
+
RELATIVE_PATH_SOURCE_PATTERN: re.Pattern[str] = re.compile(r"\.\.[\\/]")
|
|
47
|
+
|
|
48
|
+
ALL_REFERENCED_FILE_EXTENSIONS: frozenset[str] = frozenset(
|
|
49
|
+
{
|
|
50
|
+
".py",
|
|
51
|
+
".md",
|
|
52
|
+
".json",
|
|
53
|
+
".mjs",
|
|
54
|
+
".js",
|
|
55
|
+
".ts",
|
|
56
|
+
".ps1",
|
|
57
|
+
".cmd",
|
|
58
|
+
".ahk",
|
|
59
|
+
".yml",
|
|
60
|
+
".yaml",
|
|
61
|
+
".sh",
|
|
62
|
+
".txt",
|
|
63
|
+
".cfg",
|
|
64
|
+
".toml",
|
|
65
|
+
".ini",
|
|
66
|
+
}
|
|
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
|
+
|
|
79
|
+
MAX_SUBTREE_FILES_SCANNED: int = 5000
|
|
80
|
+
|
|
81
|
+
MAX_ORPHAN_FILE_ISSUES: int = 20
|
|
82
|
+
|
|
83
|
+
ORPHAN_FILE_MESSAGE_TEMPLATE: str = (
|
|
84
|
+
"CLAUDE.md table references files that exist nowhere under {directory}: "
|
|
85
|
+
"{missing}. A per-directory CLAUDE.md table names files in its own directory "
|
|
86
|
+
"subtree; a first-column cell naming a file absent from that subtree points a "
|
|
87
|
+
"reader at something that is not there. Drop the row, or correct the cell to "
|
|
88
|
+
"name a file that exists in this directory, a subdirectory of it, or a sibling "
|
|
89
|
+
"directory under its parent."
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
ORPHAN_FILE_SYSTEM_MESSAGE: str = (
|
|
93
|
+
"CLAUDE.md table names a file that does not exist in its directory subtree - "
|
|
94
|
+
"drop the row or name an existing file"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
ORPHAN_FILE_ADDITIONAL_CONTEXT: str = (
|
|
98
|
+
"Each first-column table cell wrapped in backticks that ends in a known file "
|
|
99
|
+
"extension must name a file present under the scan root: this CLAUDE.md's own "
|
|
100
|
+
"directory, a subdirectory of it, or a sibling directory under its parent. "
|
|
101
|
+
"Cells holding a path with a slash, a subdirectory ending in '/', or a "
|
|
102
|
+
"slash-command are out of scope. A table whose own block names an explicit "
|
|
103
|
+
"relative-path source (a '../' token) documents files outside the subtree and "
|
|
104
|
+
"is out of scope. For each missing file:\n"
|
|
105
|
+
" - delete the table row, or\n"
|
|
106
|
+
" - rename the cell to an existing file under the scan root."
|
|
107
|
+
)
|
package/package.json
CHANGED
package/rules/CLAUDE.md
CHANGED
|
@@ -9,6 +9,7 @@ Rule files installed into `~/.claude/rules/` by `bin/install.mjs`. Claude Code l
|
|
|
9
9
|
| `agent-spawn-protocol.md` | Protocol for spawning subagents: context sufficiency check, prompt generation via `/prompt-generator`, then spawn |
|
|
10
10
|
| `ask-user-question-required.md` | Every user-directed question goes through the `AskUserQuestion` tool — no plain-text questions |
|
|
11
11
|
| `bdd.md` | BDD discovery-driven development workflow and Example Mapping reference |
|
|
12
|
+
| `claude-md-orphan-file.md` | Every backticked bare filename in a per-directory `CLAUDE.md` table's first column names a file in that directory's subtree |
|
|
12
13
|
| `cleanup-temp-files.md` | Remove temporary files created during a task when the task is complete |
|
|
13
14
|
| `code-reviews.md` | Mandatory protocol for responding to GitHub PR review feedback |
|
|
14
15
|
| `code-standards.md` | Pointer to `CODE_RULES.md` as the single source of truth |
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Orphan File Reference in a Per-Directory CLAUDE.md
|
|
2
|
+
|
|
3
|
+
**When this applies:** Any Write, Edit, or MultiEdit to a file named `CLAUDE.md` that lists files in a markdown table whose first column names each file in backticks.
|
|
4
|
+
|
|
5
|
+
## Rule
|
|
6
|
+
|
|
7
|
+
Every bare filename a per-directory `CLAUDE.md` table names in its first column points at a file that exists in the directory subtree the `CLAUDE.md` describes. A first-column cell naming a file that exists nowhere in that subtree points a reader at something that is not there: the listing claims a file the directory does not hold.
|
|
8
|
+
|
|
9
|
+
When you add a table row, the file it names already exists in this directory or a subdirectory of it. When you remove a file, drop the row that named it.
|
|
10
|
+
|
|
11
|
+
## What the gate checks
|
|
12
|
+
|
|
13
|
+
The `claude_md_orphan_file_blocker.py` hook runs on every Write, Edit, and MultiEdit whose target basename is `CLAUDE.md`. It:
|
|
14
|
+
|
|
15
|
+
1. Reads the content the tool would leave on disk. For a Write that is the full `content`. For an Edit or MultiEdit it reconstructs the post-edit file — the existing on-disk file with the replacements applied — and also notes which orphans the file already held before the edit, so a pre-existing orphan on an untouched line is excluded and only an orphan the edit introduces is reported; when the existing file cannot be read, it scans the raw `new_string` fragment(s) instead.
|
|
16
|
+
2. Skips any line inside a fenced code block (between a ``` or `~~~` fence pair), since an example table there is documentation, not a live listing.
|
|
17
|
+
3. Takes the first column of each remaining markdown table row and keeps the cells that name a bare filename: wrapped in backticks, no path separator, not a slash-command, and ending in a known file extension (`.py`, `.md`, `.json`, `.mjs`, `.js`, `.ts`, `.ps1`, `.cmd`, `.ahk`, `.yml`, `.yaml`, `.sh`, `.txt`, `.cfg`, `.toml`, `.ini`).
|
|
18
|
+
4. Blocks the write when a named file exists nowhere under the scan root — the `CLAUDE.md` directory's parent, which covers the directory, its subdirectories, and its siblings. A filesystem error that halts the whole subtree walk fails open (the write proceeds), so an unreadable tree never blocks a write.
|
|
19
|
+
|
|
20
|
+
The check stays quiet for a target that is not a `CLAUDE.md`, for a table cell that holds a path, a subdirectory ending in `/`, or a slash-command, for a table row inside a fenced code block, and for a table whose content names an explicit relative-path source (a `../` token), since that table documents files that sit outside the subtree by design.
|
|
21
|
+
|
|
22
|
+
## Why this is a hook, not a lint pass
|
|
23
|
+
|
|
24
|
+
A table row that names an absent file reads as a contract: a reader trusts the listing to map the directory. A wrong row sends the reader looking for a file that is not there and erodes trust in every other row. Catching it as each row is written keeps the table and the directory in step.
|
|
@@ -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.
|
package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py
CHANGED
|
@@ -92,6 +92,16 @@ TIMELINE_TERMINAL_BAR_LABEL = "terminal"
|
|
|
92
92
|
|
|
93
93
|
CAUSE_MUTED_STYLE = "color:#94a3b8;"
|
|
94
94
|
|
|
95
|
+
ISSUE_ICON_BY_CATEGORY = {
|
|
96
|
+
"bug": "\U0001f41e",
|
|
97
|
+
"code-standard": "\U0001f4cf",
|
|
98
|
+
}
|
|
99
|
+
DEFAULT_ISSUE_ICON = "\U0001f41e"
|
|
100
|
+
|
|
101
|
+
SCORECARD_LABEL_CAUGHT = "caught"
|
|
102
|
+
SCORECARD_LABEL_ROUNDS = "rounds"
|
|
103
|
+
SCORECARD_LABEL_REMAINING = "left"
|
|
104
|
+
|
|
95
105
|
GITHUB_PR_URL_TEMPLATE = "https://github.com/{owner}/{repo}/pull/{number}"
|
|
96
106
|
|
|
97
107
|
SUMMARY_PR_COORDINATES_TEMPLATE = "owner={owner} repo={repo} PR #{pr_number} ({url})"
|
|
@@ -128,12 +138,13 @@ Write so a non-programmer understands every line. The reader has never seen the
|
|
|
128
138
|
- problemScenes/fixScenes example (a DIFFERENT project, to teach the style not the answer). Problem scene: trigger "export stops at batch 90", condition "you restart it", result "starts again at batch 1", caption "A halted export threw away the 90 batches it had already finished and began again." Mirroring fix scene: trigger "export stops at batch 90", condition "you restart it", result "continues at batch 91", caption "A restarted export now picks up at the next unfinished batch."
|
|
129
139
|
- GROUP near-duplicate findings into issue CLASSES: the same KIND of problem across different files or lines becomes ONE class with a count. Example: seven "Missing return type annotation on test function" findings become one class with count 7.
|
|
130
140
|
- TRANSLATE reviewer jargon into plain everyday English. Examples: "CodingGuidelineID 1000000 / Repository guideline (Types)" -> "a typing rule the project enforces"; "missing return type annotation / Add -> None" -> "a test did not declare what it returns"; "Banned identifier result" -> "a vague variable name the project bans"; a magic-value finding -> "a raw number or string that should be a named value".
|
|
131
|
-
-
|
|
141
|
+
- TRANSLATE internal-mechanism nouns the same way. "guard", "hook", "checker", "validator", "gate", "parser", "entry-point", "AST" each name a part of the machine a non-programmer never sees. Replace each with the everyday check it performs or, better, the effect it had: "the guard blocked the save" -> "saving the file was blocked"; "the hook fired" -> "the project's automatic check ran". Never leave a bare "guard", "hook", or "checker" in a sentence a reader sees.
|
|
142
|
+
- plainName: a short plain symptom a non-programmer recognizes - name what BROKE or what a person would notice, not the internal component. Carry NO tool token, rule id, file path, line number, severity code (P0/P1/P2), bot name, or internal-mechanism noun (guard, hook, checker, validator, gate, parser, AST, annotation). Lead with the visible effect. Good: "The install command quietly did nothing"; "Saving a file was blocked by mistake". Weak: "Entry-point guard misfires under symlinked bin invocation"; "The guard wrongly flagged a field".
|
|
132
143
|
- cause is the field that matters most. Sentence 1: the observable impact - what a person saw go wrong, or would have. Sentence 2: the cause in everyday terms. At most 2 sentences, no paragraphs.
|
|
133
144
|
- In cause describe the CONSEQUENCE, never the internal mechanism. Do not narrate control flow, comparisons, or internal state. Banned phrasings: "a check returned false", "two forms of the path", "treated the run as a non-run", "the values did not match", "a guard misfired". Say what that meant for the person instead.
|
|
134
145
|
- Prefer concrete everyday words: "the install command" and "on Mac and Linux" over "the launcher", "the entry-point guard", or "the symlink". Read each sentence as if aloud to a non-programmer; if they would not follow it, rewrite it.
|
|
135
146
|
- cause worked example (a DIFFERENT problem, to show the style, not the answer). WEAK, do not write like this: "A cached value's timestamp was compared across mismatched time zones, so the freshness check evaluated false and the entry was treated as stale and re-fetched on every request." STRONG, write like this: "Every page re-downloaded the same data from scratch instead of reusing the copy it already had, so pages loaded slower than needed. The app misjudged its saved copy as out of date."
|
|
136
|
-
- medium picks how the before/after panels are drawn: 'terminal' when the user-visible effect is command-line behavior; 'code' when the finding is best shown as a small before/after code snippet (e.g. a missing return type: before "def test_x():" / after "def test_x() -> None:"); 'text' otherwise.
|
|
147
|
+
- medium picks how the before/after panels are drawn: 'terminal' when the user-visible effect is command-line behavior; 'code' when the finding is best shown as a small before/after code snippet (e.g. a missing return type: before "def test_x():" / after "def test_x() -> None:"); 'text' otherwise. PREFER 'terminal' or 'text' so the panels show what a PERSON sees; reserve 'code' for the rare finding whose fix truly is a one-line code change a reader would recognize. When in doubt, choose 'text' and describe the effect in plain words, not source code.
|
|
137
148
|
- beforeLines and afterLines are the literal short lines to show in each panel - what a person sees. For a terminal: include the prompt + command + the (missing or present) output. For code: 1 to 4 lines before, 1 to 4 lines after. Keep every line short. Never fabricate exact output text that is not implied by the finding - if the exact text is unknown, show the shape (e.g. "(no output - nothing installed)"). Leave both arrays empty to fall back to the cause line alone.
|
|
138
149
|
- Lead with category 'bug' classes, then 'code-standard'. Create one class per distinct KIND of problem, however many that is; never merge different kinds or drop classes to hit a number.
|
|
139
150
|
- status is 'fixed' unless the fix summaries or the deferred code-standard note mark the class deferred, in which case status is 'deferred'.
|
|
@@ -148,6 +159,7 @@ HTML_DOCTYPE = "<!DOCTYPE html>"
|
|
|
148
159
|
HTML_HEAD_TEMPLATE = """\
|
|
149
160
|
<head>
|
|
150
161
|
<meta charset="utf-8">
|
|
162
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
151
163
|
<title>PR #{pr_number} Convergence Summary</title>
|
|
152
164
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
153
165
|
{style_block}
|
|
@@ -156,9 +168,10 @@ HTML_HEAD_TEMPLATE = """\
|
|
|
156
168
|
HTML_STYLE_BLOCK = """\
|
|
157
169
|
<style>
|
|
158
170
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
171
|
+
html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
|
|
159
172
|
body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; background: #f1f5f9; color: #334155; line-height: 1.6; padding: 48px 20px; }
|
|
160
173
|
.container { max-width: 860px; margin: 0 auto; }
|
|
161
|
-
h1 { font-size: 30px; font-weight: 800; color: #0f172a; letter-spacing: -0.5px; }
|
|
174
|
+
h1 { font-size: clamp(22px, 6vw, 30px); font-weight: 800; color: #0f172a; letter-spacing: -0.5px; line-height: 1.2; }
|
|
162
175
|
.subtitle { color: #64748b; font-size: 14px; margin: 6px 0 26px; }
|
|
163
176
|
h2 { font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.6px; color: #94a3b8; margin: 40px 0 16px; }
|
|
164
177
|
|
|
@@ -168,6 +181,14 @@ HTML_STYLE_BLOCK = """\
|
|
|
168
181
|
.verdict .vtext { font-size: 16px; font-weight: 700; color:#0f172a; }
|
|
169
182
|
.verdict .vsub { font-size: 13px; font-weight: 500; color:#15803d; }
|
|
170
183
|
|
|
184
|
+
/* scorecard */
|
|
185
|
+
.scorecard { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-top: 14px; }
|
|
186
|
+
.stat { background:#fff; border:1px solid #e2e8f0; border-radius:12px; padding:16px 14px; text-align:center; }
|
|
187
|
+
.stat-num { font-size: clamp(26px, 8vw, 34px); font-weight:800; color:#0f172a; line-height:1.1; }
|
|
188
|
+
.stat-label { font-size:12px; font-weight:600; text-transform:uppercase; letter-spacing:.5px; color:#94a3b8; margin-top:4px; }
|
|
189
|
+
.stat.good { border-color:#22c55e; background:linear-gradient(135deg,#f0fdf4,#dcfce7); }
|
|
190
|
+
.stat.good .stat-num { color:#16a34a; }
|
|
191
|
+
|
|
171
192
|
/* problem / fix scenes */
|
|
172
193
|
.pf-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
|
173
194
|
.pf { border-radius: 12px; padding: 18px 20px; border:1px solid #e2e8f0; background:#fff; }
|
|
@@ -186,8 +207,10 @@ HTML_STYLE_BLOCK = """\
|
|
|
186
207
|
.scene-cap:last-child { margin-bottom:0; }
|
|
187
208
|
|
|
188
209
|
/* bug class */
|
|
189
|
-
.bug-head { display:flex; align-items:
|
|
210
|
+
.bug-head { display:flex; align-items:center; justify-content:space-between; gap:12px; margin:32px 0 12px; }
|
|
190
211
|
.bug-head:first-of-type { margin-top:8px; }
|
|
212
|
+
.bug-title { display:flex; align-items:center; gap:8px; min-width:0; }
|
|
213
|
+
.bug-icon { font-size:18px; line-height:1; flex:0 0 auto; }
|
|
191
214
|
.bug-name { font-size:16px; font-weight:700; color:#0f172a; }
|
|
192
215
|
.bug-count { font-size:12px; font-weight:600; color:#64748b; background:#f1f5f9; border:1px solid #e2e8f0; border-radius:20px; padding:3px 11px; white-space:nowrap; flex:0 0 auto; }
|
|
193
216
|
|
|
@@ -222,5 +245,13 @@ HTML_STYLE_BLOCK = """\
|
|
|
222
245
|
|
|
223
246
|
footer { margin-top:40px; padding-top:16px; border-top:1px solid #e2e8f0; color:#94a3b8; font-size:12px; }
|
|
224
247
|
footer code { background:#e2e8f0; padding:1px 6px; border-radius:4px; font-family:'JetBrains Mono',monospace; }
|
|
225
|
-
@media (max-width:680px){
|
|
248
|
+
@media (max-width:680px){
|
|
249
|
+
body{ padding:24px 14px; }
|
|
250
|
+
.pf-grid,.term-grid{ grid-template-columns:1fr; }
|
|
251
|
+
.scorecard{ gap:8px; }
|
|
252
|
+
.stat{ padding:12px 8px; }
|
|
253
|
+
.stat-label{ font-size:11px; }
|
|
254
|
+
.bug-head{ flex-wrap:wrap; gap:6px; }
|
|
255
|
+
h2{ margin:28px 0 12px; }
|
|
256
|
+
}
|
|
226
257
|
</style>"""
|