claude-dev-env 1.47.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/CLAUDE.md +3 -0
- package/docs/CODE_RULES.md +26 -0
- package/docs/references/dead-code-elimination.md +17 -0
- 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/confirm-implementation-forks.md +48 -0
- package/rules/plain-language.md +44 -0
- package/rules/vault-context.md +3 -1
- package/skills/pr-converge/SKILL.md +52 -27
- package/skills/pr-converge/reference/examples.md +13 -2
- package/skills/pr-converge/reference/ground-rules.md +6 -4
- package/skills/pr-converge/reference/per-tick.md +33 -6
- package/skills/pr-converge/reference/state-schema.md +5 -1
- package/skills/pr-converge/workflows/schedule-wakeup-loop.md +3 -2
- package/skills/session-log/SKILL.md +4 -1
package/CLAUDE.md
CHANGED
|
@@ -9,6 +9,8 @@ The user delegates execution to you and expects zero manual steps unless strictl
|
|
|
9
9
|
## Code Rules
|
|
10
10
|
@~/.claude/docs/CODE_RULES.md
|
|
11
11
|
|
|
12
|
+
When an edit deletes or rewrites code, delete everything it orphans in the same edit — unused variables, uncalled functions, unpassed parameters, dead branches, unused imports — once Serena's `find_referencing_symbols` (plus a text search for dynamic lookups) confirms they're unreachable from any live entry point, not merely unreferenced; when liveness is uncertain, ask via AskUserQuestion rather than risk deleting live code (CODE_RULES.md §9.8).
|
|
13
|
+
|
|
12
14
|
ALWAYS call the AskUserQuestion tool if you have a question for the user. Provide content-appropriate default options, with a flag for the recommended one.
|
|
13
15
|
|
|
14
16
|
## Timeless Documentation (all `.md` files)
|
|
@@ -50,3 +52,4 @@ Reserve `Read`/`Grep`/`Glob` for files you will actually touch this turn. Compos
|
|
|
50
52
|
## Additional Non-overlapping Rules
|
|
51
53
|
|
|
52
54
|
- **task_scope:** Match every action to what was explicitly requested. When intent is ambiguous, research official docs and present options via AskUserQuestion before making any changes. Proceed with edits only on explicit instruction.
|
|
55
|
+
- **confirm_implementation_forks:** When two or more viable paths would satisfy the goal and the choice changes the deliverable — its scope, completeness, deferred work, dependencies, or a hard-to-reverse contract — stop and ask which path via AskUserQuestion before implementing. A path that defers work or leaves a placeholder creating a follow-up task is itself a fork to surface, not a default to take silently. Phrase the question in plain language with only the detail needed to decide. See [`confirm-implementation-forks`](rules/confirm-implementation-forks.md).
|
package/docs/CODE_RULES.md
CHANGED
|
@@ -288,6 +288,31 @@ Fallback values mask programming errors (KeyError vs RuntimeError vs AttributeEr
|
|
|
288
288
|
|
|
289
289
|
---
|
|
290
290
|
|
|
291
|
+
## 9.8 REMOVE CODE YOU ORPHAN (Dead Code Elimination)
|
|
292
|
+
|
|
293
|
+
When an edit deletes or rewrites code, it must also remove everything that edit makes dead. The change is not complete while orphans remain.
|
|
294
|
+
|
|
295
|
+
After deleting or rewriting code, trace what it referenced and remove whatever is unreachable as a result:
|
|
296
|
+
|
|
297
|
+
- **Variables** with no remaining readers after the change
|
|
298
|
+
- **Functions / methods** with no remaining call sites
|
|
299
|
+
- **Parameters** that no caller passes
|
|
300
|
+
- **Branches** made unreachable (dead `if`/`else`, conditions that are always true or always false)
|
|
301
|
+
- **Imports** left unused (also caught by the unused-import hook)
|
|
302
|
+
- **Helper files** whose only consumer you just deleted
|
|
303
|
+
|
|
304
|
+
**Confirm the orphan is truly unreferenced first.** "No remaining call sites" means none *anywhere in the codebase*, not just in the file you edited. Before removing a function, method, class, or module-level name, run Serena's `find_referencing_symbols` on it: the language server resolves call sites, `import` statements, and re-exports across files far more reliably and cheaply than a text sweep. Back it with a plain text search for string-based dynamic lookups (`getattr`, entry-point names) that the language server cannot see. A reference is not proof of life — the referrer can be dead too. The symbol is live only when some chain of references reaches a live entry point: a CLI command, route, public API, test, or any caller outside the code you are removing. Trace the referrers upward. If every chain dead-ends in code that is itself unreferenced, the whole cluster is dead — remove it together in the same commit (§9.6). If any chain reaches a live entry point, the symbol is in use; leave it. Deleting something still reachable breaks its importers; treating a self-referential dead cluster as alive leaves litter.
|
|
305
|
+
|
|
306
|
+
**When liveness is uncertain, ask — never guess.** A tool cannot always tell what is live: a chain may leave the repository (a public API, plugin hook, or module other projects import), or run through dynamic or reflective dispatch that no search resolves. If you cannot conclusively prove a symbol is unreachable, do not delete it. Surface the specific ambiguity to the user through AskUserQuestion and let them decide. Removing live production code is never an acceptable risk — when in doubt, keep it and ask.
|
|
307
|
+
|
|
308
|
+
This is the inverse of comment preservation: existing **comments** are sacred and never removed, but dead **code** is removed in the same edit that orphans it. A function left with no callers is not "preserved" — it is litter.
|
|
309
|
+
|
|
310
|
+
> **See also:** §9.6 (a renamed alias is dead code by another name), the `file_global_constants_use_count` rule (zero references → delete), and the unused-import hook check — each enforces a specific slice of this principle automatically.
|
|
311
|
+
|
|
312
|
+
> **Standard terms (shorthand for this section):** the mechanical part is *dead code elimination* (compilers; Aho et al., *Compilers: Principles, Techniques, and Tools*) and *tree-shaking* (bundlers like Rollup/Webpack) — retain only what is reachable from a live entry point. The ask-when-uncertain overlay guards against the *Lava Flow* anti-pattern — dead code kept because removing it feels risky (Brown et al., *AntiPatterns*, 1998). Compare Fowler's "Remove Dead Code" refactoring (*Refactoring*, 2nd ed., 2018). Direct source links: [`references/dead-code-elimination.md`](references/dead-code-elimination.md).
|
|
313
|
+
|
|
314
|
+
---
|
|
315
|
+
|
|
291
316
|
## 10. NO REDUNDANT DATA FETCHES
|
|
292
317
|
|
|
293
318
|
If you already have data, don't fetch again.
|
|
@@ -384,5 +409,6 @@ Manual check:
|
|
|
384
409
|
[ ] OCP/LSP/ISP/DIP only applied where abstractions already earn their keep (see §7.5)?
|
|
385
410
|
[ ] No backwards-compatibility shims (§9.6)?
|
|
386
411
|
[ ] No fallback/best-effort wrappers (§9.7)?
|
|
412
|
+
[ ] No code orphaned by an edit (§9.8 — dead vars, uncalled functions, unused imports, dead branches)?
|
|
387
413
|
[ ] Readability: /check
|
|
388
414
|
```
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Dead Code Elimination — Reference
|
|
2
|
+
|
|
3
|
+
External sources and standard terminology behind [CODE_RULES §9.8](../CODE_RULES.md#98-remove-code-you-orphan-dead-code-elimination). Each entry names the concept, gives a one-line definition, and links a direct source.
|
|
4
|
+
|
|
5
|
+
## The mechanic — removing code that cannot affect results
|
|
6
|
+
|
|
7
|
+
- **Dead-code elimination (DCE)** — a compiler optimization that removes code which does not affect program results, reducing program size, resource use, and execution time. Foundational treatment: Aho, Lam, Sethi & Ullman, *Compilers: Principles, Techniques, and Tools* (the "Dragon Book"). <https://en.wikipedia.org/wiki/Dead-code_elimination>
|
|
8
|
+
- **Tree shaking** — dead-code elimination driven by `import`/`export` structure: keep only the exports reachable from an entry point and drop the rest. MDN glossary: <https://developer.mozilla.org/en-US/docs/Glossary/Tree_shaking>. Webpack guide: <https://webpack.js.org/guides/tree-shaking/>
|
|
9
|
+
- **Remove Dead Code** — the named refactoring for eliminating unreachable or unused segments to improve clarity and maintainability. Martin Fowler, *Refactoring* (2nd ed., 2018): <https://refactoring.com/catalog/removeDeadCode.html>
|
|
10
|
+
|
|
11
|
+
## The liveness test — reachability, not mere reference
|
|
12
|
+
|
|
13
|
+
- **Unreachable code / reachability analysis** — code with no control-flow path from the rest of the program; detecting it is a form of control-flow analysis. This is the basis for §9.8 testing reachability from a live entry point rather than the bare presence of a reference. <https://en.wikipedia.org/wiki/Unreachable_code>
|
|
14
|
+
|
|
15
|
+
## The safety overlay — why uncertain deletions escalate
|
|
16
|
+
|
|
17
|
+
- **Lava Flow anti-pattern** — suboptimal code that survives in production because accumulated dependencies and backward-compatibility fears make removal feel risky. Brown, Malveau, McCormick & Mowbray, *AntiPatterns* (1998). The §9.8 "ask when uncertain, never guess-delete" clause guards against blind risky removals. <https://en.wikipedia.org/wiki/Lava_flow_(programming)>
|
|
@@ -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": [
|