claude-dev-env 1.72.0 → 1.74.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 (99) hide show
  1. package/CLAUDE.md +2 -0
  2. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +2 -2
  3. package/bin/install.mjs +73 -5
  4. package/bin/install.test.mjs +360 -4
  5. package/hooks/blocking/CLAUDE.md +6 -1
  6. package/hooks/blocking/block_main_commit.py +14 -0
  7. package/hooks/blocking/bot_mention_comment_blocker.py +7 -0
  8. package/hooks/blocking/claude_md_orphan_file_blocker.py +19 -48
  9. package/hooks/blocking/code_rules_dead_config_field.py +69 -56
  10. package/hooks/blocking/code_rules_docstrings.py +839 -0
  11. package/hooks/blocking/code_rules_enforcer.py +38 -0
  12. package/hooks/blocking/code_rules_shared.py +19 -0
  13. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +426 -0
  14. package/hooks/blocking/convergence_gate_blocker.py +17 -3
  15. package/hooks/blocking/destructive_command_blocker.py +7 -0
  16. package/hooks/blocking/docstring_rule_gate_count_blocker.py +321 -0
  17. package/hooks/blocking/gh_body_arg_blocker.py +8 -0
  18. package/hooks/blocking/gh_pr_author_enforcer.py +7 -0
  19. package/hooks/blocking/hedging_language_blocker.py +16 -10
  20. package/hooks/blocking/hook_prose_detector_consistency.py +7 -0
  21. package/hooks/blocking/intent_only_ending_blocker.py +17 -11
  22. package/hooks/blocking/md_to_html_blocker.py +17 -10
  23. package/hooks/blocking/open_questions_in_plans_blocker.py +15 -8
  24. package/hooks/blocking/package_inventory_stale_blocker.py +398 -0
  25. package/hooks/blocking/plain_language_blocker.py +57 -16
  26. package/hooks/blocking/pr_converge_bugteam_enforcer.py +11 -5
  27. package/hooks/blocking/pr_description_enforcer.py +6 -0
  28. package/hooks/blocking/pre_tool_use_dispatcher.py +545 -0
  29. package/hooks/blocking/precommit_code_rules_gate.py +10 -1
  30. package/hooks/blocking/pytest_testpaths_orphan_blocker.py +366 -0
  31. package/hooks/blocking/question_to_user_enforcer.py +18 -12
  32. package/hooks/blocking/send_user_file_open_locally_blocker.py +70 -0
  33. package/hooks/blocking/sensitive_file_protector.py +15 -1
  34. package/hooks/blocking/session_handoff_blocker.py +14 -8
  35. package/hooks/blocking/state_description_blocker.py +81 -36
  36. package/hooks/blocking/subprocess_budget_completeness.py +9 -3
  37. package/hooks/blocking/tdd_enforcer.py +6 -0
  38. package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +81 -0
  39. package/hooks/blocking/test_code_rules_enforcer_docstring_inline_literal_claim.py +93 -0
  40. package/hooks/blocking/test_code_rules_enforcer_docstring_returns_plural_cardinality.py +207 -0
  41. package/hooks/blocking/test_code_rules_enforcer_docstring_step_dispatch.py +262 -0
  42. package/hooks/blocking/test_code_rules_enforcer_docstring_undefined_constant.py +253 -0
  43. package/hooks/blocking/test_code_rules_enforcer_docstring_unguarded_payload.py +188 -0
  44. package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +279 -0
  45. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +501 -0
  46. package/hooks/blocking/test_docstring_rule_gate_count_blocker.py +203 -0
  47. package/hooks/blocking/test_hook_block_logger_coverage.py +53 -0
  48. package/hooks/blocking/test_package_inventory_stale_blocker.py +329 -0
  49. package/hooks/blocking/test_plain_language_blocker.py +36 -0
  50. package/hooks/blocking/test_pre_tool_use_dispatcher.py +816 -0
  51. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +341 -0
  52. package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +247 -0
  53. package/hooks/blocking/test_send_user_file_open_locally_blocker.py +114 -0
  54. package/hooks/blocking/test_shared_stdin_adoption.py +208 -0
  55. package/hooks/blocking/test_state_description_blocker.py +41 -0
  56. package/hooks/blocking/test_verdict_directory_write_blocker.py +49 -0
  57. package/hooks/blocking/test_workflow_substitution_slot_blocker.py +4 -19
  58. package/hooks/blocking/verdict_directory_write_blocker.py +21 -7
  59. package/hooks/blocking/verified_commit_gate.py +11 -0
  60. package/hooks/blocking/verified_commit_message_accuracy_blocker.py +16 -1
  61. package/hooks/blocking/windows_rmtree_blocker.py +7 -0
  62. package/hooks/blocking/workflow_substitution_slot_blocker.py +10 -5
  63. package/hooks/blocking/write_existing_file_blocker.py +16 -1
  64. package/hooks/hooks.json +19 -79
  65. package/hooks/hooks_constants/CLAUDE.md +7 -1
  66. package/hooks/hooks_constants/blocking_check_limits.py +74 -0
  67. package/hooks/hooks_constants/code_rules_enforcer_constants.py +9 -0
  68. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +45 -0
  69. package/hooks/hooks_constants/dead_config_field_constants.py +5 -5
  70. package/hooks/hooks_constants/docstring_rule_gate_count_blocker_constants.py +90 -0
  71. package/hooks/hooks_constants/hook_block_logger.py +59 -0
  72. package/hooks/hooks_constants/multi_edit_reconstruction.py +56 -0
  73. package/hooks/hooks_constants/mypy_validator_cache_constants.py +36 -0
  74. package/hooks/hooks_constants/package_inventory_stale_blocker_constants.py +111 -0
  75. package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +68 -0
  76. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +143 -0
  77. package/hooks/hooks_constants/pytest_testpaths_orphan_blocker_constants.py +79 -0
  78. package/hooks/hooks_constants/send_user_file_open_locally_blocker_constants.py +18 -0
  79. package/hooks/hooks_constants/test_dispatcher_constants_docstrings.py +44 -0
  80. package/hooks/hooks_constants/test_hook_block_logger.py +159 -0
  81. package/hooks/lifecycle/config_change_guard.py +12 -0
  82. package/hooks/lifecycle/test_config_change_guard.py +23 -0
  83. package/hooks/validation/hook_format_validator.py +13 -0
  84. package/hooks/validation/mypy_validator.py +245 -18
  85. package/hooks/validation/post_tool_use_dispatcher.py +344 -0
  86. package/hooks/validation/test_hook_format_validator.py +64 -0
  87. package/hooks/validation/test_mypy_validator.py +206 -1
  88. package/hooks/validation/test_post_tool_use_dispatcher.py +610 -0
  89. package/hooks/workflow/test_auto_formatter.py +10 -9
  90. package/package.json +1 -1
  91. package/rules/CLAUDE.md +1 -0
  92. package/rules/docstring-prose-matches-implementation.md +4 -2
  93. package/rules/package-inventory-stale-entry.md +24 -0
  94. package/skills/autoconverge/SKILL.md +111 -1
  95. package/skills/autoconverge/workflow/converge.contract.test.mjs +106 -0
  96. package/skills/autoconverge/workflow/converge.mjs +29 -3
  97. package/skills/autoconverge/workflow/converge.path-aware.test.mjs +47 -0
  98. package/skills/autoconverge/workflow/converge_multi.mjs +161 -0
  99. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +100 -0
