claude-dev-env 1.48.0 → 1.49.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/hooks/blocking/plain_language_blocker.py +184 -0
- package/hooks/blocking/pr_description_enforcer.py +21 -1
- package/hooks/blocking/test_plain_language_blocker.py +247 -0
- package/hooks/blocking/test_pr_description_enforcer.py +68 -0
- package/hooks/hooks.json +15 -0
- package/hooks/hooks_constants/plain_language_blocker_constants.py +295 -0
- package/hooks/hooks_constants/pr_description_enforcer_constants.py +4 -0
- package/package.json +1 -1
- package/rules/plain-language.md +2 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse hook that blocks heavy words in AskUserQuestion prose and .md writes.
|
|
3
|
+
|
|
4
|
+
Reaches for the everyday word over the formal one: `use` over `utilize`,
|
|
5
|
+
`start` over `initiate`, `enough` over `sufficient`. Two surfaces are guarded --
|
|
6
|
+
AskUserQuestion (its question and option prose) and Write/Edit/MultiEdit targeting a .md
|
|
7
|
+
file. Code fences, inline code, blockquotes, URLs, and file paths are stripped
|
|
8
|
+
before matching so exact identifiers and paths are never flagged.
|
|
9
|
+
|
|
10
|
+
See the plain-language rule for the full guidance this hook enforces.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import TextIO
|
|
17
|
+
|
|
18
|
+
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
19
|
+
if _hooks_dir not in sys.path:
|
|
20
|
+
sys.path.insert(0, _hooks_dir)
|
|
21
|
+
|
|
22
|
+
from hooks_constants.plain_language_blocker_constants import ( # noqa: E402
|
|
23
|
+
ALL_SOFTWARE_TERMS,
|
|
24
|
+
ALL_TERM_PATTERNS,
|
|
25
|
+
ALL_WRITE_EDIT_TOOL_NAMES,
|
|
26
|
+
ASK_USER_QUESTION_TOOL_NAME,
|
|
27
|
+
BLOCKQUOTE_LINE_PATTERN,
|
|
28
|
+
FENCED_CODE_BLOCK_PATTERN,
|
|
29
|
+
FILE_PATH_PATTERN,
|
|
30
|
+
INLINE_CODE_PATTERN,
|
|
31
|
+
MARKDOWN_EXTENSION,
|
|
32
|
+
URL_PATTERN,
|
|
33
|
+
USER_FACING_PLAIN_LANGUAGE_NOTICE,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def strip_non_prose_regions(text: str) -> str:
|
|
38
|
+
"""Return text with code, quotes, URLs, and file paths removed.
|
|
39
|
+
|
|
40
|
+
These regions carry exact identifiers and references that plain language
|
|
41
|
+
leaves untouched, so they must not contribute matches.
|
|
42
|
+
"""
|
|
43
|
+
without_fences = FENCED_CODE_BLOCK_PATTERN.sub("", text)
|
|
44
|
+
without_inline_code = INLINE_CODE_PATTERN.sub("", without_fences)
|
|
45
|
+
without_blockquotes = BLOCKQUOTE_LINE_PATTERN.sub("", without_inline_code)
|
|
46
|
+
without_urls = URL_PATTERN.sub("", without_blockquotes)
|
|
47
|
+
without_paths = FILE_PATH_PATTERN.sub("", without_urls)
|
|
48
|
+
return without_paths
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def find_banned_terms(text: str) -> list[tuple[str, str]]:
|
|
52
|
+
"""Return each (matched term, suggested replacement) found in the prose.
|
|
53
|
+
|
|
54
|
+
Each term appears at most once, in first-seen order. Matching is
|
|
55
|
+
case-insensitive and respects word boundaries; multi-word phrases match as
|
|
56
|
+
whole units. Terms in the software-term allowlist are exempt and never
|
|
57
|
+
flagged.
|
|
58
|
+
"""
|
|
59
|
+
prose_text = strip_non_prose_regions(text)
|
|
60
|
+
all_matches: list[tuple[str, str]] = []
|
|
61
|
+
seen_terms: set[str] = set()
|
|
62
|
+
for each_pattern, each_replacement in ALL_TERM_PATTERNS:
|
|
63
|
+
first_match = each_pattern.search(prose_text)
|
|
64
|
+
if first_match is None:
|
|
65
|
+
continue
|
|
66
|
+
normalized_term = first_match.group(0).lower()
|
|
67
|
+
if normalized_term in seen_terms:
|
|
68
|
+
continue
|
|
69
|
+
if normalized_term in ALL_SOFTWARE_TERMS:
|
|
70
|
+
continue
|
|
71
|
+
seen_terms.add(normalized_term)
|
|
72
|
+
all_matches.append((normalized_term, each_replacement))
|
|
73
|
+
return all_matches
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def build_block_reason(all_matches: list[tuple[str, str]]) -> str:
|
|
77
|
+
"""Return a deny reason naming each flagged term and its plain replacement."""
|
|
78
|
+
swap_phrases = ", ".join(
|
|
79
|
+
f'use "{each_replacement}" instead of "{each_term}"'
|
|
80
|
+
for each_term, each_replacement in all_matches
|
|
81
|
+
)
|
|
82
|
+
return (
|
|
83
|
+
"BLOCKED: [PLAIN_LANGUAGE] Heavy words detected -- "
|
|
84
|
+
f"{swap_phrases}. Reach for the everyday word the reader understands "
|
|
85
|
+
"on the first pass."
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _collect_ask_user_question_prose(tool_input: dict) -> str:
|
|
90
|
+
all_questions = tool_input.get("questions", [])
|
|
91
|
+
if not isinstance(all_questions, list):
|
|
92
|
+
return ""
|
|
93
|
+
prose_segments: list[str] = []
|
|
94
|
+
for each_question in all_questions:
|
|
95
|
+
if not isinstance(each_question, dict):
|
|
96
|
+
continue
|
|
97
|
+
question_text = each_question.get("question", "")
|
|
98
|
+
if isinstance(question_text, str):
|
|
99
|
+
prose_segments.append(question_text)
|
|
100
|
+
all_options = each_question.get("options", [])
|
|
101
|
+
if isinstance(all_options, list):
|
|
102
|
+
for each_option in all_options:
|
|
103
|
+
if isinstance(each_option, dict):
|
|
104
|
+
option_label = each_option.get("label", "")
|
|
105
|
+
if isinstance(option_label, str):
|
|
106
|
+
prose_segments.append(option_label)
|
|
107
|
+
option_description = each_option.get("description", "")
|
|
108
|
+
if isinstance(option_description, str):
|
|
109
|
+
prose_segments.append(option_description)
|
|
110
|
+
return "\n".join(prose_segments)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _collect_write_edit_markdown_prose(tool_name: str, tool_input: dict) -> str:
|
|
114
|
+
file_path = tool_input.get("file_path", "")
|
|
115
|
+
if not isinstance(file_path, str) or not file_path.lower().endswith(MARKDOWN_EXTENSION):
|
|
116
|
+
return ""
|
|
117
|
+
if tool_name == "Write":
|
|
118
|
+
content = tool_input.get("content", "")
|
|
119
|
+
return content if isinstance(content, str) else ""
|
|
120
|
+
if tool_name == "Edit":
|
|
121
|
+
new_string = tool_input.get("new_string", "")
|
|
122
|
+
return new_string if isinstance(new_string, str) else ""
|
|
123
|
+
all_edits = tool_input.get("edits", [])
|
|
124
|
+
if not isinstance(all_edits, list):
|
|
125
|
+
return ""
|
|
126
|
+
prose_segments: list[str] = []
|
|
127
|
+
for each_edit in all_edits:
|
|
128
|
+
if isinstance(each_edit, dict):
|
|
129
|
+
new_string = each_edit.get("new_string", "")
|
|
130
|
+
if isinstance(new_string, str):
|
|
131
|
+
prose_segments.append(new_string)
|
|
132
|
+
return "\n".join(prose_segments)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _collect_prose_for_tool(tool_name: str, tool_input: dict) -> str:
|
|
136
|
+
if tool_name == ASK_USER_QUESTION_TOOL_NAME:
|
|
137
|
+
return _collect_ask_user_question_prose(tool_input)
|
|
138
|
+
if tool_name in ALL_WRITE_EDIT_TOOL_NAMES:
|
|
139
|
+
return _collect_write_edit_markdown_prose(tool_name, tool_input)
|
|
140
|
+
return ""
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _emit_deny(all_matches: list[tuple[str, str]], output_stream: TextIO) -> None:
|
|
144
|
+
deny_payload = {
|
|
145
|
+
"hookSpecificOutput": {
|
|
146
|
+
"hookEventName": "PreToolUse",
|
|
147
|
+
"permissionDecision": "deny",
|
|
148
|
+
"permissionDecisionReason": build_block_reason(all_matches),
|
|
149
|
+
},
|
|
150
|
+
"systemMessage": USER_FACING_PLAIN_LANGUAGE_NOTICE,
|
|
151
|
+
"suppressOutput": True,
|
|
152
|
+
}
|
|
153
|
+
output_stream.write(json.dumps(deny_payload))
|
|
154
|
+
output_stream.flush()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def main() -> None:
|
|
158
|
+
try:
|
|
159
|
+
input_data = json.load(sys.stdin)
|
|
160
|
+
except json.JSONDecodeError:
|
|
161
|
+
sys.exit(0)
|
|
162
|
+
|
|
163
|
+
if not isinstance(input_data, dict):
|
|
164
|
+
sys.exit(0)
|
|
165
|
+
|
|
166
|
+
tool_name = input_data.get("tool_name", "")
|
|
167
|
+
tool_input = input_data.get("tool_input", {})
|
|
168
|
+
if not isinstance(tool_name, str) or not isinstance(tool_input, dict):
|
|
169
|
+
sys.exit(0)
|
|
170
|
+
|
|
171
|
+
prose_text = _collect_prose_for_tool(tool_name, tool_input)
|
|
172
|
+
if not prose_text:
|
|
173
|
+
sys.exit(0)
|
|
174
|
+
|
|
175
|
+
all_matches = find_banned_terms(prose_text)
|
|
176
|
+
if not all_matches:
|
|
177
|
+
sys.exit(0)
|
|
178
|
+
|
|
179
|
+
_emit_deny(all_matches, sys.stdout)
|
|
180
|
+
sys.exit(0)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
if __name__ == "__main__":
|
|
184
|
+
main()
|
|
@@ -31,6 +31,7 @@ from hooks_constants.pr_description_enforcer_constants import ( # noqa: E402
|
|
|
31
31
|
ALL_HEAVY_TESTING_HEADERS,
|
|
32
32
|
ALL_READABILITY_CLI_FLAG_TOKENS,
|
|
33
33
|
ATOMIC_WRITE_TEMP_SUFFIX,
|
|
34
|
+
BLOCKQUOTE_LINE_PATTERN,
|
|
34
35
|
BLOCKQUOTE_MARKER_PATTERN,
|
|
35
36
|
BOLD_PAIR_PATTERN,
|
|
36
37
|
BULLET_MARKER_PATTERN,
|
|
@@ -63,6 +64,7 @@ from hooks_constants.pr_description_enforcer_constants import ( # noqa: E402
|
|
|
63
64
|
SELF_CLOSING_REFERENCE_MESSAGE_SUFFIX,
|
|
64
65
|
SELF_REFERENCE_PATTERN_TEMPLATE,
|
|
65
66
|
STANDARD_SHAPE,
|
|
67
|
+
TABLE_ROW_LINE_PATTERN,
|
|
66
68
|
THIS_PR_OPENING_PATTERN,
|
|
67
69
|
TRIVIAL_BODY_CHAR_THRESHOLD,
|
|
68
70
|
TRIVIAL_SHAPE,
|
|
@@ -350,6 +352,23 @@ def _count_substantive_prose_chars(body: str) -> int:
|
|
|
350
352
|
return len(body_collapsed)
|
|
351
353
|
|
|
352
354
|
|
|
355
|
+
def _extract_vague_scan_text(body: str) -> str:
|
|
356
|
+
"""Return the prose to scan for vague language, with non-prose regions removed.
|
|
357
|
+
|
|
358
|
+
Drops whole blockquote lines and whole pipe-delimited table rows, then strips
|
|
359
|
+
the same Markdown ceremony as the prose-count path -- which removes fenced
|
|
360
|
+
code, inline code, and whole heading lines. This exempts vague phrases that
|
|
361
|
+
appear only inside code fences, inline code, Markdown headings, quoted
|
|
362
|
+
reviewer text, or pipe-delimited example tables -- those are not the author's
|
|
363
|
+
own prose. A pipe-delimited row carries at least two pipes; a line with a
|
|
364
|
+
single leading pipe, or a borderless table row with no leading pipe, stays in
|
|
365
|
+
scope.
|
|
366
|
+
"""
|
|
367
|
+
without_blockquote_lines = BLOCKQUOTE_LINE_PATTERN.sub("", body)
|
|
368
|
+
without_table_rows = TABLE_ROW_LINE_PATTERN.sub("", without_blockquote_lines)
|
|
369
|
+
return _strip_markdown_ceremony(without_table_rows)
|
|
370
|
+
|
|
371
|
+
|
|
353
372
|
def _iter_section_headers(body: str) -> list[str]:
|
|
354
373
|
"""Return every ATX heading line in the body, preserving canonical form.
|
|
355
374
|
|
|
@@ -813,7 +832,8 @@ def validate_pr_body(body: str, pr_number: int | None = None) -> list[str]:
|
|
|
813
832
|
"(Adds, Fixes, Updates, Removes, Tightens, Ports)"
|
|
814
833
|
)
|
|
815
834
|
|
|
816
|
-
|
|
835
|
+
vague_scan_text = _extract_vague_scan_text(body)
|
|
836
|
+
vague_matches = VAGUE_LANGUAGE_PATTERN.findall(vague_scan_text)
|
|
817
837
|
if vague_matches:
|
|
818
838
|
violations.append(
|
|
819
839
|
f"Vague language detected: {', '.join(vague_matches)} -- "
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""Tests for the plain_language_blocker PreToolUse hook.
|
|
2
|
+
|
|
3
|
+
Covers the shared prose scanner (fenced code, inline code, blockquotes, URLs,
|
|
4
|
+
file paths), the word-boundary guard, multi-word phrase matching, case
|
|
5
|
+
insensitivity, the term -> replacement block message, and both registered
|
|
6
|
+
PreToolUse surfaces (AskUserQuestion and Write|Edit on .md targets).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import importlib.util
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
HOOK_SCRIPT_PATH = Path(__file__).parent / "plain_language_blocker.py"
|
|
17
|
+
_HOOKS_DIR = str(Path(__file__).resolve().parent)
|
|
18
|
+
_HOOKS_ROOT = str(Path(__file__).resolve().parent.parent)
|
|
19
|
+
if _HOOKS_DIR not in sys.path:
|
|
20
|
+
sys.path.insert(0, _HOOKS_DIR)
|
|
21
|
+
if _HOOKS_ROOT not in sys.path:
|
|
22
|
+
sys.path.insert(0, _HOOKS_ROOT)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _load_hook_module() -> object:
|
|
26
|
+
module_spec = importlib.util.spec_from_file_location(
|
|
27
|
+
"plain_language_blocker_under_test", HOOK_SCRIPT_PATH
|
|
28
|
+
)
|
|
29
|
+
assert module_spec is not None and module_spec.loader is not None
|
|
30
|
+
loaded_module = importlib.util.module_from_spec(module_spec)
|
|
31
|
+
module_spec.loader.exec_module(loaded_module)
|
|
32
|
+
return loaded_module
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
hook_module = _load_hook_module()
|
|
36
|
+
find_banned_terms = hook_module.find_banned_terms
|
|
37
|
+
strip_non_prose_regions = hook_module.strip_non_prose_regions
|
|
38
|
+
build_block_reason = hook_module.build_block_reason
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _run_hook_with_payload(payload: dict) -> subprocess.CompletedProcess[str]:
|
|
42
|
+
return subprocess.run(
|
|
43
|
+
[sys.executable, str(HOOK_SCRIPT_PATH)],
|
|
44
|
+
input=json.dumps(payload),
|
|
45
|
+
capture_output=True,
|
|
46
|
+
text=True,
|
|
47
|
+
check=False,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _decision_from(completed: subprocess.CompletedProcess[str]) -> str | None:
|
|
52
|
+
if not completed.stdout:
|
|
53
|
+
return None
|
|
54
|
+
parsed = json.loads(completed.stdout)
|
|
55
|
+
return parsed.get("hookSpecificOutput", {}).get("permissionDecision")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_canonical_hook_script_exists_at_expected_path() -> None:
|
|
59
|
+
assert HOOK_SCRIPT_PATH.is_file()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_bare_prose_banned_term_is_detected() -> None:
|
|
63
|
+
matched = find_banned_terms("We initiate the worker pool at boot.")
|
|
64
|
+
assert any(each_term == "initiate" for each_term, _replacement in matched)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_banned_term_inside_fenced_code_is_exempt() -> None:
|
|
68
|
+
prose = "Start the pool at boot.\n\n```python\nutilize(pool)\n```\n"
|
|
69
|
+
assert find_banned_terms(prose) == []
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_banned_term_inside_inline_code_is_exempt() -> None:
|
|
73
|
+
prose = "Call the `utilize` helper from the legacy module to migrate."
|
|
74
|
+
assert find_banned_terms(prose) == []
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_banned_term_inside_blockquote_is_exempt() -> None:
|
|
78
|
+
prose = "> The old guide said to utilize the pool.\n\nUse the pool directly now."
|
|
79
|
+
assert find_banned_terms(prose) == []
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_banned_term_inside_url_is_exempt() -> None:
|
|
83
|
+
prose = "See https://example.com/initiate-flow for the original write-up."
|
|
84
|
+
assert find_banned_terms(prose) == []
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_banned_term_inside_file_path_is_exempt() -> None:
|
|
88
|
+
prose = "Edit src/utilize_helpers/initiate.py to wire the new path."
|
|
89
|
+
assert find_banned_terms(prose) == []
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_word_boundary_guard_does_not_match_substring() -> None:
|
|
93
|
+
assert find_banned_terms("The reinitialize routine reruns the seed.") == []
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_case_insensitive_match() -> None:
|
|
97
|
+
matched_lower = find_banned_terms("utilize the cache.")
|
|
98
|
+
matched_upper = find_banned_terms("Utilize the cache.")
|
|
99
|
+
assert any(term == "utilize" for term, _ in matched_lower)
|
|
100
|
+
assert any(term == "utilize" for term, _ in matched_upper)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_multi_word_phrase_matches_as_unit() -> None:
|
|
104
|
+
matched = find_banned_terms("Run the migration prior to the deploy step.")
|
|
105
|
+
assert any(term == "prior to" for term, _ in matched)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_strip_non_prose_regions_removes_code_and_paths() -> None:
|
|
109
|
+
prose = "Use `utilize` and src/initiate.py and https://x.test/utilize here."
|
|
110
|
+
stripped = strip_non_prose_regions(prose)
|
|
111
|
+
assert "utilize" not in stripped
|
|
112
|
+
assert "initiate" not in stripped
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_block_reason_names_term_and_replacement() -> None:
|
|
116
|
+
reason = build_block_reason([("initiate", "start")])
|
|
117
|
+
assert "initiate" in reason
|
|
118
|
+
assert "start" in reason
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_ask_user_question_with_banned_term_is_denied() -> None:
|
|
122
|
+
payload = {
|
|
123
|
+
"tool_name": "AskUserQuestion",
|
|
124
|
+
"tool_input": {
|
|
125
|
+
"questions": [
|
|
126
|
+
{
|
|
127
|
+
"question": "Should we utilize the new allocator now?",
|
|
128
|
+
"header": "Allocator",
|
|
129
|
+
"options": [{"label": "Yes", "description": "Switch now."}],
|
|
130
|
+
}
|
|
131
|
+
]
|
|
132
|
+
},
|
|
133
|
+
}
|
|
134
|
+
completed = _run_hook_with_payload(payload)
|
|
135
|
+
assert _decision_from(completed) == "deny"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_ask_user_question_banned_term_in_option_label_is_denied() -> None:
|
|
139
|
+
payload = {
|
|
140
|
+
"tool_name": "AskUserQuestion",
|
|
141
|
+
"tool_input": {
|
|
142
|
+
"questions": [
|
|
143
|
+
{
|
|
144
|
+
"question": "Which path should we take?",
|
|
145
|
+
"header": "Path",
|
|
146
|
+
"options": [{"label": "Utilize the cache", "description": "Go fast."}],
|
|
147
|
+
}
|
|
148
|
+
]
|
|
149
|
+
},
|
|
150
|
+
}
|
|
151
|
+
completed = _run_hook_with_payload(payload)
|
|
152
|
+
assert _decision_from(completed) == "deny"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_clean_ask_user_question_passes_through() -> None:
|
|
156
|
+
payload = {
|
|
157
|
+
"tool_name": "AskUserQuestion",
|
|
158
|
+
"tool_input": {
|
|
159
|
+
"questions": [
|
|
160
|
+
{
|
|
161
|
+
"question": "Should we switch the allocator now?",
|
|
162
|
+
"header": "Allocator",
|
|
163
|
+
"options": [{"label": "Yes", "description": "Switch now."}],
|
|
164
|
+
}
|
|
165
|
+
]
|
|
166
|
+
},
|
|
167
|
+
}
|
|
168
|
+
completed = _run_hook_with_payload(payload)
|
|
169
|
+
assert _decision_from(completed) is None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_write_markdown_with_banned_term_is_denied(tmp_path: Path) -> None:
|
|
173
|
+
target = tmp_path / "notes.md"
|
|
174
|
+
payload = {
|
|
175
|
+
"tool_name": "Write",
|
|
176
|
+
"tool_input": {
|
|
177
|
+
"file_path": str(target),
|
|
178
|
+
"content": "This guide explains how to utilize the new cache layer.",
|
|
179
|
+
},
|
|
180
|
+
}
|
|
181
|
+
completed = _run_hook_with_payload(payload)
|
|
182
|
+
assert _decision_from(completed) == "deny"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def test_write_non_markdown_is_ignored(tmp_path: Path) -> None:
|
|
186
|
+
target = tmp_path / "notes.txt"
|
|
187
|
+
payload = {
|
|
188
|
+
"tool_name": "Write",
|
|
189
|
+
"tool_input": {
|
|
190
|
+
"file_path": str(target),
|
|
191
|
+
"content": "This guide explains how to utilize the new cache layer.",
|
|
192
|
+
},
|
|
193
|
+
}
|
|
194
|
+
completed = _run_hook_with_payload(payload)
|
|
195
|
+
assert _decision_from(completed) is None
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_edit_markdown_clean_content_passes_through(tmp_path: Path) -> None:
|
|
199
|
+
target = tmp_path / "notes.md"
|
|
200
|
+
payload = {
|
|
201
|
+
"tool_name": "Edit",
|
|
202
|
+
"tool_input": {
|
|
203
|
+
"file_path": str(target),
|
|
204
|
+
"new_string": "This guide explains how to use the new cache layer.",
|
|
205
|
+
},
|
|
206
|
+
}
|
|
207
|
+
completed = _run_hook_with_payload(payload)
|
|
208
|
+
assert _decision_from(completed) is None
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def test_multiedit_markdown_with_banned_term_is_denied(tmp_path: Path) -> None:
|
|
212
|
+
target = tmp_path / "notes.md"
|
|
213
|
+
payload = {
|
|
214
|
+
"tool_name": "MultiEdit",
|
|
215
|
+
"tool_input": {
|
|
216
|
+
"file_path": str(target),
|
|
217
|
+
"edits": [
|
|
218
|
+
{"old_string": "intro", "new_string": "This section reads cleanly."},
|
|
219
|
+
{"old_string": "body", "new_string": "Then we utilize the new cache."},
|
|
220
|
+
],
|
|
221
|
+
},
|
|
222
|
+
}
|
|
223
|
+
completed = _run_hook_with_payload(payload)
|
|
224
|
+
assert _decision_from(completed) == "deny"
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def test_other_tool_is_ignored() -> None:
|
|
228
|
+
payload = {"tool_name": "Bash", "tool_input": {"command": "echo utilize"}}
|
|
229
|
+
completed = _run_hook_with_payload(payload)
|
|
230
|
+
assert _decision_from(completed) is None
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def test_software_allowlisted_term_is_not_flagged() -> None:
|
|
234
|
+
assert find_banned_terms("Run this command to start the worker.") == []
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def test_non_allowlisted_formal_term_still_flagged() -> None:
|
|
238
|
+
matched = find_banned_terms("Please utilize the cache now.")
|
|
239
|
+
assert any(term == "utilize" for term, _ in matched)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def test_prose_slash_token_is_not_stripped_as_path() -> None:
|
|
243
|
+
assert "client/server" in strip_non_prose_regions("Use a client/server split here.")
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def test_real_file_path_is_still_stripped() -> None:
|
|
247
|
+
assert "initiate" not in strip_non_prose_regions("Edit src/initiate.py to wire it.")
|
|
@@ -225,6 +225,74 @@ def test_validate_blocks_vague_language() -> None:
|
|
|
225
225
|
assert any("Vague language" in each_violation for each_violation in violations)
|
|
226
226
|
|
|
227
227
|
|
|
228
|
+
def _has_vague_language_violation(all_violations: list[str]) -> bool:
|
|
229
|
+
return any("Vague language" in each_violation for each_violation in all_violations)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def test_vague_language_inside_fenced_code_block_is_exempt() -> None:
|
|
233
|
+
body = (
|
|
234
|
+
"The allocator now bounds retries so a runaway request cannot exhaust the "
|
|
235
|
+
"connection pool under sustained load.\n\n"
|
|
236
|
+
"```bash\ngit commit -m \"fixed bug in parser\"\n```\n"
|
|
237
|
+
)
|
|
238
|
+
assert not _has_vague_language_violation(validate_pr_body(body))
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def test_vague_language_inside_inline_code_span_is_exempt() -> None:
|
|
242
|
+
body = (
|
|
243
|
+
"This change documents the historical commit message `fixed bug` referenced "
|
|
244
|
+
"in the changelog and rewrites the surrounding allocator narrative for clarity.\n"
|
|
245
|
+
)
|
|
246
|
+
assert not _has_vague_language_violation(validate_pr_body(body))
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def test_vague_language_inside_blockquote_line_is_exempt() -> None:
|
|
250
|
+
body = (
|
|
251
|
+
"> The reviewer wrote: minor changes were requested here.\n\n"
|
|
252
|
+
"The allocator rewrite removes the unbounded retry loop and adds a hard ceiling "
|
|
253
|
+
"so a single client cannot starve the pool.\n"
|
|
254
|
+
)
|
|
255
|
+
assert not _has_vague_language_violation(validate_pr_body(body))
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def test_vague_language_inside_markdown_table_is_exempt() -> None:
|
|
259
|
+
body = (
|
|
260
|
+
"The commit-message guide contrasts weak and strong messages so contributors "
|
|
261
|
+
"learn the difference before opening a pull request.\n\n"
|
|
262
|
+
"| Bad message | Good message |\n"
|
|
263
|
+
"| --- | --- |\n"
|
|
264
|
+
"| fixed bug | bound retry loop in allocator |\n"
|
|
265
|
+
"| update code | rename pool field to active_count |\n"
|
|
266
|
+
)
|
|
267
|
+
assert not _has_vague_language_violation(validate_pr_body(body))
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def test_vague_language_in_bare_prose_still_blocks() -> None:
|
|
271
|
+
body = (
|
|
272
|
+
"The allocator rewrite removes the unbounded retry loop and adds a hard "
|
|
273
|
+
"ceiling so a single client cannot starve the pool. Fixed bug in the parser.\n"
|
|
274
|
+
)
|
|
275
|
+
assert _has_vague_language_violation(validate_pr_body(body))
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def test_vague_language_inside_heading_is_exempt() -> None:
|
|
279
|
+
body = (
|
|
280
|
+
"## Fixed bug in the allocator\n\n"
|
|
281
|
+
"The allocator rewrite removes the unbounded retry loop and adds a hard "
|
|
282
|
+
"ceiling so a single client cannot starve the connection pool.\n"
|
|
283
|
+
)
|
|
284
|
+
assert not _has_vague_language_violation(validate_pr_body(body))
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def test_vague_language_in_single_pipe_prose_line_still_blocks() -> None:
|
|
288
|
+
body = (
|
|
289
|
+
"The allocator rewrite removes the unbounded retry loop and adds a hard "
|
|
290
|
+
"ceiling so a single client cannot starve the connection pool.\n\n"
|
|
291
|
+
"| fixed bug\n"
|
|
292
|
+
)
|
|
293
|
+
assert _has_vague_language_violation(validate_pr_body(body))
|
|
294
|
+
|
|
295
|
+
|
|
228
296
|
def test_validate_blocks_short_body() -> None:
|
|
229
297
|
violations = validate_pr_body("Too short.")
|
|
230
298
|
assert any("substantive prose" in each_violation.lower() for each_violation in violations)
|
package/hooks/hooks.json
CHANGED
|
@@ -69,6 +69,11 @@
|
|
|
69
69
|
"type": "command",
|
|
70
70
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/open_questions_in_plans_blocker.py",
|
|
71
71
|
"timeout": 10
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
"type": "command",
|
|
75
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/plain_language_blocker.py",
|
|
76
|
+
"timeout": 10
|
|
72
77
|
}
|
|
73
78
|
]
|
|
74
79
|
},
|
|
@@ -171,6 +176,16 @@
|
|
|
171
176
|
"timeout": 10
|
|
172
177
|
}
|
|
173
178
|
]
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
"matcher": "AskUserQuestion",
|
|
182
|
+
"hooks": [
|
|
183
|
+
{
|
|
184
|
+
"type": "command",
|
|
185
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/plain_language_blocker.py",
|
|
186
|
+
"timeout": 10
|
|
187
|
+
}
|
|
188
|
+
]
|
|
174
189
|
}
|
|
175
190
|
],
|
|
176
191
|
"SessionStart": [
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""Configuration constants for the plain_language_blocker PreToolUse hook."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
REPLACEMENT_BY_TERM: dict[str, str] = {
|
|
8
|
+
"addressees are requested": "(omit), please",
|
|
9
|
+
"under the provisions of": "under",
|
|
10
|
+
"interpose no objection": "don't object",
|
|
11
|
+
"afford an opportunity": "allow, let",
|
|
12
|
+
"has a requirement for": "needs",
|
|
13
|
+
"is in consonance with": "agrees with, follows",
|
|
14
|
+
"not later than 10 may": "by 10 May, before 11 May",
|
|
15
|
+
"provides guidance for": "guides",
|
|
16
|
+
"successfully complete": "complete, pass",
|
|
17
|
+
"with the exception of": "except for",
|
|
18
|
+
"due to the fact that": "due to, since",
|
|
19
|
+
"effect modifications": "make changes",
|
|
20
|
+
"in view of the above": "so",
|
|
21
|
+
"adversely impact on": "hurt, set back",
|
|
22
|
+
"at the present time": "at present, now",
|
|
23
|
+
"not later than 1600": "by 1600",
|
|
24
|
+
"combat environment": "combat",
|
|
25
|
+
"in a timely manner": "on time, promptly",
|
|
26
|
+
"in accordance with": "by, following, per, under",
|
|
27
|
+
"in the near future": "shortly, soon",
|
|
28
|
+
"is responsible for": "(omit) handles",
|
|
29
|
+
"on a regular basis": "(omit)",
|
|
30
|
+
"until such time as": "until",
|
|
31
|
+
"during the period": "during",
|
|
32
|
+
"in the process of": "(omit)",
|
|
33
|
+
"with reference to": "about",
|
|
34
|
+
"as prescribed by": "in, under",
|
|
35
|
+
"in the amount of": "for",
|
|
36
|
+
"is applicable to": "applies to",
|
|
37
|
+
"is authorized to": "may",
|
|
38
|
+
"state-of-the-art": "latest",
|
|
39
|
+
"close proximity": "near",
|
|
40
|
+
"for a period of": "for",
|
|
41
|
+
"in an effort to": "to",
|
|
42
|
+
"in the event of": "if",
|
|
43
|
+
"it is essential": "must, need to",
|
|
44
|
+
"it is requested": "please, we request, I request",
|
|
45
|
+
"notwithstanding": "inspite of, still",
|
|
46
|
+
"the undersigned": "I",
|
|
47
|
+
"arrive onboard": "arrive",
|
|
48
|
+
"in relation to": "about, with, to",
|
|
49
|
+
"incumbent upon": "must",
|
|
50
|
+
"limited number": "limits",
|
|
51
|
+
"take action to": "(omit)",
|
|
52
|
+
"as a means of": "to",
|
|
53
|
+
"in order that": "for, so",
|
|
54
|
+
"pertaining to": "about, of, on",
|
|
55
|
+
"provided that": "if",
|
|
56
|
+
"this activity": "us, we",
|
|
57
|
+
"advantageous": "helpful",
|
|
58
|
+
"consequently": "so",
|
|
59
|
+
"in regard to": "about, concerning, on",
|
|
60
|
+
"remuneration": "pay, payment",
|
|
61
|
+
"set forth in": "in",
|
|
62
|
+
"subsequently": "after, later, then",
|
|
63
|
+
"the month of": "(omit)",
|
|
64
|
+
"a number of": "some",
|
|
65
|
+
"accordingly": "so",
|
|
66
|
+
"adjacent to": "next to",
|
|
67
|
+
"appreciable": "many",
|
|
68
|
+
"appropriate": "(omit), proper, right",
|
|
69
|
+
"approximate": "about",
|
|
70
|
+
"by means of": "by, with",
|
|
71
|
+
"comply with": "follow",
|
|
72
|
+
"consolidate": "combine, join, merge",
|
|
73
|
+
"constitutes": "is, forms, makes up",
|
|
74
|
+
"demonstrate": "prove, show",
|
|
75
|
+
"discontinue": "drop, stop",
|
|
76
|
+
"disseminate": "give, issue, pass, send",
|
|
77
|
+
"expeditious": "fast, quick",
|
|
78
|
+
"immediately": "at once",
|
|
79
|
+
"in addition": "also, besides, too",
|
|
80
|
+
"in order to": "to",
|
|
81
|
+
"inasmuch as": "since",
|
|
82
|
+
"methodology": "method",
|
|
83
|
+
"necessitate": "cause, need",
|
|
84
|
+
"participate": "take part",
|
|
85
|
+
"practicable": "practical",
|
|
86
|
+
"proficiency": "skill",
|
|
87
|
+
"pursuant to": "by, following, per, under",
|
|
88
|
+
"relative to": "about, on",
|
|
89
|
+
"requirement": "need",
|
|
90
|
+
"substantial": "large, much",
|
|
91
|
+
"time period": "(either one)",
|
|
92
|
+
"utilization": "use",
|
|
93
|
+
"your office": "you",
|
|
94
|
+
"a and/or b": "a or b or both",
|
|
95
|
+
"accomplish": "carry out, do",
|
|
96
|
+
"additional": "added, more, other",
|
|
97
|
+
"addressees": "you",
|
|
98
|
+
"anticipate": "expect",
|
|
99
|
+
"assistance": "aid, help",
|
|
100
|
+
"be advised": "(omit)",
|
|
101
|
+
"capability": "ability",
|
|
102
|
+
"concerning": "about, on",
|
|
103
|
+
"equipments": "equipment",
|
|
104
|
+
"expiration": "end",
|
|
105
|
+
"facilitate": "ease, help",
|
|
106
|
+
"frequently": "often",
|
|
107
|
+
"heretofore": "until now",
|
|
108
|
+
"in lieu of": "instead",
|
|
109
|
+
"in view of": "since",
|
|
110
|
+
"indication": "sign",
|
|
111
|
+
"inter alia": "(omit)",
|
|
112
|
+
"it appears": "seems",
|
|
113
|
+
"parameters": "limits",
|
|
114
|
+
"previously": "before",
|
|
115
|
+
"prioritize": "rank",
|
|
116
|
+
"promulgate": "issue, publish",
|
|
117
|
+
"represents": "is",
|
|
118
|
+
"similar to": "like",
|
|
119
|
+
"subsequent": "later, next",
|
|
120
|
+
"sufficient": "enough",
|
|
121
|
+
"the use of": "(omit)",
|
|
122
|
+
"accompany": "go with",
|
|
123
|
+
"ascertain": "find out, learn",
|
|
124
|
+
"component": "part",
|
|
125
|
+
"currently": "(omit), now",
|
|
126
|
+
"designate": "appoint, choose, name",
|
|
127
|
+
"determine": "decide, figure, find",
|
|
128
|
+
"eliminate": "cut, drop, end",
|
|
129
|
+
"encounter": "meet",
|
|
130
|
+
"enumerate": "count",
|
|
131
|
+
"equitable": "fair",
|
|
132
|
+
"establish": "set up, prove, show",
|
|
133
|
+
"evidenced": "showed",
|
|
134
|
+
"expertise": "ability",
|
|
135
|
+
"failed to": "didn't",
|
|
136
|
+
"identical": "same",
|
|
137
|
+
"implement": "carry out, start",
|
|
138
|
+
"inception": "start",
|
|
139
|
+
"interface": "meet, work with",
|
|
140
|
+
"magnitude": "size",
|
|
141
|
+
"objective": "aim, goal",
|
|
142
|
+
"regarding": "about, of, on",
|
|
143
|
+
"remainder": "rest",
|
|
144
|
+
"selection": "choice",
|
|
145
|
+
"terminate": "end, stop",
|
|
146
|
+
"there are": "(omit)",
|
|
147
|
+
"therefore": "so",
|
|
148
|
+
"witnessed": "saw",
|
|
149
|
+
"accorded": "given",
|
|
150
|
+
"accurate": "correct, exact, right",
|
|
151
|
+
"aircraft": "plane",
|
|
152
|
+
"allocate": "divide",
|
|
153
|
+
"apparent": "clear, plain",
|
|
154
|
+
"combined": "joint",
|
|
155
|
+
"commence": "begin, start",
|
|
156
|
+
"comprise": "form, include, make up",
|
|
157
|
+
"contains": "has",
|
|
158
|
+
"disclose": "show",
|
|
159
|
+
"endeavor": "try",
|
|
160
|
+
"expedite": "hasten, speed up",
|
|
161
|
+
"feasible": "can be done, workable",
|
|
162
|
+
"finalize": "complete, finish",
|
|
163
|
+
"function": "act, role, work",
|
|
164
|
+
"herewith": "below, here",
|
|
165
|
+
"identify": "find, name, show",
|
|
166
|
+
"impacted": "affected, changed",
|
|
167
|
+
"indicate": "show, write down",
|
|
168
|
+
"initiate": "start",
|
|
169
|
+
"maintain": "keep, support",
|
|
170
|
+
"minimize": "decrease, method",
|
|
171
|
+
"numerous": "many",
|
|
172
|
+
"obligate": "bind, compel",
|
|
173
|
+
"preclude": "prevent",
|
|
174
|
+
"previous": "earlier",
|
|
175
|
+
"prior to": "before",
|
|
176
|
+
"purchase": "buy",
|
|
177
|
+
"relocate": "move",
|
|
178
|
+
"there is": "(omit)",
|
|
179
|
+
"transmit": "send",
|
|
180
|
+
"validate": "confirm",
|
|
181
|
+
"address": "discuss",
|
|
182
|
+
"attempt": "try",
|
|
183
|
+
"benefit": "help",
|
|
184
|
+
"command": "us, we",
|
|
185
|
+
"convene": "meet",
|
|
186
|
+
"evident": "clear",
|
|
187
|
+
"exhibit": "show",
|
|
188
|
+
"females": "women",
|
|
189
|
+
"forfeit": "give up, lose",
|
|
190
|
+
"forward": "send",
|
|
191
|
+
"furnish": "give, send",
|
|
192
|
+
"however": "but",
|
|
193
|
+
"initial": "first",
|
|
194
|
+
"liaison": "discussion",
|
|
195
|
+
"maximum": "greatest, largest, most",
|
|
196
|
+
"minimum": "least, smallest",
|
|
197
|
+
"monitor": "check, watch",
|
|
198
|
+
"observe": "see",
|
|
199
|
+
"operate": "run, use, work",
|
|
200
|
+
"optimum": "best, greatest, most",
|
|
201
|
+
"perform": "do",
|
|
202
|
+
"portion": "part",
|
|
203
|
+
"possess": "have, own",
|
|
204
|
+
"proceed": "do, go ahead, try",
|
|
205
|
+
"procure": "(omit)",
|
|
206
|
+
"provide": "give, offer, say",
|
|
207
|
+
"reflect": "say, show",
|
|
208
|
+
"request": "ask",
|
|
209
|
+
"require": "must, need",
|
|
210
|
+
"solicit": "ask for, request",
|
|
211
|
+
"subject": "the, this, your",
|
|
212
|
+
"therein": "there",
|
|
213
|
+
"thereof": "its, their",
|
|
214
|
+
"utilize": "use",
|
|
215
|
+
"warrant": "call for, permit",
|
|
216
|
+
"whereas": "because, since",
|
|
217
|
+
"accrue": "add, gain",
|
|
218
|
+
"advise": "recommend, tell",
|
|
219
|
+
"assist": "aid, help",
|
|
220
|
+
"attain": "meet",
|
|
221
|
+
"caveat": "warning",
|
|
222
|
+
"delete": "cut, drop",
|
|
223
|
+
"depart": "leave",
|
|
224
|
+
"desire": "want, wish",
|
|
225
|
+
"employ": "use",
|
|
226
|
+
"ensure": "make sure",
|
|
227
|
+
"expend": "spend",
|
|
228
|
+
"herein": "here",
|
|
229
|
+
"modify": "change",
|
|
230
|
+
"notify": "let know, tell",
|
|
231
|
+
"option": "choice, way",
|
|
232
|
+
"permit": "let",
|
|
233
|
+
"remain": "stay",
|
|
234
|
+
"render": "give, make",
|
|
235
|
+
"reside": "live",
|
|
236
|
+
"retain": "keep",
|
|
237
|
+
"submit": "give, send",
|
|
238
|
+
"timely": "prompt",
|
|
239
|
+
"viable": "practical, workable",
|
|
240
|
+
"elect": "choose, pick",
|
|
241
|
+
"it is": "(omit)",
|
|
242
|
+
"deem": "believe, consider, think",
|
|
243
|
+
"said": "the, this, that",
|
|
244
|
+
"same": "the, this, that",
|
|
245
|
+
"such": "the, this, that",
|
|
246
|
+
"type": "(omit)",
|
|
247
|
+
"vice": "instead of, versus",
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
ALL_TERM_PATTERNS: list[tuple[re.Pattern[str], str]] = [
|
|
251
|
+
(re.compile(r"\b" + re.escape(each_term) + r"\b", re.IGNORECASE), each_replacement)
|
|
252
|
+
for each_term, each_replacement in REPLACEMENT_BY_TERM.items()
|
|
253
|
+
]
|
|
254
|
+
|
|
255
|
+
ALL_SOFTWARE_TERMS: frozenset[str] = frozenset(
|
|
256
|
+
{
|
|
257
|
+
"command",
|
|
258
|
+
"function",
|
|
259
|
+
"address",
|
|
260
|
+
"parameters",
|
|
261
|
+
"interface",
|
|
262
|
+
"component",
|
|
263
|
+
"monitor",
|
|
264
|
+
"request",
|
|
265
|
+
"validate",
|
|
266
|
+
"however",
|
|
267
|
+
"forward",
|
|
268
|
+
"subject",
|
|
269
|
+
"same",
|
|
270
|
+
"such",
|
|
271
|
+
"said",
|
|
272
|
+
"type",
|
|
273
|
+
"it is",
|
|
274
|
+
"there is",
|
|
275
|
+
"there are",
|
|
276
|
+
}
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
FENCED_CODE_BLOCK_PATTERN: re.Pattern[str] = re.compile(r"```[\s\S]*?```", re.MULTILINE)
|
|
280
|
+
INLINE_CODE_PATTERN: re.Pattern[str] = re.compile(r"`[^`]+`")
|
|
281
|
+
BLOCKQUOTE_LINE_PATTERN: re.Pattern[str] = re.compile(r"^\s*>.*$", re.MULTILINE)
|
|
282
|
+
URL_PATTERN: re.Pattern[str] = re.compile(r"https?://\S+", re.IGNORECASE)
|
|
283
|
+
FILE_PATH_PATTERN: re.Pattern[str] = re.compile(
|
|
284
|
+
r"(?<![\w/\\])(?:[A-Za-z]:[\\/]|~?[\\/]|\.{1,2}[\\/])(?:[\w.\-]+[\\/])*[\w.\-]+"
|
|
285
|
+
r"|(?:[\w.\-]+[\\/])+[\w.\-]+\.[A-Za-z0-9]+"
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
MARKDOWN_EXTENSION: str = ".md"
|
|
289
|
+
ASK_USER_QUESTION_TOOL_NAME: str = "AskUserQuestion"
|
|
290
|
+
ALL_WRITE_EDIT_TOOL_NAMES: frozenset[str] = frozenset({"Write", "Edit", "MultiEdit"})
|
|
291
|
+
|
|
292
|
+
USER_FACING_PLAIN_LANGUAGE_NOTICE: str = (
|
|
293
|
+
"Plain-language check: replace each flagged formal word with the simpler "
|
|
294
|
+
"word named in the reason."
|
|
295
|
+
)
|
|
@@ -19,6 +19,8 @@ HEADING_LINE_PATTERN: re.Pattern[str] = re.compile(r"^#+[ \t].*$", re.MULTILINE)
|
|
|
19
19
|
BOLD_PAIR_PATTERN: re.Pattern[str] = re.compile(r"\*\*([^*]+?)\*\*")
|
|
20
20
|
BULLET_MARKER_PATTERN: re.Pattern[str] = re.compile(r"^\s*[-*+]\s+", re.MULTILINE)
|
|
21
21
|
BLOCKQUOTE_MARKER_PATTERN: re.Pattern[str] = re.compile(r"^\s*>\s+", re.MULTILINE)
|
|
22
|
+
BLOCKQUOTE_LINE_PATTERN: re.Pattern[str] = re.compile(r"^\s*>.*$", re.MULTILINE)
|
|
23
|
+
TABLE_ROW_LINE_PATTERN: re.Pattern[str] = re.compile(r"^\s*\|.*\|.*$", re.MULTILINE)
|
|
22
24
|
LINK_TEXT_PATTERN: re.Pattern[str] = re.compile(r"\[([^\]]+)\]\([^)]+\)")
|
|
23
25
|
WHITESPACE_RUN_PATTERN: re.Pattern[str] = re.compile(r"\s+")
|
|
24
26
|
|
|
@@ -115,6 +117,7 @@ __all__ = [
|
|
|
115
117
|
"ALL_HEAVY_TESTING_HEADERS",
|
|
116
118
|
"ALL_READABILITY_CLI_FLAG_TOKENS",
|
|
117
119
|
"ATOMIC_WRITE_TEMP_SUFFIX",
|
|
120
|
+
"BLOCKQUOTE_LINE_PATTERN",
|
|
118
121
|
"BLOCKQUOTE_MARKER_PATTERN",
|
|
119
122
|
"BOLD_PAIR_PATTERN",
|
|
120
123
|
"BULLET_MARKER_PATTERN",
|
|
@@ -147,6 +150,7 @@ __all__ = [
|
|
|
147
150
|
"SELF_CLOSING_REFERENCE_MESSAGE_SUFFIX",
|
|
148
151
|
"SELF_REFERENCE_PATTERN_TEMPLATE",
|
|
149
152
|
"STANDARD_SHAPE",
|
|
153
|
+
"TABLE_ROW_LINE_PATTERN",
|
|
150
154
|
"THIS_PR_OPENING_PATTERN",
|
|
151
155
|
"TRIVIAL_BODY_CHAR_THRESHOLD",
|
|
152
156
|
"TRIVIAL_SHAPE",
|
package/package.json
CHANGED
package/rules/plain-language.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
**When this applies:** All prose you write — chat responses, `AskUserQuestion` questions and options, documentation and Markdown, PR and issue bodies, and commit messages. Anything a person reads.
|
|
4
4
|
|
|
5
|
+
**Hook enforcement:** `plain_language_blocker` (PreToolUse on `AskUserQuestion` and on Write|Edit|MultiEdit of `.md` targets) blocks a heavy word and names the everyday word to swap in. Code fences, inline code, blockquotes, URLs, and file paths are skipped so exact identifiers stay untouched. See `hooks.json` for registration.
|
|
6
|
+
|
|
5
7
|
## Rule
|
|
6
8
|
|
|
7
9
|
Write so the reader understands it on the first pass, without hunting for extra context and without wading through more than the point requires. Favor everyday words, short sentences, and concrete phrasing. This is **plain language** (ISO 24495-1:2023; U.S. Plain Writing Act of 2010) — clear writing, not dumbed-down writing.
|