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.
Files changed (35) hide show
  1. package/audit-rubrics/category_rubrics/category-k-codebase-conflicts.md +1 -0
  2. package/bin/install.mjs +1 -0
  3. package/bin/install.test.mjs +3 -2
  4. package/hooks/blocking/CLAUDE.md +3 -2
  5. package/hooks/blocking/code_rules_docstrings.py +609 -16
  6. package/hooks/blocking/code_rules_enforcer.py +37 -0
  7. package/hooks/blocking/code_rules_naming_collection.py +76 -1
  8. package/hooks/blocking/code_rules_paired_test.py +240 -22
  9. package/hooks/blocking/code_rules_test_assertions.py +159 -1
  10. package/hooks/blocking/env_var_table_code_drift_blocker.py +475 -0
  11. package/hooks/blocking/package_inventory_stale_blocker.py +54 -15
  12. package/hooks/blocking/test_code_rules_enforcer_docstring_field_runmode_outcome.py +129 -0
  13. package/hooks/blocking/test_code_rules_enforcer_docstring_length_constant_superlative.py +198 -0
  14. package/hooks/blocking/test_code_rules_enforcer_docstring_no_network.py +115 -0
  15. package/hooks/blocking/test_code_rules_enforcer_docstring_unreferenced_param.py +160 -0
  16. package/hooks/blocking/test_code_rules_enforcer_module_docstring_data_schema_scope.py +82 -0
  17. package/hooks/blocking/test_code_rules_enforcer_paired_test.py +190 -0
  18. package/hooks/blocking/test_code_rules_enforcer_polarity_name_contradiction.py +76 -0
  19. package/hooks/blocking/test_code_rules_enforcer_vacuous_cleanup_assertion.py +132 -0
  20. package/hooks/blocking/test_env_var_table_code_drift_blocker.py +94 -0
  21. package/hooks/blocking/test_package_inventory_stale_blocker.py +46 -0
  22. package/hooks/blocking/test_pre_tool_use_dispatcher.py +7 -7
  23. package/hooks/hooks_constants/CLAUDE.md +1 -0
  24. package/hooks/hooks_constants/blocking_check_limits.py +78 -0
  25. package/hooks/hooks_constants/code_rules_enforcer_constants.py +14 -0
  26. package/hooks/hooks_constants/env_var_table_code_drift_constants.py +64 -0
  27. package/hooks/hooks_constants/package_inventory_stale_blocker_constants.py +20 -9
  28. package/hooks/hooks_constants/paired_test_coverage_constants.py +11 -3
  29. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
  30. package/package.json +1 -1
  31. package/rules/CLAUDE.md +1 -0
  32. package/rules/docstring-prose-matches-implementation.md +56 -54
  33. package/rules/env-var-table-code-drift.md +24 -0
  34. package/rules/package-inventory-stale-entry.md +4 -4
  35. 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) == 18, (
636
- f"Write tool must apply to all 18 hosted hooks, got {len(all_write_entries)}"
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) == 18, (
644
- f"Edit tool must apply to all 18 hosted hooks, got {len(all_edit_entries)}"
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 (7 hooks), not Group A."""
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) == 7, (
652
- f"MultiEdit tool must apply to exactly 7 Group-B hooks, got {len(all_multi_edit_entries)}"
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 or a ``CLAUDE.md`` "Key files" list whose entries
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
- ALL_INVENTORY_DOCUMENT_NAMES: frozenset[str] = frozenset({"README.md", "CLAUDE.md"})
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 CLAUDE.md lists its files in "
104
- "backticks is a maintained inventory of the package's file set. A new "
105
- "production code file (.py, .mjs, .js, .ts, .ps1, .sh) in that directory "
106
- "carries one inventory entry naming it. Add a row to the README.md table or "
107
- "a bullet to the CLAUDE.md list naming this file, describing what it does, "
108
- "in the same change that creates the file. Exempt files (no entry needed): "
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 covers another public function in this module - add a"
25
- " behavioral test that calls this function and asserts on its return value"
26
- " or side effect (CODE_RULES TDD paired-test rule)"
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "1.78.0",
3
+ "version": "1.79.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
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 |