claude-dev-env 1.82.0 → 1.84.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +16 -13
- package/_shared/pr-loop/audit-contract.md +24 -12
- package/_shared/pr-loop/scripts/CLAUDE.md +1 -0
- package/_shared/pr-loop/scripts/README.md +1 -0
- package/_shared/pr-loop/scripts/_claude_permissions_common.py +16 -5
- package/_shared/pr-loop/scripts/code_rules_gate.py +2 -3
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviewer_availability_constants.py +12 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +0 -2
- package/_shared/pr-loop/scripts/reviewer_availability.py +182 -0
- package/_shared/pr-loop/scripts/reviews_disabled.py +2 -0
- package/_shared/pr-loop/scripts/terminology_sweep.py +9 -33
- package/_shared/pr-loop/scripts/tests/CLAUDE.md +2 -0
- package/_shared/pr-loop/scripts/tests/test__claude_permissions_common.py +212 -0
- package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +18 -0
- package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +18 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +37 -0
- package/_shared/pr-loop/scripts/tests/test_reviewer_availability.py +159 -0
- package/_shared/pr-loop/scripts/tests/test_reviewer_availability_constants.py +36 -0
- package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +14 -0
- package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +14 -4
- package/agents/clean-coder.md +3 -0
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
- package/bin/install.mjs +16 -8
- package/bin/install.test.mjs +17 -6
- package/commands/CLAUDE.md +0 -1
- package/docs/CODE_RULES.md +1 -1
- package/hooks/blocking/CLAUDE.md +2 -4
- package/hooks/blocking/code_rules_constants_config.py +164 -1
- package/hooks/blocking/code_rules_docstrings.py +428 -15
- package/hooks/blocking/code_rules_enforcer.py +33 -0
- package/hooks/blocking/code_rules_imports_logging.py +867 -1
- package/hooks/blocking/code_rules_magic_values.py +5 -0
- package/hooks/blocking/code_rules_naming_collection.py +152 -6
- package/hooks/blocking/code_rules_shared.py +34 -0
- package/hooks/blocking/code_rules_string_magic.py +68 -0
- package/hooks/blocking/duplicate_rmtree_helper_blocker.py +4 -4
- package/hooks/blocking/pre_tool_use_dispatcher.py +3 -3
- package/hooks/blocking/reviewer_spawn_gate.py +182 -0
- package/hooks/blocking/stale_comment_reference_blocker.py +267 -0
- package/hooks/blocking/state_description_blocker.py +96 -5
- package/hooks/blocking/test_code_rules_config_duplicate_path_anchor.py +132 -0
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_delegation_summary.py +385 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_prose_wall_illustration.py +197 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_runon_sentence.py +27 -0
- package/hooks/blocking/test_code_rules_enforcer_join_separator_magic.py +67 -0
- package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +40 -0
- package/hooks/blocking/test_code_rules_enforcer_naive_datetime.py +213 -0
- package/hooks/blocking/test_code_rules_enforcer_referenced_underscore_loop.py +169 -0
- package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +33 -0
- package/hooks/blocking/test_code_rules_enforcer_split_shared.py +23 -0
- package/hooks/blocking/test_code_rules_js_bare_flag_return_directive.py +266 -0
- package/hooks/blocking/test_code_rules_js_sibling_return_object_key_drift.py +490 -0
- package/hooks/blocking/test_code_rules_logging_adjacent_literals.py +171 -0
- package/hooks/blocking/test_code_rules_magic_values.py +54 -0
- package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +0 -6
- package/hooks/blocking/test_pr_converge_bugteam_enforcer_state_tolerance.py +184 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +9 -3
- package/hooks/blocking/test_reviewer_spawn_gate.py +230 -0
- package/hooks/blocking/test_shared_stdin_adoption.py +5 -30
- package/hooks/blocking/test_stale_comment_reference_blocker.py +236 -0
- package/hooks/blocking/test_state_description_blocker.py +135 -0
- package/hooks/hooks.json +5 -0
- package/hooks/hooks_constants/CLAUDE.md +3 -4
- package/hooks/hooks_constants/blocking_check_limits.py +46 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +41 -0
- package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -1
- package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +0 -1
- package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +0 -5
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
- package/hooks/hooks_constants/reviewer_spawn_gate_constants.py +41 -0
- package/hooks/hooks_constants/stale_comment_reference_blocker_constants.py +76 -0
- package/hooks/hooks_constants/state_description_blocker_constants.py +8 -0
- package/hooks/validation/post_tool_use_dispatcher.py +1 -1
- package/hooks/validation/test_post_tool_use_dispatcher.py +51 -35
- package/hooks/workflow/CLAUDE.md +2 -8
- package/package.json +1 -1
- package/rules/CLAUDE.md +5 -2
- package/rules/claude-md-orphan-file.md +5 -0
- package/rules/docstring-prose-matches-implementation.md +10 -1
- package/rules/env-var-table-code-drift.md +5 -0
- package/rules/es-exe-file-search.md +17 -0
- package/rules/no-historical-clutter.md +12 -1
- package/rules/orphan-css-class.md +5 -0
- package/rules/package-inventory-stale-entry.md +10 -0
- package/rules/paired-test-coverage.md +5 -0
- package/rules/plain-illustrative-docstrings.md +40 -1
- package/rules/verify-before-asking.md +7 -0
- package/rules/verify-runtime-state.md +40 -0
- package/rules/windows-filesystem-safe.md +8 -0
- package/rules/workers-done-before-complete.md +33 -0
- package/rules/workflow-substitution-slots.md +5 -0
- package/skills/CLAUDE.md +7 -2
- package/skills/autoconverge/SKILL.md +48 -61
- package/skills/autoconverge/reference/closing-report.md +6 -6
- package/skills/autoconverge/reference/convergence.md +17 -15
- package/skills/autoconverge/reference/gotchas.md +6 -3
- package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +2 -9
- package/skills/autoconverge/workflow/converge.contract.test.mjs +153 -10
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +55 -19
- package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +16 -9
- package/skills/autoconverge/workflow/converge.mjs +167 -106
- package/skills/autoconverge/workflow/render_report.py +7 -11
- package/skills/bugteam/CLAUDE.md +1 -1
- package/skills/bugteam/PROMPTS.md +7 -6
- package/skills/bugteam/SKILL.md +25 -72
- package/skills/bugteam/reference/CLAUDE.md +1 -3
- package/skills/bugteam/reference/README.md +1 -1
- package/skills/bugteam/reference/audit-and-teammates.md +1 -1
- package/skills/bugteam/reference/obstacles/fix-publish-summary.md +1 -1
- package/skills/bugteam/reference/team-setup.md +8 -7
- package/skills/bugteam/scripts/CLAUDE.md +0 -6
- package/skills/bugteam/scripts/README.md +0 -4
- package/skills/bugteam/scripts/bugteam_scripts_constants/CLAUDE.md +0 -1
- package/skills/code/SKILL.md +2 -0
- package/skills/copilot-review/CLAUDE.md +1 -1
- package/skills/copilot-review/SKILL.md +25 -23
- package/skills/everything-search/SKILL.md +5 -0
- package/skills/findbugs/CLAUDE.md +2 -2
- package/skills/findbugs/SKILL.md +22 -83
- package/skills/fixbugs/SKILL.md +2 -4
- package/skills/log-audit/CLAUDE.md +20 -0
- package/skills/log-audit/SKILL.md +68 -0
- package/skills/log-audit/reference/CLAUDE.md +9 -0
- package/skills/log-audit/reference/charter.md +52 -0
- package/skills/log-audit/scripts/CLAUDE.md +27 -0
- package/skills/log-audit/scripts/cluster_recurrences.py +261 -0
- package/skills/log-audit/scripts/collect_log_window.py +199 -0
- package/skills/log-audit/scripts/log_audit_constants/CLAUDE.md +12 -0
- package/skills/log-audit/scripts/log_audit_constants/cluster_recurrences_constants.py +23 -0
- package/skills/log-audit/scripts/log_audit_constants/collect_log_window_constants.py +24 -0
- package/skills/log-audit/scripts/log_audit_constants/mine_copilot_findings_constants.py +49 -0
- package/skills/log-audit/scripts/mine_copilot_findings.py +302 -0
- package/skills/log-audit/scripts/test_cluster_recurrences.py +160 -0
- package/skills/log-audit/scripts/test_collect_log_window.py +111 -0
- package/skills/log-audit/scripts/test_mine_copilot_findings.py +126 -0
- package/skills/monitor-open-prs/SKILL.md +2 -2
- package/skills/post-audit-findings/SKILL.md +84 -0
- package/skills/pr-converge/CLAUDE.md +2 -0
- package/skills/pr-converge/SKILL.md +72 -59
- package/skills/pr-converge/reference/CLAUDE.md +1 -1
- package/skills/pr-converge/reference/convergence-gates.md +16 -19
- package/skills/pr-converge/reference/examples.md +5 -5
- package/skills/pr-converge/reference/fix-protocol.md +16 -43
- package/skills/pr-converge/reference/obstacles/fix-publish-summary.md +1 -1
- package/skills/pr-converge/reference/per-tick.md +24 -45
- package/skills/pr-converge/reference/state-schema.md +15 -0
- package/skills/pr-converge/scripts/README.md +3 -5
- package/skills/pr-fix-protocol/SKILL.md +70 -0
- package/skills/pr-loop-lifecycle/SKILL.md +73 -0
- package/skills/{bugteam → pr-loop-lifecycle}/reference/teardown-publish-permissions.md +22 -24
- package/skills/pr-scope-resolve/SKILL.md +48 -0
- package/skills/qbug/CLAUDE.md +4 -4
- package/skills/qbug/SKILL.md +46 -144
- package/skills/qbug/test_qbug_skill_audit_schema.py +2 -2
- package/skills/qbug/test_qbug_skill_post_fix_audit.py +1 -1
- package/skills/reviewer-gates/SKILL.md +96 -0
- package/skills/session-log/CLAUDE.md +7 -7
- package/skills/session-log/SKILL.md +27 -44
- package/skills/test_markdown_link_integrity.py +103 -0
- package/commands/doc-gist.md +0 -16
- package/hooks/blocking/_md_to_html_blocker_test_support.py +0 -65
- package/hooks/blocking/conftest.py +0 -30
- package/hooks/blocking/md_path_exemptions.py +0 -224
- package/hooks/blocking/md_to_html_blocker.py +0 -155
- package/hooks/blocking/test_md_to_html_blocker_exemptions.py +0 -434
- package/hooks/blocking/test_md_to_html_blocker_extensions.py +0 -157
- package/hooks/blocking/test_md_to_html_blocker_path_resolution.py +0 -336
- package/hooks/hooks_constants/doc_gist_auto_publish_constants.py +0 -18
- package/hooks/hooks_constants/html_companion_constants.py +0 -20
- package/hooks/hooks_constants/md_to_html_blocker_constants.py +0 -76
- package/hooks/hooks_constants/test_md_to_html_blocker_constants.py +0 -125
- package/hooks/workflow/doc_gist_auto_publish.py +0 -144
- package/hooks/workflow/md_to_html_companion.py +0 -358
- package/hooks/workflow/test_doc_gist_auto_publish.py +0 -117
- package/hooks/workflow/test_md_to_html_companion.py +0 -613
- package/skills/bugteam/reference/audit-contract.md +0 -163
- package/skills/bugteam/scripts/_bugteam_permissions_common.py +0 -455
- package/skills/bugteam/scripts/bugteam_scripts_constants/claude_permissions_common_constants.py +0 -69
- package/skills/bugteam/scripts/grant_project_claude_permissions.py +0 -280
- package/skills/bugteam/scripts/revoke_project_claude_permissions.py +0 -266
- package/skills/bugteam/scripts/test__bugteam_permissions_common.py +0 -160
- package/skills/bugteam/scripts/test_agent_config_carveout.py +0 -356
- package/skills/bugteam/scripts/test_bugteam_permissions_common.py +0 -140
- package/skills/doc-gist/CLAUDE.md +0 -25
- package/skills/doc-gist/SKILL.md +0 -97
- package/skills/doc-gist/references/CLAUDE.md +0 -9
- package/skills/doc-gist/references/examples/01-exploration-code-approaches.html +0 -453
- package/skills/doc-gist/references/examples/02-exploration-visual-designs.html +0 -515
- package/skills/doc-gist/references/examples/03-code-review-pr.html +0 -638
- package/skills/doc-gist/references/examples/04-code-understanding.html +0 -491
- package/skills/doc-gist/references/examples/05-design-system.html +0 -629
- package/skills/doc-gist/references/examples/06-component-variants.html +0 -605
- package/skills/doc-gist/references/examples/07-prototype-animation.html +0 -455
- package/skills/doc-gist/references/examples/08-prototype-interaction.html +0 -396
- package/skills/doc-gist/references/examples/09-slide-deck.html +0 -592
- package/skills/doc-gist/references/examples/10-svg-illustrations.html +0 -492
- package/skills/doc-gist/references/examples/11-status-report.html +0 -528
- package/skills/doc-gist/references/examples/12-incident-report.html +0 -596
- package/skills/doc-gist/references/examples/13-flowchart-diagram.html +0 -395
- package/skills/doc-gist/references/examples/14-research-feature-explainer.html +0 -381
- package/skills/doc-gist/references/examples/15-research-concept-explainer.html +0 -368
- package/skills/doc-gist/references/examples/16-implementation-plan.html +0 -702
- package/skills/doc-gist/references/examples/17-pr-writeup.html +0 -595
- package/skills/doc-gist/references/examples/18-editor-triage-board.html +0 -573
- package/skills/doc-gist/references/examples/19-editor-feature-flags.html +0 -663
- package/skills/doc-gist/references/examples/20-editor-prompt-tuner.html +0 -722
- package/skills/doc-gist/references/examples/21-decision-signoff.html +0 -546
- package/skills/doc-gist/references/examples/CLAUDE.md +0 -25
- package/skills/doc-gist/references/examples/README.md +0 -5
- package/skills/doc-gist/scripts/CLAUDE.md +0 -27
- package/skills/doc-gist/scripts/doc_gist_scripts_constants/CLAUDE.md +0 -10
- package/skills/doc-gist/scripts/doc_gist_scripts_constants/gist_upload_constants.py +0 -16
- package/skills/doc-gist/scripts/gist_upload.py +0 -177
- package/skills/doc-gist/scripts/test_gist_upload.py +0 -51
- /package/skills/{doc-gist/scripts/doc_gist_scripts_constants → log-audit/scripts/log_audit_constants}/__init__.py +0 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse hook: blocks an Edit that rewrites a Python code line while keeping a contradicting comment above it.
|
|
3
|
+
|
|
4
|
+
Say a comment reads ``# Mock asyncio`` right above a line that patches
|
|
5
|
+
``asyncio.sleep``. An Edit that rewrites just that line to patch something
|
|
6
|
+
else, but leaves the comment untouched, orphans the comment: it still names
|
|
7
|
+
``asyncio``, but the code below no longer does. The same gap opens when the
|
|
8
|
+
Edit deletes the line outright instead of rewriting it. The hook locates
|
|
9
|
+
each occurrence of the edit's ``old_string`` in the file and reads the line
|
|
10
|
+
directly above it, denying the edit when that line is a standalone ``#``
|
|
11
|
+
comment naming an identifier ``old_string`` carries and ``new_string`` drops.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import TextIO
|
|
19
|
+
|
|
20
|
+
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
21
|
+
if _hooks_dir not in sys.path:
|
|
22
|
+
sys.path.insert(0, _hooks_dir)
|
|
23
|
+
|
|
24
|
+
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
25
|
+
from hooks_constants.pre_tool_use_dispatcher_constants import EDIT_TOOL_NAME # noqa: E402
|
|
26
|
+
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin # noqa: E402
|
|
27
|
+
from hooks_constants.stale_comment_reference_blocker_constants import ( # noqa: E402
|
|
28
|
+
ALL_COMMENT_STOPWORDS,
|
|
29
|
+
COMMENT_IDENTIFIER_PATTERN,
|
|
30
|
+
COMMENT_LINE_PREFIX,
|
|
31
|
+
PYTHON_FILE_SUFFIX,
|
|
32
|
+
STALE_COMMENT_ADDITIONAL_CONTEXT,
|
|
33
|
+
STALE_COMMENT_DENY_TEMPLATE,
|
|
34
|
+
STALE_COMMENT_SYSTEM_MESSAGE,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _first_orphaned_identifier(
|
|
39
|
+
overlying_comment: str,
|
|
40
|
+
all_old_block_lines: list[str],
|
|
41
|
+
all_new_block_lines: list[str],
|
|
42
|
+
) -> str | None:
|
|
43
|
+
"""Return the first comment identifier the edit removes from the block.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
overlying_comment: The stripped standalone comment text above the block.
|
|
47
|
+
all_old_block_lines: The replaced lines as they read before the edit.
|
|
48
|
+
all_new_block_lines: The replacement lines as they read after the edit.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
The first identifier the comment names that matches the old block and
|
|
52
|
+
not the new block, or None when the comment stays consistent.
|
|
53
|
+
"""
|
|
54
|
+
words_in_comment = overlying_comment.lstrip(COMMENT_LINE_PREFIX).strip()
|
|
55
|
+
original_block_text = "\n".join(all_old_block_lines)
|
|
56
|
+
revised_block_text = "\n".join(all_new_block_lines)
|
|
57
|
+
for each_identifier in COMMENT_IDENTIFIER_PATTERN.findall(words_in_comment):
|
|
58
|
+
if each_identifier.lower() in ALL_COMMENT_STOPWORDS:
|
|
59
|
+
continue
|
|
60
|
+
bounded_pattern = re.compile(
|
|
61
|
+
"(?<![A-Za-z0-9_])" + re.escape(each_identifier) + "(?![A-Za-z0-9_])"
|
|
62
|
+
)
|
|
63
|
+
if bounded_pattern.search(original_block_text) and not bounded_pattern.search(
|
|
64
|
+
revised_block_text
|
|
65
|
+
):
|
|
66
|
+
return each_identifier
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _occurrence_start_offsets(
|
|
71
|
+
old_content: str,
|
|
72
|
+
old_string: str,
|
|
73
|
+
is_replace_all: bool,
|
|
74
|
+
) -> list[int]:
|
|
75
|
+
"""List the character offsets where old_string starts in old_content.
|
|
76
|
+
|
|
77
|
+
Walks old_content left to right the same way str.replace does, so the
|
|
78
|
+
offsets line up with the occurrences the edit actually rewrites.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
old_content: The file text before the edit.
|
|
82
|
+
old_string: The text the edit replaces.
|
|
83
|
+
is_replace_all: Whether every occurrence is collected, matching the
|
|
84
|
+
Edit tool's replace_all flag, or only the first one.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
The offset of each matching occurrence, in file order.
|
|
88
|
+
"""
|
|
89
|
+
all_offsets: list[int] = []
|
|
90
|
+
search_from = 0
|
|
91
|
+
while True:
|
|
92
|
+
found_at = old_content.find(old_string, search_from)
|
|
93
|
+
if found_at == -1:
|
|
94
|
+
return all_offsets
|
|
95
|
+
all_offsets.append(found_at)
|
|
96
|
+
if not is_replace_all:
|
|
97
|
+
return all_offsets
|
|
98
|
+
search_from = found_at + len(old_string)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _preceding_line_text(old_content: str, occurrence_start: int) -> str | None:
|
|
102
|
+
"""Return the stripped line directly above the line an occurrence sits in.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
old_content: The file text before the edit.
|
|
106
|
+
occurrence_start: The character offset where the occurrence begins.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
The stripped text of the line above the occurrence's line, or None
|
|
110
|
+
when that line is the first line in the file.
|
|
111
|
+
"""
|
|
112
|
+
containing_line_start = old_content.rfind("\n", 0, occurrence_start) + 1
|
|
113
|
+
if containing_line_start == 0:
|
|
114
|
+
return None
|
|
115
|
+
preceding_line_end = containing_line_start - 1
|
|
116
|
+
preceding_line_start = old_content.rfind("\n", 0, preceding_line_end) + 1
|
|
117
|
+
return old_content[preceding_line_start:preceding_line_end].strip()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _find_stale_comment_reference(
|
|
121
|
+
old_content: str,
|
|
122
|
+
old_string: str,
|
|
123
|
+
new_string: str,
|
|
124
|
+
is_replace_all: bool,
|
|
125
|
+
file_path: str,
|
|
126
|
+
) -> str | None:
|
|
127
|
+
"""Check each occurrence old_string rewrites for an orphaned comment above it.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
old_content: The file text before the edit.
|
|
131
|
+
old_string: The text the edit replaces.
|
|
132
|
+
new_string: The text the edit substitutes in, empty for a straight
|
|
133
|
+
deletion.
|
|
134
|
+
is_replace_all: Whether every occurrence of old_string is checked,
|
|
135
|
+
matching the Edit tool's replace_all flag.
|
|
136
|
+
file_path: The target path, named in the deny reason.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
The deny-reason text for the first occurrence whose kept comment
|
|
140
|
+
names an identifier old_string carries and new_string drops, or None
|
|
141
|
+
when every kept comment stays consistent with the rewritten line.
|
|
142
|
+
"""
|
|
143
|
+
all_old_block_lines = old_string.splitlines()
|
|
144
|
+
all_new_block_lines = new_string.splitlines()
|
|
145
|
+
for each_occurrence_start in _occurrence_start_offsets(old_content, old_string, is_replace_all):
|
|
146
|
+
preceding_line = _preceding_line_text(old_content, each_occurrence_start)
|
|
147
|
+
if preceding_line is None or not preceding_line.startswith(COMMENT_LINE_PREFIX):
|
|
148
|
+
continue
|
|
149
|
+
maybe_identifier = _first_orphaned_identifier(
|
|
150
|
+
preceding_line, all_old_block_lines, all_new_block_lines
|
|
151
|
+
)
|
|
152
|
+
if maybe_identifier is not None:
|
|
153
|
+
return STALE_COMMENT_DENY_TEMPLATE.format(
|
|
154
|
+
file_path=file_path,
|
|
155
|
+
contradicted_comment=preceding_line,
|
|
156
|
+
orphaned_name=maybe_identifier,
|
|
157
|
+
)
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def evaluate(payload_by_key: dict[str, object]) -> str | None:
|
|
162
|
+
"""Decide whether an Edit payload orphans a comment above a changed line.
|
|
163
|
+
|
|
164
|
+
Reads the target file from disk and checks the line directly above each
|
|
165
|
+
occurrence the edit rewrites for a kept standalone comment whose named
|
|
166
|
+
identifier the edit removes from the line below it. Non-Edit tools,
|
|
167
|
+
non-Python targets, unreadable files, and an old_string absent from the
|
|
168
|
+
file all pass.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
payload_by_key: The PreToolUse payload with tool_name and tool_input.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
The deny-reason text when the edit is denied, or None when allowed.
|
|
175
|
+
"""
|
|
176
|
+
raw_tool_name = payload_by_key.get("tool_name", "")
|
|
177
|
+
tool_name = raw_tool_name if isinstance(raw_tool_name, str) else ""
|
|
178
|
+
if tool_name != EDIT_TOOL_NAME:
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
raw_tool_input = payload_by_key.get("tool_input", {})
|
|
182
|
+
tool_input = raw_tool_input if isinstance(raw_tool_input, dict) else {}
|
|
183
|
+
|
|
184
|
+
file_path = tool_input.get("file_path", "")
|
|
185
|
+
if not isinstance(file_path, str) or not file_path.endswith(PYTHON_FILE_SUFFIX):
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
raw_old_string = tool_input.get("old_string", "")
|
|
189
|
+
raw_new_string = tool_input.get("new_string", "")
|
|
190
|
+
old_string = raw_old_string if isinstance(raw_old_string, str) else ""
|
|
191
|
+
new_string = raw_new_string if isinstance(raw_new_string, str) else ""
|
|
192
|
+
if not old_string:
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
old_content = Path(file_path).read_text(encoding="utf-8")
|
|
197
|
+
except (OSError, UnicodeDecodeError):
|
|
198
|
+
return None
|
|
199
|
+
if old_string not in old_content:
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
is_replace_all = tool_input.get("replace_all") is True
|
|
203
|
+
return _find_stale_comment_reference(
|
|
204
|
+
old_content, old_string, new_string, is_replace_all, file_path
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def build_deny_payload(deny_reason: str) -> dict[str, object]:
|
|
209
|
+
"""Build the full deny payload the hook writes for a deny-reason string.
|
|
210
|
+
|
|
211
|
+
Logs the block, then returns the permission decision with the corrective
|
|
212
|
+
guidance in additionalContext, the user-facing systemMessage, and output
|
|
213
|
+
suppression, matching the deny shape the sibling blockers write.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
deny_reason: The permissionDecisionReason text for the denial.
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
The deny payload dictionary the hook serializes to stdout.
|
|
220
|
+
"""
|
|
221
|
+
log_hook_block(
|
|
222
|
+
calling_hook_name="stale_comment_reference_blocker.py",
|
|
223
|
+
hook_event="PreToolUse",
|
|
224
|
+
block_reason=deny_reason,
|
|
225
|
+
)
|
|
226
|
+
return {
|
|
227
|
+
"hookSpecificOutput": {
|
|
228
|
+
"hookEventName": "PreToolUse",
|
|
229
|
+
"permissionDecision": "deny",
|
|
230
|
+
"permissionDecisionReason": deny_reason,
|
|
231
|
+
"additionalContext": STALE_COMMENT_ADDITIONAL_CONTEXT,
|
|
232
|
+
},
|
|
233
|
+
"systemMessage": STALE_COMMENT_SYSTEM_MESSAGE,
|
|
234
|
+
"suppressOutput": True,
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def main() -> None:
|
|
239
|
+
"""Run the gate over the stdin payload dictionary and emit any denial.
|
|
240
|
+
"""
|
|
241
|
+
payload_dictionary = read_hook_input_dictionary_from_stdin()
|
|
242
|
+
if payload_dictionary is None:
|
|
243
|
+
sys.exit(0)
|
|
244
|
+
|
|
245
|
+
deny_reason = evaluate(payload_dictionary)
|
|
246
|
+
if deny_reason is None:
|
|
247
|
+
sys.exit(0)
|
|
248
|
+
|
|
249
|
+
_emit_deny_line(build_deny_payload(deny_reason), sys.stdout)
|
|
250
|
+
sys.exit(0)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _emit_deny_line(
|
|
254
|
+
all_deny_payload_fields: dict[str, object], destination_stream: TextIO
|
|
255
|
+
) -> None:
|
|
256
|
+
"""Write the deny payload JSON as one line to the given stream.
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
all_deny_payload_fields: The deny payload to serialize.
|
|
260
|
+
destination_stream: The stream the JSON line is written to.
|
|
261
|
+
"""
|
|
262
|
+
destination_stream.write(json.dumps(all_deny_payload_fields) + "\n")
|
|
263
|
+
destination_stream.flush()
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
if __name__ == "__main__":
|
|
267
|
+
main()
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""PreToolUse hook: blocks Write/Edit containing historical/comparative language in comments and .md files.
|
|
2
|
+
"""PreToolUse hook: blocks Write/Edit containing historical/comparative language in comments, Python docstrings, and .md files.
|
|
3
3
|
|
|
4
4
|
Enforces the "describe current state only" rule — no "instead of", "previously",
|
|
5
|
-
"now uses", or similar transitional framing. Comments and
|
|
6
|
-
describe what IS, not what WAS or what CHANGED.
|
|
5
|
+
"now uses", or similar transitional framing. Comments, docstrings, and
|
|
6
|
+
documentation should describe what IS, not what WAS or what CHANGED. Inside a
|
|
7
|
+
docstring, a phrase wrapped in double quotes or backticks is a mention rather
|
|
8
|
+
than a use, so quoted spans are stripped before the scan.
|
|
7
9
|
"""
|
|
8
10
|
|
|
11
|
+
import ast
|
|
9
12
|
import json
|
|
10
13
|
import os
|
|
11
14
|
import sys
|
|
@@ -23,11 +26,15 @@ from hooks_constants.state_description_blocker_constants import ( # noqa: E402
|
|
|
23
26
|
ALL_BLOCK_COMMENT_ONLY_EXTENSIONS,
|
|
24
27
|
ALL_COMMENT_BEARING_EXTENSIONS,
|
|
25
28
|
ALL_COMMENT_TRANSITION_PATTERNS,
|
|
29
|
+
ALL_DEFINITION_HEADER_PREFIXES,
|
|
26
30
|
ALL_HASH_AND_SLASH_EXTENSIONS,
|
|
27
31
|
ALL_HASH_ONLY_EXTENSIONS,
|
|
28
32
|
ALL_MARKDOWN_EXTENSIONS,
|
|
29
33
|
CODE_FENCE_PATTERN,
|
|
34
|
+
DOUBLE_QUOTED_SPAN_PATTERN,
|
|
30
35
|
INLINE_CODE_PATTERN,
|
|
36
|
+
PYTHON_EXTENSION,
|
|
37
|
+
TRIPLE_QUOTED_BLOCK_PATTERN,
|
|
31
38
|
)
|
|
32
39
|
|
|
33
40
|
|
|
@@ -131,17 +138,101 @@ def _find_inline_comment_start(stripped: str, all_markers: tuple[str, ...]) -> i
|
|
|
131
138
|
return best_position
|
|
132
139
|
|
|
133
140
|
|
|
141
|
+
def _extract_parsed_docstrings(tree: ast.Module) -> list[str]:
|
|
142
|
+
"""Collect the module, class, and function docstrings from a parsed tree.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
tree: The parsed module tree to walk.
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
Every docstring found on the module and its class/function definitions.
|
|
149
|
+
"""
|
|
150
|
+
all_found_docstrings: list[str] = []
|
|
151
|
+
for each_node in ast.walk(tree):
|
|
152
|
+
if not isinstance(
|
|
153
|
+
each_node, (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)
|
|
154
|
+
):
|
|
155
|
+
continue
|
|
156
|
+
maybe_docstring = ast.get_docstring(each_node, clean=False)
|
|
157
|
+
if maybe_docstring:
|
|
158
|
+
all_found_docstrings.append(maybe_docstring)
|
|
159
|
+
return all_found_docstrings
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _extract_fragment_docstrings(text: str) -> list[str]:
|
|
163
|
+
"""Collect docstring-positioned triple-quoted blocks from an unparseable fragment.
|
|
164
|
+
|
|
165
|
+
A triple-quoted block counts as a docstring when it opens the fragment
|
|
166
|
+
(only whitespace before it) or when the nearest preceding non-blank line is
|
|
167
|
+
a def/class header ending in a colon. A block assigned to a name or sitting
|
|
168
|
+
elsewhere in the fragment is data and is skipped.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
text: The Python source fragment that failed to parse.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
The body text of each docstring-positioned triple-quoted block.
|
|
175
|
+
"""
|
|
176
|
+
all_found_docstrings: list[str] = []
|
|
177
|
+
for each_match in TRIPLE_QUOTED_BLOCK_PATTERN.finditer(text):
|
|
178
|
+
body = each_match.group(1) if each_match.group(1) is not None else each_match.group(2)
|
|
179
|
+
leading_text = text[: each_match.start()]
|
|
180
|
+
if not leading_text.strip():
|
|
181
|
+
all_found_docstrings.append(body)
|
|
182
|
+
continue
|
|
183
|
+
if leading_text.rpartition("\n")[2].strip():
|
|
184
|
+
continue
|
|
185
|
+
all_preceding_lines = [
|
|
186
|
+
each_line for each_line in leading_text.splitlines() if each_line.strip()
|
|
187
|
+
]
|
|
188
|
+
last_preceding_line = all_preceding_lines[-1].strip()
|
|
189
|
+
if last_preceding_line.startswith(
|
|
190
|
+
ALL_DEFINITION_HEADER_PREFIXES
|
|
191
|
+
) and last_preceding_line.endswith(":"):
|
|
192
|
+
all_found_docstrings.append(body)
|
|
193
|
+
return all_found_docstrings
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _extract_python_docstring_text(text: str) -> str:
|
|
197
|
+
"""Return the scannable docstring prose from Python source.
|
|
198
|
+
|
|
199
|
+
Parses the source when valid, or falls back to the docstring-positioned
|
|
200
|
+
triple-quoted blocks of a mid-edit fragment. Double-quoted and backticked
|
|
201
|
+
spans inside each docstring are mentions rather than uses, so both are
|
|
202
|
+
stripped from the returned prose.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
text: The Python source or fragment under scan.
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
The docstring prose joined into one scannable string.
|
|
209
|
+
"""
|
|
210
|
+
try:
|
|
211
|
+
all_found_docstrings = _extract_parsed_docstrings(ast.parse(text))
|
|
212
|
+
except (SyntaxError, ValueError):
|
|
213
|
+
all_found_docstrings = _extract_fragment_docstrings(text)
|
|
214
|
+
all_docstring_prose = [
|
|
215
|
+
DOUBLE_QUOTED_SPAN_PATTERN.sub("", INLINE_CODE_PATTERN.sub("", each_docstring))
|
|
216
|
+
for each_docstring in all_found_docstrings
|
|
217
|
+
]
|
|
218
|
+
return "\n".join(all_docstring_prose)
|
|
219
|
+
|
|
220
|
+
|
|
134
221
|
def find_violations(text: str, file_path: str) -> list[str]:
|
|
135
222
|
"""Return all violated patterns found in text for the given file.
|
|
136
223
|
|
|
137
|
-
For .md files, scans the entire text. For code files, scans
|
|
224
|
+
For .md files, scans the entire text. For code files, scans comment lines,
|
|
225
|
+
and for Python files also scans module/class/function docstrings.
|
|
138
226
|
Returns a list of matched pattern source strings.
|
|
139
227
|
"""
|
|
228
|
+
extension = _get_file_extension(file_path)
|
|
140
229
|
if is_markdown_file(file_path):
|
|
141
230
|
scan_text = text
|
|
142
231
|
elif is_comment_bearing_file(file_path):
|
|
143
|
-
all_comment_lines = _extract_comment_lines(text,
|
|
232
|
+
all_comment_lines = _extract_comment_lines(text, extension)
|
|
144
233
|
scan_text = "\n".join(all_comment_lines)
|
|
234
|
+
if extension == PYTHON_EXTENSION:
|
|
235
|
+
scan_text = "\n".join([scan_text, _extract_python_docstring_text(text)])
|
|
145
236
|
else:
|
|
146
237
|
return []
|
|
147
238
|
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Tests for check_config_duplicate_path_anchor.
|
|
2
|
+
|
|
3
|
+
Directories come from ``tmp_path_factory.mktemp`` with a neutral name because
|
|
4
|
+
the per-test ``tmp_path`` embeds the test function name, and that name would
|
|
5
|
+
make the test-path exemption inside the validator swallow every case.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import importlib.util
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from types import ModuleType
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
ENFORCER_FILENAME = "code_rules_enforcer.py"
|
|
17
|
+
ENFORCER_MODULE_NAME = "code_rules_enforcer_duplicate_anchor_tests"
|
|
18
|
+
SIBLING_MODULE_SOURCE = """\
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
NEON_SUBMISSION_LOG_DIRECTORY = Path(__file__).resolve().parents[2] / "logs" / "theme_submissions"
|
|
22
|
+
"""
|
|
23
|
+
WRITTEN_MODULE_SOURCE = """\
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
LOG_BASE_DIR = Path(__file__).resolve().parents[2] / "logs"
|
|
27
|
+
THEME_SUBMISSIONS_LOG_FOLDER = "theme_submissions"
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def load_enforcer_module() -> ModuleType:
|
|
32
|
+
loader_path = Path(__file__).parent / ENFORCER_FILENAME
|
|
33
|
+
module_spec = importlib.util.spec_from_file_location(ENFORCER_MODULE_NAME, loader_path)
|
|
34
|
+
assert module_spec is not None
|
|
35
|
+
assert module_spec.loader is not None
|
|
36
|
+
loaded_module = importlib.util.module_from_spec(module_spec)
|
|
37
|
+
module_spec.loader.exec_module(loaded_module)
|
|
38
|
+
return loaded_module
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
enforcer = load_enforcer_module()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _config_directory_with_sibling(
|
|
45
|
+
tmp_path_factory: pytest.TempPathFactory, sibling_source: str
|
|
46
|
+
) -> Path:
|
|
47
|
+
config_directory = tmp_path_factory.mktemp("workspace_home") / "config"
|
|
48
|
+
config_directory.mkdir()
|
|
49
|
+
(config_directory / "neon_submission.py").write_text(sibling_source, encoding="utf-8")
|
|
50
|
+
return config_directory
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_should_flag_reanchored_base_already_built_by_sibling(
|
|
54
|
+
tmp_path_factory: pytest.TempPathFactory,
|
|
55
|
+
) -> None:
|
|
56
|
+
config_directory = _config_directory_with_sibling(tmp_path_factory, SIBLING_MODULE_SOURCE)
|
|
57
|
+
written_path = str(config_directory / "derived_paths.py")
|
|
58
|
+
issues = enforcer.check_config_duplicate_path_anchor(WRITTEN_MODULE_SOURCE, written_path)
|
|
59
|
+
assert len(issues) == 1
|
|
60
|
+
assert "neon_submission.py" in issues[0]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_should_flag_parent_chain_anchor_matching_sibling_parents_index(
|
|
64
|
+
tmp_path_factory: pytest.TempPathFactory,
|
|
65
|
+
) -> None:
|
|
66
|
+
config_directory = _config_directory_with_sibling(tmp_path_factory, SIBLING_MODULE_SOURCE)
|
|
67
|
+
written_source = """\
|
|
68
|
+
from pathlib import Path
|
|
69
|
+
|
|
70
|
+
LOG_BASE_DIR = Path(__file__).resolve().parent.parent.parent / "logs"
|
|
71
|
+
"""
|
|
72
|
+
written_path = str(config_directory / "derived_paths.py")
|
|
73
|
+
issues = enforcer.check_config_duplicate_path_anchor(written_source, written_path)
|
|
74
|
+
assert len(issues) == 1
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_should_allow_anchor_with_different_first_segment(
|
|
78
|
+
tmp_path_factory: pytest.TempPathFactory,
|
|
79
|
+
) -> None:
|
|
80
|
+
config_directory = _config_directory_with_sibling(tmp_path_factory, SIBLING_MODULE_SOURCE)
|
|
81
|
+
written_source = """\
|
|
82
|
+
from pathlib import Path
|
|
83
|
+
|
|
84
|
+
SNAPSHOT_BASE_DIR = Path(__file__).resolve().parents[2] / "snapshots"
|
|
85
|
+
"""
|
|
86
|
+
written_path = str(config_directory / "derived_paths.py")
|
|
87
|
+
issues = enforcer.check_config_duplicate_path_anchor(written_source, written_path)
|
|
88
|
+
assert issues == []
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_should_allow_anchor_with_different_depth(
|
|
92
|
+
tmp_path_factory: pytest.TempPathFactory,
|
|
93
|
+
) -> None:
|
|
94
|
+
config_directory = _config_directory_with_sibling(tmp_path_factory, SIBLING_MODULE_SOURCE)
|
|
95
|
+
written_source = """\
|
|
96
|
+
from pathlib import Path
|
|
97
|
+
|
|
98
|
+
LOG_BASE_DIR = Path(__file__).resolve().parents[3] / "logs"
|
|
99
|
+
"""
|
|
100
|
+
written_path = str(config_directory / "derived_paths.py")
|
|
101
|
+
issues = enforcer.check_config_duplicate_path_anchor(written_source, written_path)
|
|
102
|
+
assert issues == []
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_should_allow_module_with_no_sibling_config_files(
|
|
106
|
+
tmp_path_factory: pytest.TempPathFactory,
|
|
107
|
+
) -> None:
|
|
108
|
+
config_directory = tmp_path_factory.mktemp("workspace_home") / "config"
|
|
109
|
+
config_directory.mkdir()
|
|
110
|
+
written_path = str(config_directory / "derived_paths.py")
|
|
111
|
+
issues = enforcer.check_config_duplicate_path_anchor(WRITTEN_MODULE_SOURCE, written_path)
|
|
112
|
+
assert issues == []
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_should_skip_modules_outside_config_directories(
|
|
116
|
+
tmp_path_factory: pytest.TempPathFactory,
|
|
117
|
+
) -> None:
|
|
118
|
+
plain_directory = tmp_path_factory.mktemp("workspace_home") / "services"
|
|
119
|
+
plain_directory.mkdir()
|
|
120
|
+
(plain_directory / "neon_submission.py").write_text(SIBLING_MODULE_SOURCE, encoding="utf-8")
|
|
121
|
+
written_path = str(plain_directory / "derived_paths.py")
|
|
122
|
+
issues = enforcer.check_config_duplicate_path_anchor(WRITTEN_MODULE_SOURCE, written_path)
|
|
123
|
+
assert issues == []
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_should_skip_unparseable_sibling_modules(
|
|
127
|
+
tmp_path_factory: pytest.TempPathFactory,
|
|
128
|
+
) -> None:
|
|
129
|
+
config_directory = _config_directory_with_sibling(tmp_path_factory, "def broken(:\n")
|
|
130
|
+
written_path = str(config_directory / "derived_paths.py")
|
|
131
|
+
issues = enforcer.check_config_duplicate_path_anchor(WRITTEN_MODULE_SOURCE, written_path)
|
|
132
|
+
assert issues == []
|
|
@@ -71,10 +71,12 @@ KNOWN_UNCAPPED_CHECKS_PENDING_REVIEW: frozenset[str] = frozenset(
|
|
|
71
71
|
"check_file_global_constants_use_count",
|
|
72
72
|
"check_imports_at_top",
|
|
73
73
|
"check_inline_literal_collections",
|
|
74
|
+
"check_join_separator_string_magic",
|
|
74
75
|
"check_known_pytest_fixture_annotations",
|
|
75
76
|
"check_library_print",
|
|
76
77
|
"check_loop_variable_naming",
|
|
77
78
|
"check_parameter_annotations",
|
|
79
|
+
"check_referenced_underscore_loop_variable",
|
|
78
80
|
"check_return_annotations",
|
|
79
81
|
"check_skip_decorators_in_tests",
|
|
80
82
|
"check_string_literal_magic",
|