@@ -0,0 +1,111 @@
1
+ """Constants for the package-inventory stale-entry blocker.
2
+
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
5
+ name each file in backticks. When a new production code file lands in that
6
+ directory and the inventory carries no entry naming it, the inventory disagrees
7
+ with the directory on the package's file set, and a reader trusting the
8
+ inventory to map the directory misses the new file. This module holds the
9
+ inventory document names, the production code extensions that earn an inventory
10
+ entry, the backtick pattern that finds an inventory's named files, the code-fence
11
+ pattern that marks lines to skip, the glob-metacharacter pattern that rejects
12
+ pattern tokens, the non-filename pattern that rejects command-example and
13
+ path-bearing prose spans, the minimum inventory size that marks a document as a
14
+ maintained inventory, the filenames exempt from an entry, the scan budget, and
15
+ the block-message text the hook emits.
16
+ """
17
+
18
+ import re
19
+
20
+ __all__ = [
21
+ "ALL_INVENTORY_DOCUMENT_NAMES",
22
+ "ALL_PRODUCTION_CODE_EXTENSIONS",
23
+ "PYTHON_FILE_EXTENSION",
24
+ "ALL_TEST_FILE_MARKERS",
25
+ "BACKTICK_TOKEN_PATTERN",
26
+ "CODE_FENCE_PATTERN",
27
+ "GLOB_METACHARACTER_PATTERN",
28
+ "NON_FILENAME_TOKEN_PATTERN",
29
+ "MINIMUM_INVENTORY_ENTRY_COUNT",
30
+ "ALL_EXEMPT_BASENAMES",
31
+ "ALL_EXEMPT_DIRECTORY_NAMES",
32
+ "MAX_INVENTORY_FILE_BYTES",
33
+ "STALE_INVENTORY_MESSAGE_TEMPLATE",
34
+ "STALE_INVENTORY_SYSTEM_MESSAGE",
35
+ "STALE_INVENTORY_ADDITIONAL_CONTEXT",
36
+ ]
37
+
38
+ ALL_INVENTORY_DOCUMENT_NAMES: frozenset[str] = frozenset({"README.md", "CLAUDE.md"})
39
+
40
+ PYTHON_FILE_EXTENSION: str = ".py"
41
+
42
+ ALL_TEST_FILE_MARKERS: tuple[str, ...] = (".spec.", ".test.")
43
+
44
+ ALL_PRODUCTION_CODE_EXTENSIONS: frozenset[str] = frozenset(
45
+ {
46
+ ".py",
47
+ ".mjs",
48
+ ".js",
49
+ ".ts",
50
+ ".ps1",
51
+ ".sh",
52
+ }
53
+ )
54
+
55
+ BACKTICK_TOKEN_PATTERN: re.Pattern[str] = re.compile(r"`([^`]+)`")
56
+
57
+ CODE_FENCE_PATTERN: re.Pattern[str] = re.compile(r"^\s*(?:```|~~~)")
58
+
59
+ GLOB_METACHARACTER_PATTERN: re.Pattern[str] = re.compile(r"[*?{}\[\]]")
60
+
61
+ NON_FILENAME_TOKEN_PATTERN: re.Pattern[str] = re.compile(r"[\s:$<>]")
62
+
63
+ MINIMUM_INVENTORY_ENTRY_COUNT: int = 2
64
+
65
+ ALL_EXEMPT_BASENAMES: frozenset[str] = frozenset(
66
+ {
67
+ "__init__.py",
68
+ "conftest.py",
69
+ "setup.py",
70
+ "_path_setup.py",
71
+ }
72
+ )
73
+
74
+ ALL_EXEMPT_DIRECTORY_NAMES: frozenset[str] = frozenset(
75
+ {
76
+ "config",
77
+ "tests",
78
+ "__pycache__",
79
+ ".git",
80
+ "node_modules",
81
+ ".pytest_cache",
82
+ ".ruff_cache",
83
+ }
84
+ )
85
+
86
+ MAX_INVENTORY_FILE_BYTES: int = 200_000
87
+
88
+ STALE_INVENTORY_MESSAGE_TEMPLATE: str = (
89
+ "New production file `{filename}` lands in {directory}, whose inventory "
90
+ "document(s) ({inventories}) name {entry_count} sibling files but no entry "
91
+ "for `{filename}`. A package inventory names every production file in its "
92
+ "directory; a new file the inventory omits leaves the inventory and the "
93
+ "directory disagreeing on the package's file set. Add an entry naming "
94
+ "`{filename}` to the inventory in this same change."
95
+ )
96
+
97
+ STALE_INVENTORY_SYSTEM_MESSAGE: str = (
98
+ "New production file is absent from its package inventory (README.md / "
99
+ "CLAUDE.md) - add the inventory entry in this same change"
100
+ )
101
+
102
+ 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): "
109
+ "__init__.py, conftest.py, setup.py, _path_setup.py, files under config/ or "
110
+ "tests/, and test files (test_*.py, *_test.py, *.spec.*, *.test.*)."
111
+ )
@@ -0,0 +1,68 @@
1
+ """Constants for the PostToolUse dispatcher that hosts the after-write hooks.
2
+
3
+ Holds the ordered hosted-hook list with each hook's extra command-line
4
+ arguments and blocking flag, the PostToolUse block-decision string and key,
5
+ and the hook-event name. The dispatcher imports each of these by name.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+
12
+ __all__ = [
13
+ "BLOCK_DECISION",
14
+ "DECISION_KEY",
15
+ "REASON_KEY",
16
+ "HOOK_EVENT_NAME",
17
+ "EMPTY_REASON_BLOCK_FALLBACK",
18
+ "PLUGIN_ROOT_PLACEHOLDER",
19
+ "PostHostedHookEntry",
20
+ "ALL_POST_HOSTED_HOOK_ENTRIES",
21
+ ]
22
+
23
+ BLOCK_DECISION = "block"
24
+ DECISION_KEY = "decision"
25
+ REASON_KEY = "reason"
26
+ HOOK_EVENT_NAME = "PostToolUse"
27
+ EMPTY_REASON_BLOCK_FALLBACK = "[dispatcher] hook blocked with no reason — write blocked"
28
+
29
+ PLUGIN_ROOT_PLACEHOLDER = "${CLAUDE_PLUGIN_ROOT}"
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class PostHostedHookEntry:
34
+ """A single hosted PostToolUse hook with its run-time arguments and flags.
35
+
36
+ Attributes:
37
+ script_relative_path: Hook path relative to the hooks/ directory.
38
+ extra_argument_relative_paths: Command-line arguments the live entry
39
+ passes after the script path, each a path relative to the plugin
40
+ root (the hooks/ parent). The dispatcher resolves each to an
41
+ absolute path and exposes them as the hook's argv tail, so a hook
42
+ that reads sys.argv[1] resolves the same path the live entry gives
43
+ it. An empty tuple means the live entry passes no extra arguments.
44
+ is_blocking: True when this hook can emit a block decision and a crash
45
+ should surface a blocking signal; False when the hook only performs
46
+ a side effect and never blocks.
47
+ """
48
+
49
+ script_relative_path: str
50
+ extra_argument_relative_paths: tuple[str, ...] = field(default_factory=tuple)
51
+ is_blocking: bool = field(default=False)
52
+
53
+
54
+ ALL_POST_HOSTED_HOOK_ENTRIES: tuple[PostHostedHookEntry, ...] = (
55
+ PostHostedHookEntry(
56
+ script_relative_path="validation/mypy_validator.py",
57
+ is_blocking=True,
58
+ ),
59
+ PostHostedHookEntry(
60
+ script_relative_path="workflow/auto_formatter.py",
61
+ is_blocking=False,
62
+ ),
63
+ PostHostedHookEntry(
64
+ script_relative_path="workflow/doc_gist_auto_publish.py",
65
+ extra_argument_relative_paths=(PLUGIN_ROOT_PLACEHOLDER,),
66
+ is_blocking=False,
67
+ ),
68
+ )
@@ -0,0 +1,143 @@
1
+ """Constants for the PreToolUse dispatcher that hosts Write/Edit/MultiEdit hooks.
2
+
3
+ Holds the ordered hosted-hook list with per-hook applicable-tool sets, the
4
+ special exit codes, the deny decision string, and the hook-event name. The
5
+ dispatcher imports each of these by name.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+
12
+ __all__ = [
13
+ "DENY_DECISION",
14
+ "ALLOW_DECISION",
15
+ "HOOK_EVENT_NAME",
16
+ "BLOCKING_CRASH_EXIT_CODE",
17
+ "EXIT_CODE_TWO_DENY_REASON",
18
+ "WRITE_TOOL_NAME",
19
+ "EDIT_TOOL_NAME",
20
+ "MULTI_EDIT_TOOL_NAME",
21
+ "ALL_WRITE_AND_EDIT_TOOL_NAMES",
22
+ "ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES",
23
+ "STATE_DESCRIPTION_BLOCKER_MODULE_NAME",
24
+ "PLAIN_LANGUAGE_BLOCKER_MODULE_NAME",
25
+ "HostedHookEntry",
26
+ "ALL_HOSTED_HOOK_ENTRIES",
27
+ ]
28
+
29
+ DENY_DECISION = "deny"
30
+ ALLOW_DECISION = "allow"
31
+ HOOK_EVENT_NAME = "PreToolUse"
32
+ BLOCKING_CRASH_EXIT_CODE = 2
33
+ EXIT_CODE_TWO_DENY_REASON = "[dispatcher] hook denied via exit code 2 — write blocked"
34
+
35
+ WRITE_TOOL_NAME = "Write"
36
+ EDIT_TOOL_NAME = "Edit"
37
+ MULTI_EDIT_TOOL_NAME = "MultiEdit"
38
+
39
+ ALL_WRITE_AND_EDIT_TOOL_NAMES: frozenset[str] = frozenset({WRITE_TOOL_NAME, EDIT_TOOL_NAME})
40
+ ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES: frozenset[str] = frozenset(
41
+ {WRITE_TOOL_NAME, EDIT_TOOL_NAME, MULTI_EDIT_TOOL_NAME}
42
+ )
43
+
44
+
45
+ STATE_DESCRIPTION_BLOCKER_MODULE_NAME = "state_description_blocker"
46
+ PLAIN_LANGUAGE_BLOCKER_MODULE_NAME = "plain_language_blocker"
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class HostedHookEntry:
51
+ """A single hosted hook with its applicable-tools constraint and blocking flag.
52
+
53
+ Attributes:
54
+ script_relative_path: Hook path relative to the hooks/ directory.
55
+ applicable_tool_names: Tool names this hook applies to. The dispatcher
56
+ skips the hook when the payload's tool is not in this set.
57
+ is_blocking: True when a crash surfaces a blocking signal; False when the
58
+ hook is advisory and a crash stays silent.
59
+ native_module_name: The importable module name whose evaluate function
60
+ the dispatcher calls in-process for this hook, or None when the hook
61
+ runs via runpy under __main__. The named module exposes a function
62
+ named NATIVE_EVALUATE_FUNCTION_NAME taking the payload dict and
63
+ returning a deny-reason string or None.
64
+ """
65
+
66
+ script_relative_path: str
67
+ applicable_tool_names: frozenset[str]
68
+ is_blocking: bool = field(default=True)
69
+ native_module_name: str | None = field(default=None)
70
+
71
+
72
+ ALL_HOSTED_HOOK_ENTRIES: tuple[HostedHookEntry, ...] = (
73
+ HostedHookEntry(
74
+ script_relative_path="blocking/write_existing_file_blocker.py",
75
+ applicable_tool_names=ALL_WRITE_AND_EDIT_TOOL_NAMES,
76
+ ),
77
+ HostedHookEntry(
78
+ script_relative_path="blocking/sensitive_file_protector.py",
79
+ applicable_tool_names=ALL_WRITE_AND_EDIT_TOOL_NAMES,
80
+ ),
81
+ HostedHookEntry(
82
+ script_relative_path="validation/hook_format_validator.py",
83
+ applicable_tool_names=ALL_WRITE_AND_EDIT_TOOL_NAMES,
84
+ ),
85
+ HostedHookEntry(
86
+ script_relative_path="blocking/code_rules_enforcer.py",
87
+ applicable_tool_names=ALL_WRITE_AND_EDIT_TOOL_NAMES,
88
+ ),
89
+ HostedHookEntry(
90
+ script_relative_path="blocking/tdd_enforcer.py",
91
+ applicable_tool_names=ALL_WRITE_AND_EDIT_TOOL_NAMES,
92
+ ),
93
+ HostedHookEntry(
94
+ script_relative_path="blocking/windows_rmtree_blocker.py",
95
+ applicable_tool_names=ALL_WRITE_AND_EDIT_TOOL_NAMES,
96
+ ),
97
+ HostedHookEntry(
98
+ script_relative_path="blocking/state_description_blocker.py",
99
+ applicable_tool_names=ALL_WRITE_AND_EDIT_TOOL_NAMES,
100
+ native_module_name=STATE_DESCRIPTION_BLOCKER_MODULE_NAME,
101
+ ),
102
+ HostedHookEntry(
103
+ script_relative_path="blocking/subprocess_budget_completeness.py",
104
+ applicable_tool_names=ALL_WRITE_AND_EDIT_TOOL_NAMES,
105
+ ),
106
+ HostedHookEntry(
107
+ script_relative_path="blocking/hook_prose_detector_consistency.py",
108
+ applicable_tool_names=ALL_WRITE_AND_EDIT_TOOL_NAMES,
109
+ ),
110
+ HostedHookEntry(
111
+ script_relative_path="blocking/verified_commit_message_accuracy_blocker.py",
112
+ applicable_tool_names=ALL_WRITE_AND_EDIT_TOOL_NAMES,
113
+ ),
114
+ HostedHookEntry(
115
+ script_relative_path="blocking/workflow_substitution_slot_blocker.py",
116
+ applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
117
+ ),
118
+ HostedHookEntry(
119
+ script_relative_path="blocking/claude_md_orphan_file_blocker.py",
120
+ applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
121
+ ),
122
+ HostedHookEntry(
123
+ script_relative_path="blocking/package_inventory_stale_blocker.py",
124
+ applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
125
+ ),
126
+ HostedHookEntry(
127
+ script_relative_path="blocking/pytest_testpaths_orphan_blocker.py",
128
+ applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
129
+ ),
130
+ HostedHookEntry(
131
+ script_relative_path="blocking/open_questions_in_plans_blocker.py",
132
+ applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
133
+ ),
134
+ HostedHookEntry(
135
+ script_relative_path="blocking/docstring_rule_gate_count_blocker.py",
136
+ applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
137
+ ),
138
+ HostedHookEntry(
139
+ script_relative_path="blocking/plain_language_blocker.py",
140
+ applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
141
+ native_module_name=PLAIN_LANGUAGE_BLOCKER_MODULE_NAME,
142
+ ),
143
+ )
@@ -0,0 +1,79 @@
1
+ """Constants for the pytest unregistered-test-directory blocker.
2
+
3
+ A package whose ``pyproject.toml`` declares ``[tool.pytest.ini_options]`` with an
4
+ explicit ``testpaths`` list runs only the directories that list names. A
5
+ ``test_*.py`` file written into a directory that no ``testpaths`` entry covers is
6
+ collected by no default ``pytest`` run, so the test silently never executes and a
7
+ regression in the code it guards passes the standard suite undetected. This
8
+ module holds the marker filename that anchors a pytest package, the key name
9
+ that identifies an explicit ``testpaths`` allowlist, the test-file basename
10
+ pattern, the package-root entry tokens and glob metacharacters that classify a
11
+ ``testpaths`` entry, the directory names the upward search prunes, the search
12
+ budget, and the block-message text the hook emits.
13
+ """
14
+
15
+ import re
16
+
17
+ __all__ = [
18
+ "PYPROJECT_FILENAME",
19
+ "TESTPATHS_KEY",
20
+ "TEST_FILE_BASENAME_PATTERN",
21
+ "PACKAGE_ROOT_ENTRY",
22
+ "PACKAGE_ROOT_ENTRY_PREFIX",
23
+ "GLOB_METACHARACTERS",
24
+ "ALL_PRUNED_PARENT_DIRECTORY_NAMES",
25
+ "MAX_PARENT_DIRECTORIES_SEARCHED",
26
+ "UNREGISTERED_TEST_DIRECTORY_MESSAGE_TEMPLATE",
27
+ "UNREGISTERED_TEST_DIRECTORY_SYSTEM_MESSAGE",
28
+ "UNREGISTERED_TEST_DIRECTORY_ADDITIONAL_CONTEXT",
29
+ ]
30
+
31
+ PYPROJECT_FILENAME: str = "pyproject.toml"
32
+
33
+ TESTPATHS_KEY: str = "testpaths"
34
+
35
+ TEST_FILE_BASENAME_PATTERN: re.Pattern[str] = re.compile(r"^test_.+\.py$")
36
+
37
+ PACKAGE_ROOT_ENTRY: str = "."
38
+
39
+ PACKAGE_ROOT_ENTRY_PREFIX: str = "./"
40
+
41
+ GLOB_METACHARACTERS: frozenset[str] = frozenset({"*", "?", "["})
42
+
43
+ ALL_PRUNED_PARENT_DIRECTORY_NAMES: frozenset[str] = frozenset(
44
+ {
45
+ ".git",
46
+ "__pycache__",
47
+ "node_modules",
48
+ ".pytest_cache",
49
+ ".ruff_cache",
50
+ }
51
+ )
52
+
53
+ MAX_PARENT_DIRECTORIES_SEARCHED: int = 40
54
+
55
+ UNREGISTERED_TEST_DIRECTORY_MESSAGE_TEMPLATE: str = (
56
+ "Test file {test_file} lands in a directory that the pytest config at "
57
+ "{pyproject} does not collect. That pyproject declares an explicit testpaths "
58
+ "allowlist, and no entry covers {test_directory} (relative to the package "
59
+ "root). A default `pytest` run from the package root never collects this file, "
60
+ "so the test silently never runs and a regression it would catch passes the "
61
+ "suite undetected. Add the directory to the testpaths list in {pyproject} "
62
+ "(for example `{suggested_entry}`) in the same change that adds the test."
63
+ )
64
+
65
+ UNREGISTERED_TEST_DIRECTORY_SYSTEM_MESSAGE: str = (
66
+ "test file lands outside the pytest testpaths allowlist - add its directory to "
67
+ "testpaths so the default suite collects it"
68
+ )
69
+
70
+ UNREGISTERED_TEST_DIRECTORY_ADDITIONAL_CONTEXT: str = (
71
+ "When a package's pyproject.toml declares [tool.pytest.ini_options] with an "
72
+ "explicit testpaths list, that list is the complete set of directories a "
73
+ "default pytest run collects. A test_*.py file written into a directory no "
74
+ "testpaths entry covers is collected by nobody: the default run skips it and "
75
+ "the regression it guards goes unnoticed. To resolve:\n"
76
+ " - add the test file's directory (relative to the package root) to the "
77
+ "testpaths list in pyproject.toml, or\n"
78
+ " - move the test under a directory the testpaths list already covers."
79
+ )
@@ -0,0 +1,18 @@
1
+ """Configuration constants for the send_user_file_open_locally_blocker PreToolUse hook."""
2
+
3
+ TOOL_NAME: str = "SendUserFile"
4
+
5
+ PROACTIVE_STATUS: str = "proactive"
6
+
7
+ CORRECTIVE_MESSAGE: str = (
8
+ "BLOCKED [open-locally]: SendUserFile attaches a file to the session, which "
9
+ "does not let the user see it while they are at the terminal. Open the file on "
10
+ "screen in its own viewer:\n"
11
+ " Start-Process pwsh -WindowStyle Hidden -ArgumentList "
12
+ "'-NoProfile','-File',\"$HOME\\.claude\\scripts\\Show-Asset.ps1\","
13
+ "'<path 1>','<path 2>'\n"
14
+ "Show-Asset.ps1 sizes each image window to the image and opens every other file "
15
+ "type in its default app. Pass every path the user named.\n"
16
+ "The one allowed attach is a phone push: when the user has stepped away and you "
17
+ 'want the file to reach their phone, call SendUserFile with status "proactive".'
18
+ )
@@ -0,0 +1,44 @@
1
+ """Regression tests proving the dispatcher constants docstrings clear the O6 gate.
2
+
3
+ The PreToolUse and PostToolUse dispatcher constants modules each carry a module
4
+ docstring stating what the module centralizes. Neither docstring may assert that
5
+ no literals appear inline in its companion dispatcher script — that completeness
6
+ claim about a sibling file is the deterministic Category O6 drift the enforcer's
7
+ check_docstring_no_inline_literal_claim blocks. These tests load the real
8
+ modules' source and assert the check returns no issues for either.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import importlib.util
14
+ from pathlib import Path
15
+ from types import ModuleType
16
+
17
+
18
+ def _load_enforcer_module() -> ModuleType:
19
+ enforcer_path = Path(__file__).resolve().parent.parent / "blocking" / "code_rules_enforcer.py"
20
+ enforcer_spec = importlib.util.spec_from_file_location("code_rules_enforcer", enforcer_path)
21
+ assert enforcer_spec is not None
22
+ assert enforcer_spec.loader is not None
23
+ enforcer_module = importlib.util.module_from_spec(enforcer_spec)
24
+ enforcer_spec.loader.exec_module(enforcer_module)
25
+ return enforcer_module
26
+
27
+
28
+ code_rules_enforcer = _load_enforcer_module()
29
+
30
+
31
+ def _check_issues_for(module_filename: str) -> list[str]:
32
+ module_path = Path(__file__).resolve().parent / module_filename
33
+ module_source = module_path.read_text(encoding="utf-8")
34
+ return code_rules_enforcer.check_docstring_no_inline_literal_claim(
35
+ module_source, str(module_path)
36
+ )
37
+
38
+
39
+ def test_pre_tool_use_dispatcher_constants_docstring_clears_o6_gate() -> None:
40
+ assert _check_issues_for("pre_tool_use_dispatcher_constants.py") == []
41
+
42
+
43
+ def test_post_tool_use_dispatcher_constants_docstring_clears_o6_gate() -> None:
44
+ assert _check_issues_for("post_tool_use_dispatcher_constants.py") == []
@@ -0,0 +1,159 @@
1
+ """Tests for the shared hook block logger."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime
6
+ import json
7
+ import stat
8
+ import sys
9
+ from pathlib import Path
10
+ from unittest.mock import patch
11
+
12
+ import pytest
13
+
14
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
15
+
16
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
17
+
18
+
19
+ def test_log_hook_block_writes_parseable_json_line(tmp_path: Path) -> None:
20
+ with patch.object(Path, "home", return_value=tmp_path):
21
+ log_hook_block(
22
+ calling_hook_name="test_hook.py",
23
+ hook_event="PreToolUse",
24
+ block_reason="test block reason",
25
+ tool_name="Bash",
26
+ offending_input_preview="echo hello",
27
+ )
28
+
29
+ log_path = tmp_path / ".claude" / "logs" / "hook-blocks.log"
30
+ assert log_path.exists()
31
+ line = log_path.read_text(encoding="utf-8").strip()
32
+ parsed = json.loads(line)
33
+
34
+ assert "timestamp" in parsed
35
+ assert parsed["hook"] == "test_hook.py"
36
+ assert parsed["event"] == "PreToolUse"
37
+ assert parsed["tool"] == "Bash"
38
+ assert parsed["reason"] == "test block reason"
39
+ assert parsed["preview"] == "echo hello"
40
+
41
+
42
+ def test_log_hook_block_creates_logs_directory(tmp_path: Path) -> None:
43
+ with patch.object(Path, "home", return_value=tmp_path):
44
+ log_hook_block(
45
+ calling_hook_name="some_hook.py",
46
+ hook_event="Stop",
47
+ block_reason="stop reason",
48
+ )
49
+
50
+ log_path = tmp_path / ".claude" / "logs" / "hook-blocks.log"
51
+ assert log_path.exists()
52
+
53
+
54
+ def test_log_hook_block_omits_none_fields(tmp_path: Path) -> None:
55
+ with patch.object(Path, "home", return_value=tmp_path):
56
+ log_hook_block(
57
+ calling_hook_name="minimal_hook.py",
58
+ hook_event="PreToolUse",
59
+ block_reason="some reason",
60
+ )
61
+
62
+ log_path = tmp_path / ".claude" / "logs" / "hook-blocks.log"
63
+ line = log_path.read_text(encoding="utf-8").strip()
64
+ parsed = json.loads(line)
65
+ assert "tool" not in parsed
66
+ assert "preview" not in parsed
67
+
68
+
69
+ def test_log_hook_block_appends_multiple_records(tmp_path: Path) -> None:
70
+ with patch.object(Path, "home", return_value=tmp_path):
71
+ log_hook_block(
72
+ calling_hook_name="hook_a.py",
73
+ hook_event="PreToolUse",
74
+ block_reason="first",
75
+ )
76
+ log_hook_block(
77
+ calling_hook_name="hook_b.py",
78
+ hook_event="Stop",
79
+ block_reason="second",
80
+ )
81
+
82
+ log_path = tmp_path / ".claude" / "logs" / "hook-blocks.log"
83
+ all_lines = log_path.read_text(encoding="utf-8").strip().splitlines()
84
+ assert len(all_lines) == 2
85
+ first_parsed = json.loads(all_lines[0])
86
+ second_parsed = json.loads(all_lines[1])
87
+ assert first_parsed["hook"] == "hook_a.py"
88
+ assert second_parsed["hook"] == "hook_b.py"
89
+
90
+
91
+ def test_log_hook_block_swallows_io_error_on_unwritable_log(tmp_path: Path) -> None:
92
+ logs_dir = tmp_path / ".claude" / "logs"
93
+ logs_dir.mkdir(parents=True, exist_ok=True)
94
+ log_path = logs_dir / "hook-blocks.log"
95
+ log_path.write_text("", encoding="utf-8")
96
+ log_path.chmod(stat.S_IREAD)
97
+
98
+ try:
99
+ with patch.object(Path, "home", return_value=tmp_path):
100
+ log_hook_block(
101
+ calling_hook_name="any_hook.py",
102
+ hook_event="PreToolUse",
103
+ block_reason="reason",
104
+ )
105
+ except OSError:
106
+ pytest.fail("log_hook_block raised OSError on unwritable log file")
107
+ finally:
108
+ log_path.chmod(stat.S_IREAD | stat.S_IWRITE)
109
+
110
+
111
+ def test_log_hook_block_swallows_runtime_error_when_home_unresolvable() -> None:
112
+ def raise_home_resolution_failure() -> Path:
113
+ raise RuntimeError("Could not determine home directory.")
114
+
115
+ with patch.object(Path, "home", side_effect=raise_home_resolution_failure):
116
+ try:
117
+ returned_nothing = log_hook_block(
118
+ calling_hook_name="any_hook.py",
119
+ hook_event="PreToolUse",
120
+ block_reason="reason",
121
+ )
122
+ except RuntimeError:
123
+ pytest.fail("log_hook_block raised RuntimeError when home was unresolvable")
124
+
125
+ assert returned_nothing is None
126
+
127
+
128
+ def test_log_hook_block_truncates_long_preview(tmp_path: Path) -> None:
129
+ long_input = "x" * 600
130
+
131
+ with patch.object(Path, "home", return_value=tmp_path):
132
+ log_hook_block(
133
+ calling_hook_name="hook.py",
134
+ hook_event="PreToolUse",
135
+ block_reason="reason",
136
+ offending_input_preview=long_input,
137
+ )
138
+
139
+ log_path = tmp_path / ".claude" / "logs" / "hook-blocks.log"
140
+ line = log_path.read_text(encoding="utf-8").strip()
141
+ parsed = json.loads(line)
142
+ assert len(parsed["preview"]) <= 500
143
+
144
+
145
+ def test_log_hook_block_timestamp_is_iso8601(tmp_path: Path) -> None:
146
+ before = datetime.datetime.now()
147
+ with patch.object(Path, "home", return_value=tmp_path):
148
+ log_hook_block(
149
+ calling_hook_name="ts_hook.py",
150
+ hook_event="PreToolUse",
151
+ block_reason="ts test",
152
+ )
153
+ after = datetime.datetime.now()
154
+
155
+ log_path = tmp_path / ".claude" / "logs" / "hook-blocks.log"
156
+ line = log_path.read_text(encoding="utf-8").strip()
157
+ parsed = json.loads(line)
158
+ parsed_timestamp = datetime.datetime.fromisoformat(parsed["timestamp"])
159
+ assert before <= parsed_timestamp <= after
@@ -4,6 +4,13 @@ from datetime import datetime
4
4
  import json
