claude-dev-env 8.24.0 → 8.25.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.
@@ -4,6 +4,11 @@
4
4
  The gate reads the ``text`` field of the chat tools that post to the user.
5
5
  It denies the call when the text holds more than MAXIMUM_SENTENCE_COUNT
6
6
  sentences, or a sentence longer than MAXIMUM_WORDS_PER_SENTENCE words.
7
+ It also denies a pull request number the reader cannot open::
8
+
9
+ flag: Both land in PR 5256.
10
+ flag: It merged in #4347.
11
+ ok: Both land in [PR 5256](https://github.com/owner/repo/pull/5256).
7
12
  Each non-empty line counts as its own sentence, so a list counts one
8
13
  sentence per item. URLs, markdown link targets, inline code spans, and
9
14
  fenced blocks carry no words.
@@ -34,6 +39,7 @@ from hooks_constants.reply_length_gate_constants import (
34
39
  LINE_BREAK_PATTERN,
35
40
  LINK_TARGET_PATTERN,
36
41
  LONG_SENTENCE_MESSAGE,
42
+ MARKDOWN_LINK_PATTERN,
37
43
  MAXIMUM_SENTENCE_COUNT,
38
44
  MAXIMUM_WORDS_PER_SENTENCE,
39
45
  RETRY_INSTRUCTION,
@@ -44,6 +50,8 @@ from hooks_constants.reply_length_gate_constants import (
44
50
  TOO_MANY_SENTENCES_MESSAGE,
45
51
  TOOL_INPUT_KEY,
46
52
  TOOL_NAME_KEY,
53
+ UNLINKED_PULL_REQUEST_MESSAGE,
54
+ UNLINKED_PULL_REQUEST_PATTERN,
47
55
  URL_PATTERN,
48
56
  WORD_PATTERN,
49
57
  WORD_SEPARATOR,
@@ -87,6 +95,17 @@ def length_violation(reply_text: str) -> str | None:
87
95
  return None
88
96
 
89
97
 
98
+ def unlinked_pull_request_violation(reply_text: str) -> str | None:
99
+ """Return the deny reason for a pull request number outside a link, or None."""
100
+ without_fences = FENCED_BLOCK_PATTERN.sub(" ", reply_text)
101
+ without_code = INLINE_CODE_PATTERN.sub(" ", without_fences)
102
+ without_links = MARKDOWN_LINK_PATTERN.sub(" ", without_code)
103
+ unlinked_match = UNLINKED_PULL_REQUEST_PATTERN.search(URL_PATTERN.sub(" ", without_links))
104
+ if unlinked_match is None:
105
+ return None
106
+ return UNLINKED_PULL_REQUEST_MESSAGE.format(reference=unlinked_match.group(0))
107
+
108
+
90
109
  def main() -> int:
91
110
  hook_input = read_hook_input_dictionary_from_stdin()
92
111
  if hook_input is None:
@@ -100,7 +119,7 @@ def main() -> int:
100
119
  reply_text = tool_input.get(TEXT_KEY)
101
120
  if not isinstance(reply_text, str):
102
121
  return ALLOW_EXIT_CODE
103
- violation = length_violation(reply_text)
122
+ violation = length_violation(reply_text) or unlinked_pull_request_violation(reply_text)
104
123
  if violation is None:
105
124
  return ALLOW_EXIT_CODE
106
125
  block_reason = violation + RETRY_INSTRUCTION
@@ -191,3 +191,47 @@ def test_should_allow_a_call_without_text(
191
191
  def test_should_allow_empty_stdin(monkeypatch: pytest.MonkeyPatch) -> None:
192
192
  monkeypatch.setattr("sys.stdin", io.TextIOWrapper(io.BytesIO(b"")))
193
193
  assert reply_length_gate.main() == 0
194
+
195
+
196
+ def test_should_deny_a_pr_number_with_no_link_and_name_it(
197
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
198
+ ) -> None:
199
+ exit_code, stderr_text = run_gate(
200
+ monkeypatch, capsys, REPLY_TOOL_NAME, {"text": "Both land in PR 5256."}
201
+ )
202
+ assert exit_code == 2
203
+ assert "PR 5256" in stderr_text
204
+
205
+
206
+ def test_should_deny_a_bare_hash_number(
207
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
208
+ ) -> None:
209
+ exit_code, stderr_text = run_gate(
210
+ monkeypatch, capsys, POST_TOOL_NAME, {"text": "It merged in #4347."}
211
+ )
212
+ assert exit_code == 2
213
+ assert "#4347" in stderr_text
214
+
215
+
216
+ def test_should_allow_a_pr_number_inside_its_link(
217
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
218
+ ) -> None:
219
+ exit_code, stderr_text = run_gate(
220
+ monkeypatch,
221
+ capsys,
222
+ REPLY_TOOL_NAME,
223
+ {"text": "Both land in [PR 5256](https://github.com/owner/repo/pull/5256)."},
224
+ )
225
+ assert (exit_code, stderr_text) == (0, "")
226
+
227
+
228
+ def test_should_allow_a_bare_pull_request_url(
229
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
230
+ ) -> None:
231
+ exit_code, _ = run_gate(
232
+ monkeypatch,
233
+ capsys,
234
+ REPLY_TOOL_NAME,
235
+ {"text": "Pull request: https://github.com/owner/repo/pull/5256"},
236
+ )
237
+ assert exit_code == 0
@@ -15,6 +15,10 @@ FENCED_BLOCK_PATTERN = re.compile(r"```.*?(?:```|\Z)", re.DOTALL)
15
15
  INLINE_CODE_PATTERN = re.compile(r"`[^`\n]*`")
16
16
  LINK_TARGET_PATTERN = re.compile(r"\]\([^)\s]*\)")
17
17
  URL_PATTERN = re.compile(r"(?:https?://|www\.)\S+")
18
+ MARKDOWN_LINK_PATTERN = re.compile(r"\[[^\]\n]*\]\([^)\s]*\)")
19
+ UNLINKED_PULL_REQUEST_PATTERN = re.compile(
20
+ r"\b(?:PRs?|pull requests?)\s*#?\d+|(?<![\w/&])#\d+", re.IGNORECASE
21
+ )
18
22
  LINE_BREAK_PATTERN = re.compile(r"\n+")
19
23
  SENTENCE_END_PATTERN = re.compile(r"(?<=[.!?])\s+")
20
24
  WORD_PATTERN = re.compile(r"[A-Za-z0-9]+(?:['.,-][A-Za-z0-9]+)*")
@@ -23,6 +27,7 @@ WORD_SEPARATOR = " "
23
27
  SENTENCE_PREVIEW_SUFFIX = "..."
24
28
  RETRY_INSTRUCTION = " Cut the text and resend the call."
25
29
  TOO_MANY_SENTENCES_MESSAGE = "Reply too long: {sentence_count} sentences, limit {sentence_limit}."
30
+ UNLINKED_PULL_REQUEST_MESSAGE = 'Pull request "{reference}" has no link. Write it as [PR N](https://github.com/<owner>/<repo>/pull/N).'
26
31
  LONG_SENTENCE_MESSAGE = (
27
32
  'Sentence too long: {word_count} words, limit {word_limit}: "{sentence_preview}".'
28
33
  )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.24.0",
3
+ "version": "8.25.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {