claude-dev-env 1.78.0 → 1.79.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-k-codebase-conflicts.md +1 -0
- package/bin/install.mjs +1 -0
- package/bin/install.test.mjs +3 -2
- package/hooks/blocking/CLAUDE.md +3 -2
- package/hooks/blocking/code_rules_docstrings.py +609 -16
- package/hooks/blocking/code_rules_enforcer.py +37 -0
- package/hooks/blocking/code_rules_naming_collection.py +76 -1
- package/hooks/blocking/code_rules_paired_test.py +240 -22
- package/hooks/blocking/code_rules_test_assertions.py +159 -1
- package/hooks/blocking/env_var_table_code_drift_blocker.py +475 -0
- package/hooks/blocking/package_inventory_stale_blocker.py +54 -15
- package/hooks/blocking/test_code_rules_enforcer_docstring_field_runmode_outcome.py +129 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_length_constant_superlative.py +198 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_no_network.py +115 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_unreferenced_param.py +160 -0
- package/hooks/blocking/test_code_rules_enforcer_module_docstring_data_schema_scope.py +82 -0
- package/hooks/blocking/test_code_rules_enforcer_paired_test.py +190 -0
- package/hooks/blocking/test_code_rules_enforcer_polarity_name_contradiction.py +76 -0
- package/hooks/blocking/test_code_rules_enforcer_vacuous_cleanup_assertion.py +132 -0
- package/hooks/blocking/test_env_var_table_code_drift_blocker.py +94 -0
- package/hooks/blocking/test_package_inventory_stale_blocker.py +46 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +7 -7
- package/hooks/hooks_constants/CLAUDE.md +1 -0
- package/hooks/hooks_constants/blocking_check_limits.py +78 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +14 -0
- package/hooks/hooks_constants/env_var_table_code_drift_constants.py +64 -0
- package/hooks/hooks_constants/package_inventory_stale_blocker_constants.py +20 -9
- package/hooks/hooks_constants/paired_test_coverage_constants.py +11 -3
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/docstring-prose-matches-implementation.md +56 -54
- package/rules/env-var-table-code-drift.md +24 -0
- package/rules/package-inventory-stale-entry.md +4 -4
- package/rules/paired-test-coverage.md +12 -5
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Tests for the env-var-table code-drift blocker.
|
|
2
|
+
|
|
3
|
+
Each test builds a real on-disk directory holding a code file and a markdown
|
|
4
|
+
doc, then drives the blocker's detection function against that tree. The drift
|
|
5
|
+
the blocker catches: a markdown env-var summary table row attributes an
|
|
6
|
+
environment variable to a code file whose source never references that variable.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
_blocking_dir = str(Path(__file__).resolve().parent)
|
|
15
|
+
if _blocking_dir not in sys.path:
|
|
16
|
+
sys.path.insert(0, _blocking_dir)
|
|
17
|
+
|
|
18
|
+
from env_var_table_code_drift_blocker import ( # noqa: E402
|
|
19
|
+
find_drift_rows,
|
|
20
|
+
is_markdown_file,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _write(file_path: Path, content: str) -> None:
|
|
25
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
file_path.write_text(content, encoding="utf-8")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _anchor_repo_root(repo_root: Path) -> None:
|
|
30
|
+
(repo_root / ".git").mkdir(parents=True, exist_ok=True)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_is_markdown_file_accepts_md_and_rejects_python() -> None:
|
|
34
|
+
assert is_markdown_file("docs/configuration.md") is True
|
|
35
|
+
assert is_markdown_file("auth/google_auth.py") is False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_flags_variable_absent_from_named_code_file(tmp_path: Path) -> None:
|
|
39
|
+
_anchor_repo_root(tmp_path)
|
|
40
|
+
_write(tmp_path / "auth" / "google_auth.py", "def load():\n return read_bws_secret()\n")
|
|
41
|
+
doc_path = tmp_path / "docs" / "configuration.md"
|
|
42
|
+
content = (
|
|
43
|
+
"## Summary: Environment Variables\n\n"
|
|
44
|
+
"| Variable | Used By | Purpose |\n"
|
|
45
|
+
"|----------|---------|---------|\n"
|
|
46
|
+
"| `GOOGLE_APPLICATION_CREDENTIALS` | `auth/google_auth.py` | Path to JSON |\n"
|
|
47
|
+
)
|
|
48
|
+
drift_rows = find_drift_rows(content, doc_path.parent)
|
|
49
|
+
assert drift_rows == ["GOOGLE_APPLICATION_CREDENTIALS -> auth/google_auth.py"]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_passes_when_variable_present_in_code_file(tmp_path: Path) -> None:
|
|
53
|
+
_anchor_repo_root(tmp_path)
|
|
54
|
+
_write(
|
|
55
|
+
tmp_path / "automation_logging" / "__init__.py",
|
|
56
|
+
'topic = os.environ["NTFY_TOPIC"]\n',
|
|
57
|
+
)
|
|
58
|
+
doc_path = tmp_path / "docs" / "configuration.md"
|
|
59
|
+
content = (
|
|
60
|
+
"| Variable | Used By | Purpose |\n"
|
|
61
|
+
"|----------|---------|---------|\n"
|
|
62
|
+
"| `NTFY_TOPIC` | `automation_logging/__init__.py` | ntfy topic |\n"
|
|
63
|
+
)
|
|
64
|
+
assert find_drift_rows(content, doc_path.parent) == []
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_skips_row_whose_code_file_is_absent(tmp_path: Path) -> None:
|
|
68
|
+
_anchor_repo_root(tmp_path)
|
|
69
|
+
doc_path = tmp_path / "docs" / "configuration.md"
|
|
70
|
+
content = (
|
|
71
|
+
"| Variable | Used By | Purpose |\n"
|
|
72
|
+
"|----------|---------|---------|\n"
|
|
73
|
+
"| `MISSING_VAR` | `nowhere/ghost.py` | unresolved |\n"
|
|
74
|
+
)
|
|
75
|
+
assert find_drift_rows(content, doc_path.parent) == []
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_ignores_rows_inside_a_fenced_code_block(tmp_path: Path) -> None:
|
|
79
|
+
_anchor_repo_root(tmp_path)
|
|
80
|
+
_write(tmp_path / "auth" / "google_auth.py", "no variable here\n")
|
|
81
|
+
doc_path = tmp_path / "docs" / "configuration.md"
|
|
82
|
+
content = "```\n| `GOOGLE_APPLICATION_CREDENTIALS` | `auth/google_auth.py` | sample |\n```\n"
|
|
83
|
+
assert find_drift_rows(content, doc_path.parent) == []
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_ignores_row_whose_second_cell_is_not_a_code_file(tmp_path: Path) -> None:
|
|
87
|
+
_anchor_repo_root(tmp_path)
|
|
88
|
+
doc_path = tmp_path / "docs" / "configuration.md"
|
|
89
|
+
content = (
|
|
90
|
+
"| Variable | Purpose | Default |\n"
|
|
91
|
+
"|----------|---------|---------|\n"
|
|
92
|
+
"| `SOME_FLAG` | `enables the thing` | off |\n"
|
|
93
|
+
)
|
|
94
|
+
assert find_drift_rows(content, doc_path.parent) == []
|
|
@@ -311,6 +311,52 @@ def test_is_inventoried_production_file_accepts_production_file(tmp_path: Path):
|
|
|
311
311
|
assert is_inventoried_production_file(str(production_file_path)) is True
|
|
312
312
|
|
|
313
313
|
|
|
314
|
+
SKILL_MD_SCRIPTS_LAYOUT = (
|
|
315
|
+
"# base-theme-match\n\n"
|
|
316
|
+
"## Layout\n\n"
|
|
317
|
+
"| Path | Role |\n"
|
|
318
|
+
"|---|---|\n"
|
|
319
|
+
"| `scripts/discover_candidates.py` | Discovers candidates. |\n"
|
|
320
|
+
"| `scripts/resolve_stp.py` | Resolves the STP filename. |\n"
|
|
321
|
+
"| `scripts/select_stp.py` | Selects the STP filename. |\n"
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _skill_scripts_directory(tmp_path: Path) -> Path:
|
|
326
|
+
"""Return a skill package's scripts/ directory under a SKILL.md Layout table."""
|
|
327
|
+
skill_directory = tmp_path / "base-theme-match"
|
|
328
|
+
skill_directory.mkdir()
|
|
329
|
+
(skill_directory / "SKILL.md").write_text(SKILL_MD_SCRIPTS_LAYOUT, encoding="utf-8")
|
|
330
|
+
scripts_directory = skill_directory / "scripts"
|
|
331
|
+
scripts_directory.mkdir()
|
|
332
|
+
_write_sibling_files(scripts_directory, ["discover_candidates.py", "resolve_stp.py"])
|
|
333
|
+
return scripts_directory
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def test_blocks_new_script_absent_from_parent_skill_layout(tmp_path: Path):
|
|
337
|
+
scripts_directory = _skill_scripts_directory(tmp_path)
|
|
338
|
+
new_file_path = scripts_directory / "stp_selection.py"
|
|
339
|
+
result = _run_hook(
|
|
340
|
+
"Write",
|
|
341
|
+
{"file_path": str(new_file_path), "content": "x = 1\n"},
|
|
342
|
+
)
|
|
343
|
+
assert result.returncode == 0
|
|
344
|
+
payload = json.loads(result.stdout)
|
|
345
|
+
assert payload["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
346
|
+
assert "stp_selection.py" in payload["hookSpecificOutput"]["permissionDecisionReason"]
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def test_allows_new_script_named_in_parent_skill_layout(tmp_path: Path):
|
|
350
|
+
scripts_directory = _skill_scripts_directory(tmp_path)
|
|
351
|
+
new_file_path = scripts_directory / "select_stp.py"
|
|
352
|
+
result = _run_hook(
|
|
353
|
+
"Write",
|
|
354
|
+
{"file_path": str(new_file_path), "content": "x = 1\n"},
|
|
355
|
+
)
|
|
356
|
+
assert result.returncode == 0
|
|
357
|
+
assert result.stdout.strip() == ""
|
|
358
|
+
|
|
359
|
+
|
|
314
360
|
def test_find_stale_inventory_returns_survey_for_omission(tmp_path: Path):
|
|
315
361
|
package_directory = _package_directory_with_readme(tmp_path, README_LISTING_TWO_FILES)
|
|
316
362
|
_write_sibling_files(package_directory, ["dialer_compose.py", "compose_dialer_cli.py"])
|
|
@@ -632,24 +632,24 @@ def test_dispatcher_write_applies_both_groups() -> None:
|
|
|
632
632
|
assert "blocking/plain_language_blocker.py" in all_write_script_paths, (
|
|
633
633
|
"plain_language_blocker (Group B) must be in Write applicable set"
|
|
634
634
|
)
|
|
635
|
-
assert len(all_write_entries) ==
|
|
636
|
-
f"Write tool must apply to all
|
|
635
|
+
assert len(all_write_entries) == 19, (
|
|
636
|
+
f"Write tool must apply to all 19 hosted hooks, got {len(all_write_entries)}"
|
|
637
637
|
)
|
|
638
638
|
|
|
639
639
|
|
|
640
640
|
def test_dispatcher_edit_applies_both_groups() -> None:
|
|
641
641
|
"""Edit tool triggers both Group A and Group B hooks through the dispatcher."""
|
|
642
642
|
all_edit_entries = _applicable_entries_for_tool(EDIT_TOOL_NAME)
|
|
643
|
-
assert len(all_edit_entries) ==
|
|
644
|
-
f"Edit tool must apply to all
|
|
643
|
+
assert len(all_edit_entries) == 19, (
|
|
644
|
+
f"Edit tool must apply to all 19 hosted hooks, got {len(all_edit_entries)}"
|
|
645
645
|
)
|
|
646
646
|
|
|
647
647
|
|
|
648
648
|
def test_dispatcher_multi_edit_applies_only_group_b() -> None:
|
|
649
|
-
"""MultiEdit tool triggers only Group B (
|
|
649
|
+
"""MultiEdit tool triggers only Group B (8 hooks), not Group A."""
|
|
650
650
|
all_multi_edit_entries = _applicable_entries_for_tool(MULTI_EDIT_TOOL_NAME)
|
|
651
|
-
assert len(all_multi_edit_entries) ==
|
|
652
|
-
f"MultiEdit tool must apply to exactly
|
|
651
|
+
assert len(all_multi_edit_entries) == 8, (
|
|
652
|
+
f"MultiEdit tool must apply to exactly 8 Group-B hooks, got {len(all_multi_edit_entries)}"
|
|
653
653
|
)
|
|
654
654
|
|
|
655
655
|
|
|
@@ -13,6 +13,7 @@ Shared constant modules imported by hooks throughout the `hooks/` tree. Each fil
|
|
|
13
13
|
| `bot_mention_comment_blocker_constants.py` | Patterns for detecting bot @-mentions in PR comments |
|
|
14
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 |
|
|
15
15
|
| `code_rules_enforcer_constants.py` | File-extension sets, test-path patterns, advisory line thresholds, boolean-name prefixes |
|
|
16
|
+
| `env_var_table_code_drift_constants.py` | Table patterns, env-var-name and code-file recognizers, scan budget, and block-message text for the env-var-table code-drift blocker |
|
|
16
17
|
| `code_rules_path_utils_constants.py` | Path-matching helpers used by the code-rules check modules |
|
|
17
18
|
| `code_verifier_spawn_preflight_gate_constants.py` | Subagent type, merge-tree command flags, timeouts, and deny-message text for the code-verifier spawn pre-flight gate |
|
|
18
19
|
| `convergence_branch_constants.py` | Branch and worktree naming patterns for the convergence gate |
|
|
@@ -51,6 +51,7 @@ MAX_DOCSTRING_FALLBACK_BRANCH_ISSUES: int = 3
|
|
|
51
51
|
DOCSTRING_FALLBACK_BRANCH_MINIMUM_ROUTE_COUNT: int = 2
|
|
52
52
|
MAX_DOCSTRING_NO_CONSUMER_CLAIM_ISSUES: int = 3
|
|
53
53
|
MAX_DOCSTRING_UNGUARDED_PAYLOAD_CLAIM_ISSUES: int = 3
|
|
54
|
+
MAX_DOCSTRING_NO_NETWORK_CLAIM_ISSUES: int = 3
|
|
54
55
|
MAX_STALE_TEST_NAME_TARGET_ISSUES: int = 3
|
|
55
56
|
STALE_TEST_NAME_MINIMUM_SHARED_TOKEN_COUNT: int = 2
|
|
56
57
|
MAX_MODULE_DOCSTRING_CHECK_ROSTER_ISSUES: int = 5
|
|
@@ -79,6 +80,36 @@ MINIMUM_TOKENS_FOR_DISPATCH_CALLEE: int = 2
|
|
|
79
80
|
MAX_DOCSTRING_UNDEFINED_CONSTANT_ISSUES: int = 3
|
|
80
81
|
MAX_DOCSTRING_RETURNS_PLURAL_CARDINALITY_ISSUES: int = 5
|
|
81
82
|
SINGLE_DICT_KEY_COUNT_FOR_PLURAL_CARDINALITY_DRIFT: int = 1
|
|
83
|
+
MAX_LENGTH_CONSTANT_SUPERLATIVE_ISSUES: int = 3
|
|
84
|
+
LENGTH_GATE_PACKAGE_SCAN_FILE_LIMIT: int = 400
|
|
85
|
+
LENGTH_CONFIG_SUBDIRECTORY_NAME: str = "config"
|
|
86
|
+
ALL_LENGTH_CONSTANT_NAME_SUFFIXES: tuple[str, ...] = ("_LENGTH", "_LEN")
|
|
87
|
+
ALL_LENGTH_SUPERLATIVE_RANGE_PHRASES: tuple[str, ...] = (
|
|
88
|
+
"longest",
|
|
89
|
+
"maximum length",
|
|
90
|
+
"no longer than",
|
|
91
|
+
)
|
|
92
|
+
MAX_DOCSTRING_FIELD_RUNMODE_OUTCOME_ISSUES: int = 5
|
|
93
|
+
ALL_DOCSTRING_RUNMODE_FLAG_FIELD_NAME_TOKENS: tuple[str, ...] = ("dry_run",)
|
|
94
|
+
ALL_DOCSTRING_PER_RECORD_WRITE_OUTCOME_PHRASES: tuple[str, ...] = (
|
|
95
|
+
"was written",
|
|
96
|
+
"is written",
|
|
97
|
+
"were written",
|
|
98
|
+
"nothing was written",
|
|
99
|
+
"no file was written",
|
|
100
|
+
"wrote",
|
|
101
|
+
"written to disk",
|
|
102
|
+
"written to the file",
|
|
103
|
+
)
|
|
104
|
+
ALL_DOCSTRING_RUN_MODE_PHRASES: tuple[str, ...] = (
|
|
105
|
+
"dry run",
|
|
106
|
+
"dry-run",
|
|
107
|
+
"execute run",
|
|
108
|
+
"--execute",
|
|
109
|
+
"run mode",
|
|
110
|
+
"live run",
|
|
111
|
+
"not an execute",
|
|
112
|
+
)
|
|
82
113
|
MAX_DOCSTRING_RAISES_LARGEZIPFILE_ISSUES: int = 5
|
|
83
114
|
DOCSTRING_LARGE_ZIP_FILE_EXCEPTION_NAME: str = "LargeZipFile"
|
|
84
115
|
ZIPFILE_WRITER_CLASS_NAME: str = "ZipFile"
|
|
@@ -187,6 +218,26 @@ ALL_DOCSTRING_GUARDED_FAILURE_CLAIM_PHRASES: tuple[str, ...] = (
|
|
|
187
218
|
"malformed payload yields none",
|
|
188
219
|
)
|
|
189
220
|
|
|
221
|
+
ALL_DOCSTRING_NO_NETWORK_CLAIM_PHRASES: tuple[str, ...] = (
|
|
222
|
+
"without touching the network",
|
|
223
|
+
"without touching the share",
|
|
224
|
+
"without touching the network share",
|
|
225
|
+
"without hitting the network",
|
|
226
|
+
"without hitting the share",
|
|
227
|
+
"without a network call",
|
|
228
|
+
"without any network call",
|
|
229
|
+
"without network access",
|
|
230
|
+
"without a network round-trip",
|
|
231
|
+
"without a network round trip",
|
|
232
|
+
"no network access",
|
|
233
|
+
"no network round-trip",
|
|
234
|
+
"no network round trip",
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
ALL_PATH_METADATA_ACCESS_METHOD_NAMES: frozenset[str] = frozenset(
|
|
238
|
+
{"is_file", "is_dir", "exists", "stat", "lstat"}
|
|
239
|
+
)
|
|
240
|
+
|
|
190
241
|
MAX_DOCSTRING_INLINE_LITERAL_CLAIM_ISSUES: int = 3
|
|
191
242
|
ALL_DOCSTRING_NO_INLINE_LITERAL_CLAIM_PHRASES: tuple[str, ...] = (
|
|
192
243
|
"no literals appear inline",
|
|
@@ -228,3 +279,30 @@ ALL_TEST_INDICATING_ENVIRONMENT_VARIABLE_NAMES: frozenset[str] = frozenset(
|
|
|
228
279
|
"UNIT_TEST",
|
|
229
280
|
}
|
|
230
281
|
)
|
|
282
|
+
|
|
283
|
+
MAX_MODULE_DOCSTRING_DATA_SCHEMA_SCOPE_ISSUES: int = 1
|
|
284
|
+
MODULE_DOCSTRING_DATA_SCHEMA_CONSTANT_SAMPLE_LIMIT: int = 6
|
|
285
|
+
ALL_USER_FACING_TEXT_SCOPE_DOCSTRING_PHRASES: tuple[str, ...] = (
|
|
286
|
+
"user-facing",
|
|
287
|
+
"user facing",
|
|
288
|
+
)
|
|
289
|
+
ALL_DATA_SCHEMA_CONSTANT_NAME_MARKERS: tuple[str, ...] = (
|
|
290
|
+
"_FIELD_",
|
|
291
|
+
"_KEY_",
|
|
292
|
+
"_SCHEMA_",
|
|
293
|
+
"_ENCODING",
|
|
294
|
+
"_FORMAT_STRING",
|
|
295
|
+
)
|
|
296
|
+
ALL_DATA_SCHEMA_DOCSTRING_ACKNOWLEDGEMENT_PHRASES: tuple[str, ...] = (
|
|
297
|
+
"field key",
|
|
298
|
+
"schema key",
|
|
299
|
+
"schema",
|
|
300
|
+
"metadata",
|
|
301
|
+
"encoding",
|
|
302
|
+
"format string",
|
|
303
|
+
"runtime config",
|
|
304
|
+
"runtime-config",
|
|
305
|
+
"data schema",
|
|
306
|
+
"data-schema",
|
|
307
|
+
"config constant",
|
|
308
|
+
)
|
|
@@ -39,6 +39,20 @@ DENY_REASON_ISSUE_PREVIEW_COUNT = 10
|
|
|
39
39
|
ALL_BOOLEAN_NAME_PREFIXES: tuple[str, ...] = ("is_", "has_", "should_", "can_", "was_", "did_")
|
|
40
40
|
UPPER_SNAKE_CONSTANT_PATTERN = re.compile(r"^[A-Z][A-Z0-9_]*$")
|
|
41
41
|
|
|
42
|
+
ALL_POLARITY_ANTONYM_TOKEN_PAIRS: tuple[tuple[str, str], ...] = (
|
|
43
|
+
("allowed", "forbidden"),
|
|
44
|
+
("allowed", "denied"),
|
|
45
|
+
("allowed", "blocked"),
|
|
46
|
+
("permitted", "forbidden"),
|
|
47
|
+
("permitted", "denied"),
|
|
48
|
+
("included", "excluded"),
|
|
49
|
+
("valid", "invalid"),
|
|
50
|
+
("enabled", "disabled"),
|
|
51
|
+
("visible", "hidden"),
|
|
52
|
+
("present", "missing"),
|
|
53
|
+
)
|
|
54
|
+
POLARITY_TOKEN_BOUNDARY_PATTERN: str = r"(?:^|_)%s(?:_|$)"
|
|
55
|
+
|
|
42
56
|
ALL_MUST_CHECK_RETURN_FUNCTION_NAMES: frozenset[str] = frozenset({"find_and_click", "write_outcome"})
|
|
43
57
|
|
|
44
58
|
DOCSTRING_ARG_ENTRY_PATTERN: re.Pattern[str] = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)\s*[:(]")
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Constants for the env-var-table code-drift blocker.
|
|
2
|
+
|
|
3
|
+
Holds the markdown filename matcher, the table-row and cell patterns, the
|
|
4
|
+
env-var-name and code-file-extension recognizers, the bounded-scan budgets, and
|
|
5
|
+
the block-message strings. The blocker imports each of these by name.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"MARKDOWN_FILE_EXTENSION",
|
|
14
|
+
"TABLE_ROW_PATTERN",
|
|
15
|
+
"CODE_FENCE_PATTERN",
|
|
16
|
+
"SEPARATOR_CELL_PATTERN",
|
|
17
|
+
"BACKTICK_TOKEN_PATTERN",
|
|
18
|
+
"ENV_VAR_NAME_PATTERN",
|
|
19
|
+
"ALL_CODE_FILE_EXTENSIONS",
|
|
20
|
+
"ALL_NOISE_DIRECTORY_NAMES",
|
|
21
|
+
"GIT_DIRECTORY_NAME",
|
|
22
|
+
"MINIMUM_ENV_VAR_ROW_CELL_COUNT",
|
|
23
|
+
"MAX_SUBTREE_FILES_SCANNED",
|
|
24
|
+
"MAX_DRIFT_ISSUES",
|
|
25
|
+
"DRIFT_MESSAGE_TEMPLATE",
|
|
26
|
+
"DRIFT_ADDITIONAL_CONTEXT",
|
|
27
|
+
"DRIFT_SYSTEM_MESSAGE",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
MARKDOWN_FILE_EXTENSION = ".md"
|
|
31
|
+
|
|
32
|
+
TABLE_ROW_PATTERN = re.compile(r"^\s*\|")
|
|
33
|
+
CODE_FENCE_PATTERN = re.compile(r"^\s*(```|~~~)")
|
|
34
|
+
SEPARATOR_CELL_PATTERN = re.compile(r"^[:\-\s]+$")
|
|
35
|
+
BACKTICK_TOKEN_PATTERN = re.compile(r"`([^`]+)`")
|
|
36
|
+
ENV_VAR_NAME_PATTERN = re.compile(r"^[A-Z][A-Z0-9_]{1,}$")
|
|
37
|
+
|
|
38
|
+
ALL_CODE_FILE_EXTENSIONS: frozenset[str] = frozenset(
|
|
39
|
+
{".py", ".mjs", ".js", ".ts", ".ps1", ".sh"}
|
|
40
|
+
)
|
|
41
|
+
ALL_NOISE_DIRECTORY_NAMES: frozenset[str] = frozenset(
|
|
42
|
+
{".git", "__pycache__", "node_modules", ".pytest_cache", ".mypy_cache", ".ruff_cache"}
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
GIT_DIRECTORY_NAME = ".git"
|
|
46
|
+
MINIMUM_ENV_VAR_ROW_CELL_COUNT = 2
|
|
47
|
+
|
|
48
|
+
MAX_SUBTREE_FILES_SCANNED = 20000
|
|
49
|
+
MAX_DRIFT_ISSUES = 20
|
|
50
|
+
|
|
51
|
+
DRIFT_MESSAGE_TEMPLATE = (
|
|
52
|
+
"Env-var summary table in {file} attributes an environment variable to a "
|
|
53
|
+
"code file that does not read it: {drift}. The code file no longer "
|
|
54
|
+
"references the variable, so the table points a reader at a consumer "
|
|
55
|
+
"relationship the code does not have. Remove or correct the row so the "
|
|
56
|
+
"summary matches the code."
|
|
57
|
+
)
|
|
58
|
+
DRIFT_ADDITIONAL_CONTEXT = (
|
|
59
|
+
"Each `VARIABLE` | `code/file.py` row in a markdown env-var summary table "
|
|
60
|
+
"names a code file that reads that variable. When the code file exists but "
|
|
61
|
+
"its source never references the variable name, the row is stale. Drop the "
|
|
62
|
+
"row, or point it at the variable the file actually reads."
|
|
63
|
+
)
|
|
64
|
+
DRIFT_SYSTEM_MESSAGE = "Blocked: env-var summary table attributes a variable to a code file that does not read it."
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"""Constants for the package-inventory stale-entry blocker.
|
|
2
2
|
|
|
3
3
|
A package directory documents its own files in a sibling inventory document —
|
|
4
|
-
a ``README.md`` Layout table
|
|
4
|
+
a ``README.md`` Layout table, a ``CLAUDE.md`` "Key files" list, or a skill
|
|
5
|
+
``SKILL.md`` Layout table that maps the ``scripts/`` subdirectory — whose entries
|
|
5
6
|
name each file in backticks. When a new production code file lands in that
|
|
6
7
|
directory and the inventory carries no entry naming it, the inventory disagrees
|
|
7
8
|
with the directory on the package's file set, and a reader trusting the
|
|
@@ -19,6 +20,8 @@ import re
|
|
|
19
20
|
|
|
20
21
|
__all__ = [
|
|
21
22
|
"ALL_INVENTORY_DOCUMENT_NAMES",
|
|
23
|
+
"SKILL_INVENTORY_DOCUMENT_NAME",
|
|
24
|
+
"SCRIPTS_SUBDIRECTORY_NAME",
|
|
22
25
|
"ALL_PRODUCTION_CODE_EXTENSIONS",
|
|
23
26
|
"PYTHON_FILE_EXTENSION",
|
|
24
27
|
"ALL_TEST_FILE_MARKERS",
|
|
@@ -35,7 +38,13 @@ __all__ = [
|
|
|
35
38
|
"STALE_INVENTORY_ADDITIONAL_CONTEXT",
|
|
36
39
|
]
|
|
37
40
|
|
|
38
|
-
|
|
41
|
+
SKILL_INVENTORY_DOCUMENT_NAME: str = "SKILL.md"
|
|
42
|
+
|
|
43
|
+
SCRIPTS_SUBDIRECTORY_NAME: str = "scripts"
|
|
44
|
+
|
|
45
|
+
ALL_INVENTORY_DOCUMENT_NAMES: frozenset[str] = frozenset(
|
|
46
|
+
{"README.md", "CLAUDE.md", SKILL_INVENTORY_DOCUMENT_NAME}
|
|
47
|
+
)
|
|
39
48
|
|
|
40
49
|
PYTHON_FILE_EXTENSION: str = ".py"
|
|
41
50
|
|
|
@@ -96,16 +105,18 @@ STALE_INVENTORY_MESSAGE_TEMPLATE: str = (
|
|
|
96
105
|
|
|
97
106
|
STALE_INVENTORY_SYSTEM_MESSAGE: str = (
|
|
98
107
|
"New production file is absent from its package inventory (README.md / "
|
|
99
|
-
"CLAUDE.md) - add the inventory entry in this same change"
|
|
108
|
+
"CLAUDE.md / SKILL.md) - add the inventory entry in this same change"
|
|
100
109
|
)
|
|
101
110
|
|
|
102
111
|
STALE_INVENTORY_ADDITIONAL_CONTEXT: str = (
|
|
103
|
-
"A package directory whose README.md or
|
|
104
|
-
"backticks is a maintained inventory of the package's file set. A
|
|
105
|
-
"
|
|
106
|
-
"
|
|
107
|
-
"
|
|
108
|
-
"
|
|
112
|
+
"A package directory whose README.md, CLAUDE.md, or SKILL.md lists its files "
|
|
113
|
+
"in backticks is a maintained inventory of the package's file set. A skill "
|
|
114
|
+
"SKILL.md Layout table that maps the scripts/ subdirectory counts as the "
|
|
115
|
+
"inventory for files in that subdirectory. A new production code file (.py, "
|
|
116
|
+
".mjs, .js, .ts, .ps1, .sh) in an inventoried directory carries one entry "
|
|
117
|
+
"naming it. Add a row to the README.md or SKILL.md table or a bullet to the "
|
|
118
|
+
"CLAUDE.md list naming this file, describing what it does, in the same change "
|
|
119
|
+
"that creates the file. Exempt files (no entry needed): "
|
|
109
120
|
"__init__.py, conftest.py, setup.py, _path_setup.py, files under config/ or "
|
|
110
121
|
"tests/, and test files (test_*.py, *_test.py, *.spec.*, *.test.*)."
|
|
111
122
|
)
|
|
@@ -21,7 +21,15 @@ MINIMUM_COVERED_PUBLIC_FUNCTIONS: int = 1
|
|
|
21
21
|
MAX_PAIRED_TEST_COVERAGE_ISSUES: int = 25
|
|
22
22
|
MISSING_PAIRED_TEST_GUIDANCE: str = (
|
|
23
23
|
"is exercised by no test in the module's paired test suite, though that"
|
|
24
|
-
" suite already
|
|
25
|
-
"
|
|
26
|
-
" or side effect (CODE_RULES TDD
|
|
24
|
+
" suite already exercises this module (covering another public function or"
|
|
25
|
+
" referencing a private helper) - add a behavioral test that calls this"
|
|
26
|
+
" function and asserts on its return value or side effect (CODE_RULES TDD"
|
|
27
|
+
" paired-test rule)"
|
|
28
|
+
)
|
|
29
|
+
TEST_SUITE_OMITS_FUNCTION_GUIDANCE: str = (
|
|
30
|
+
"is a public function this stem-matched test suite defines its module for"
|
|
31
|
+
" but exercises nowhere, though the suite already covers another public"
|
|
32
|
+
" function in that module - add a behavioral test that calls this function"
|
|
33
|
+
" and asserts on its return value or side effect, or remove the function"
|
|
34
|
+
" (CODE_RULES TDD paired-test rule)"
|
|
27
35
|
)
|
|
@@ -129,6 +129,10 @@ ALL_HOSTED_HOOK_ENTRIES: tuple[HostedHookEntry, ...] = (
|
|
|
129
129
|
script_relative_path="blocking/package_inventory_stale_blocker.py",
|
|
130
130
|
applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
|
|
131
131
|
),
|
|
132
|
+
HostedHookEntry(
|
|
133
|
+
script_relative_path="blocking/env_var_table_code_drift_blocker.py",
|
|
134
|
+
applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
|
|
135
|
+
),
|
|
132
136
|
HostedHookEntry(
|
|
133
137
|
script_relative_path="blocking/pytest_testpaths_orphan_blocker.py",
|
|
134
138
|
applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
|
package/package.json
CHANGED
package/rules/CLAUDE.md
CHANGED
|
@@ -27,6 +27,7 @@ Rule files installed into `~/.claude/rules/` by `bin/install.mjs`. Claude Code l
|
|
|
27
27
|
| `no-cross-skill-duplicate-helpers.md` | No duplicating shared helpers across skills; use `_shared/` |
|
|
28
28
|
| `no-historical-clutter.md` | Documentation describes current state only; no historical or transitional language |
|
|
29
29
|
| `no-inline-destructive-literals.md` | No destructive-command literals in Bash tool command strings, even as data |
|
|
30
|
+
| `env-var-table-code-drift.md` | Every env-var summary table row in a `.md` file names a code file whose source references the variable |
|
|
30
31
|
| `orphan-css-class.md` | Every `class="..."` attribute in Python-generated markup has a matching selector in the `<style>` block |
|
|
31
32
|
| `package-inventory-stale-entry.md` | A new production code file added to a directory carries an entry in that directory's `README.md`/`CLAUDE.md` file inventory |
|
|
32
33
|
| `paired-test-coverage.md` | A public function omitted by a module's established paired test suite must get a behavioral test |
|