5
5
  import os
6
6
  import sys
7
+ from pathlib import Path
8
+
9
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
10
+ if _hooks_dir not in sys.path:
11
+ sys.path.insert(0, _hooks_dir)
12
+
13
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
7
14
 
8
15
  AUDIT_LOG = os.path.expanduser("~/.claude/cache/config-change-audit.log")
9
16
  # pragma: no-tdd-gate
@@ -68,6 +75,11 @@ def guard_hook_injection(file_path: str) -> None:
68
75
  "decision": "block",
69
76
  "reason": block_reason,
70
77
  }
78
+ log_hook_block(
79
+ calling_hook_name="config_change_guard.py",
80
+ hook_event="ConfigChange",
81
+ block_reason=block_reason,
82
+ )
71
83
  print(json.dumps(block_payload))
72
84
  return
73
85
 
@@ -109,3 +109,26 @@ def test_non_user_settings_source_produces_no_output(tmp_path: Path) -> None:
109
109
  assert hook_run.returncode == 0
110
110
  assert hook_run.stderr.strip() == ""
111
111
  assert hook_run.stdout.strip() == ""
112
+
113
+
114
+ def test_block_logs_config_change_event(tmp_path: Path) -> None:
115
+ fake_home = tmp_path / "home"
116
+ fake_home.mkdir()
117
+ known_count_file = tmp_path / "known-hook-count.txt"
118
+ known_count_file.write_text("2")
119
+ settings_path = _make_settings_with_hook_count(5, tmp_path)
120
+
121
+ hook_run = _run_hook(
122
+ source="user_settings",
123
+ file_path=settings_path,
124
+ extra_env={
125
+ "KNOWN_HOOK_COUNT_FILE": str(known_count_file),
126
+ "HOME": str(fake_home),
127
+ "USERPROFILE": str(fake_home),
128
+ },
129
+ )
130
+
131
+ assert hook_run.returncode == 0
132
+ log_path = fake_home / ".claude" / "logs" / "hook-blocks.log"
133
+ logged_record = json.loads(log_path.read_text(encoding="utf-8").splitlines()[-1])
134
+ assert logged_record["event"] == "ConfigChange"