claude-dev-env 8.24.0 → 8.25.1

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.
@@ -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
  )
@@ -1,4 +1,4 @@
1
- """Tests for the NAS local-identity loader used by the ssh enforcer hook."""
1
+ """Tests for the local-identity loader used by the PII hooks."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -14,7 +14,7 @@ except ImportError:
14
14
  sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
15
15
  from hooks_constants import local_identity
16
16
 
17
- ALL_NAS_ENV_VARS = ("CLAUDE_NAS_HOST", "CLAUDE_NAS_SSH_USER", "CLAUDE_NAS_SSH_PORT")
17
+ ALL_NAS_ENV_VARS = ("CLAUDE_NAS_HOST",)
18
18
 
19
19
 
20
20
  def _clear_nas_env() -> None:
@@ -23,30 +23,20 @@ def _clear_nas_env() -> None:
23
23
 
24
24
 
25
25
  class TestNasValuesFromEnvironment:
26
- def should_read_host_user_and_port_from_the_environment(self) -> None:
27
- with patch.dict(
28
- os.environ,
29
- {
30
- "CLAUDE_NAS_HOST": "10.0.0.5",
31
- "CLAUDE_NAS_SSH_USER": "tester",
32
- "CLAUDE_NAS_SSH_PORT": "2200",
33
- },
34
- clear=False,
35
- ):
26
+ def should_read_host_from_the_environment(self) -> None:
27
+ with patch.dict(os.environ, {"CLAUDE_NAS_HOST": "10.0.0.5"}, clear=False):
36
28
  assert local_identity.nas_host() == "10.0.0.5"
37
- assert local_identity.nas_ssh_user() == "tester"
38
- assert local_identity.nas_ssh_port() == 2200
39
29
 
40
30
 
41
31
  class TestNasValuesFromLocalFile:
42
- def should_read_host_user_and_port_from_the_local_identity_file(
32
+ def should_read_host_from_the_local_identity_file(
43
33
  self, tmp_path: Path
44
34
  ) -> None:
45
35
  claude_home = tmp_path / ".claude"
46
36
  claude_home.mkdir()
47
37
  (claude_home / "local-identity.json").write_text(
48
38
  json.dumps(
49
- {"nas": {"host": "10.1.1.9", "ssh_user": "fileuser", "ssh_port": 2222}}
39
+ {"nas": {"host": "10.1.1.9"}}
50
40
  ),
51
41
  encoding="utf-8",
52
42
  )
@@ -54,18 +44,14 @@ class TestNasValuesFromLocalFile:
54
44
  _clear_nas_env()
55
45
  with patch.object(Path, "home", return_value=tmp_path):
56
46
  assert local_identity.nas_host() == "10.1.1.9"
57
- assert local_identity.nas_ssh_user() == "fileuser"
58
- assert local_identity.nas_ssh_port() == 2222
59
47
 
60
48
 
61
49
  class TestNasValuesPlaceholderDefault:
62
- def should_return_placeholders_when_no_env_and_no_file(self, tmp_path: Path) -> None:
50
+ def should_return_placeholder_when_no_env_and_no_file(self, tmp_path: Path) -> None:
63
51
  with patch.dict(os.environ, {}, clear=False):
64
52
  _clear_nas_env()
65
53
  with patch.object(Path, "home", return_value=tmp_path):
66
54
  assert local_identity.nas_host() == "nas.example.local"
67
- assert local_identity.nas_ssh_user() == "operator"
68
- assert local_identity.nas_ssh_port() == 22
69
55
 
70
56
 
71
57
  class TestPiiExemptRepositorySlugs:
@@ -234,25 +220,3 @@ class TestLocalIdentityPathOverride:
234
220
  ), patch.object(Path, "home", return_value=tmp_path):
235
221
  _clear_nas_env()
236
222
  assert local_identity.nas_host() == "10.9.9.9"
237
-
238
-
239
- class TestDenyMessagesQuoteTheResolvedHost:
240
- def should_include_the_resolved_host_and_port_in_the_bare_binary_message(
241
- self,
242
- ) -> None:
243
- with patch.dict(
244
- os.environ,
245
- {"CLAUDE_NAS_HOST": "10.0.0.5", "CLAUDE_NAS_SSH_PORT": "2200"},
246
- clear=False,
247
- ):
248
- os.environ.pop("CLAUDE_NAS_SSH_USER", None)
249
- message = local_identity.bare_ssh_binary_deny_message()
250
- assert "10.0.0.5" in message
251
- assert "-p 2200" in message
252
- assert "BatchMode=yes" in message
253
-
254
- def should_include_the_resolved_host_in_the_missing_batch_mode_message(self) -> None:
255
- with patch.dict(os.environ, {"CLAUDE_NAS_HOST": "10.0.0.5"}, clear=False):
256
- message = local_identity.missing_batch_mode_deny_message()
257
- assert "10.0.0.5" in message
258
- assert "BatchMode=yes" in message
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.1",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -6,7 +6,7 @@ Two constraints govern every shell command an agent issues: which shell runs it,
6
6
 
7
7
  Every Bash-tool shell command on Windows uses `pwsh`: `pwsh -NoProfile -File '<script>.ps1' <args>` for scripts, `pwsh -NoProfile -Command "..."` (or a literal `@'...'@` here-string) for inline work, or the built-in `PowerShell` tool for pure-PowerShell workflows (it supports `run_in_background`). Never wrap a script path in `-Command "& '...'"` — `-File` keeps `permissions.allow` matching. The `&` call operator is fine for invoking an executable at a path (`& '<venv>\Scripts\python.exe' script.py`).
8
8
 
9
- The mandate covers the shell a command runs through, not every executable a command names. A direct interpreter invocation another rule documents — the paramiko NAS helper in [`nas-ssh-invocation.md`](nas-ssh-invocation.md), a `python` call on a repo script — conforms as written.
9
+ The mandate covers the shell a command runs through. A direct interpreter invocation, such as a `python` call on a repo script, conforms as written.
10
10
 
11
11
  Keep `powershell`, `powershell.exe`, `cmd /c`, and `bash -c` out of the `settings.json` permission rules. `Audit-ShellPolicy.ps1` reports those forms and `Migrate-ShellPolicy.ps1` rewrites them to `pwsh`. Both ship in the claude-dev-env repo at `packages/claude-dev-env/scripts/` and run on demand, not as a live gate.
12
12
 
@@ -147,10 +147,5 @@ ALL_TRACKED_SECRET_EXACT_EXEMPTIONS: frozenset[tuple[str, str, str]] = frozenset
147
147
  "home-path",
148
148
  "5d956228802fbd4a65af8fa7fb4183f5a9f8793dbe218877aefe887b905f726d",
149
149
  ),
150
- (
151
- "shared_utils/theme_assets/contracts/evidence/theme_editor_asset_resolution_master.oneui9.v1.json",
152
- "home-path",
153
- "ec69fe88e2f9d3f1ed3504532c7484120591cbec7ae6fda017b0f9ca57a3fc25",
154
- ),
155
150
  )
156
151
  )
@@ -1,107 +0,0 @@
1
- # Running Commands on the NAS
2
-
3
- Full detail behind the always-on `rules/nas-ssh-invocation.md` kernel.
4
-
5
- This file covers how to run a command there at all. What the NAS runs, where the automations are
6
- deployed, and how to verify a change against them belong with the project that owns those
7
- automations, not here.
8
-
9
- ## The call
10
-
11
- ```
12
- python <runner-path> <command-script.sh> <private-key-path>
13
- ```
14
-
15
- Two arguments, both paths. The first is a file holding a bash script; the second is the private key.
16
-
17
- The runner is `nas_ssh_key.py`. It ships with the automation project it serves, so read its path from
18
- that project's own reference rather than assuming a location.
19
-
20
- The host, ssh port and ssh user are constants inside the runner, so no command you write names them.
21
- The same three values are recorded under the `nas` key in `~/.claude/local-identity.json`. Read them
22
- from there when something needs them, and keep them out of anything committed or posted.
23
-
24
- Write the script file with the Write tool. Keeping the commands in a file has a second benefit: a
25
- destructive word such as `rm -rf` inside the file never appears in a Bash tool command string, so the
26
- harness permission prompt never fires on it and an unattended run keeps going.
27
-
28
- The script runs through `bash -s` in a single shell. Variables, `cd`, and `source` all carry from one
29
- line to the next. Standard output, standard error and the exit code all come back.
30
-
31
- ## Why not ssh, scp or sftp
32
-
33
- The runner loads the key with paramiko and signs inside the Python process. The command-line clients
34
- check the key file's permissions first and refuse to load a key whose permissions they do not like.
35
- Git Bash's `ssh` then falls back to an interactive password prompt, which hangs a run with nobody to
36
- answer it.
37
-
38
- To move a file onto the NAS, write it from inside the script:
39
-
40
- ```bash
41
- cat > /tmp/thing.conf <<'EOF'
42
- contents here
43
- EOF
44
- ```
45
-
46
- The quoted `'EOF'` stops the shell expanding anything in the body.
47
-
48
- ## The key
49
-
50
- Use the ops key under `~/.claude/keys`. It is readable as-is and the NAS accepts it.
51
-
52
- Keys under `~/.ssh` are for other jobs and none of them works here. The default one carries a
53
- passphrase, so paramiko cannot load it unattended; the rest are for other hosts.
54
-
55
- ## Writing a script that touches the automations
56
-
57
- Do not reach for the system interpreter. It carries neither pytest nor the libraries the automations
58
- import, so anything checked against it proves nothing.
59
-
60
- Each automation project records its own runtime — the interpreter, the virtual environment, the
61
- import root, and the deploy path. Read that project's reference and activate what it names before
62
- running anything.
63
-
64
- ## Verifying a change before it ships
65
-
66
- The NAS is a Linux box and this machine is Windows, so two classes of difference show up only there:
67
- syntax newer than the NAS interpreter accepts, and tests that assert Windows paths.
68
-
69
- **Always separate a break in the code from a platform-only one.** Run the same test file at the branch head
70
- and at the commit the work started from. Identical failures at both mean the environment, not the
71
- change. Report both numbers rather than the head alone.
72
-
73
- ## The /tmp limit
74
-
75
- `/tmp` is a small tmpfs. A full clone of a repository that carries binary assets fills it, the clone
76
- dies partway through, and the disk stays full for whatever runs next.
77
-
78
- Fetch shallow and sparse instead:
79
-
80
- ```bash
81
- d=$(mktemp -d /tmp/work.XXXXXX)
82
- cd "$d"
83
- git init --quiet
84
- git remote add origin <repository-url>
85
- git sparse-checkout init --cone
86
- git sparse-checkout set <subdirectory>
87
- git fetch --quiet --depth 1 origin <sha>
88
- git checkout --quiet FETCH_HEAD
89
- ```
90
-
91
- One subdirectory at one commit lands tens of megabytes rather than the whole history. Clean up at the
92
- end and print `df -h /tmp` so the next run knows the state:
93
-
94
- ```bash
95
- find /tmp -maxdepth 1 -name 'work.*' -prune -exec rm -rf {} +
96
- ```
97
-
98
- ## When something fails
99
-
100
- | What you see | What it means |
101
- |---|---|
102
- | `PermissionError: [Errno 13]` on the key | The key file is not readable by this account. |
103
- | `PasswordRequiredException: Private key file is encrypted` | That key has a passphrase and cannot be used unattended. |
104
- | `Authentication (publickey) failed` | The transport works and the NAS does not accept that key. |
105
- | `Load key ...: Permission denied` from `ssh.exe` | A command-line client is being used. Use the runner. |
106
- | `No space left on device` | `/tmp` is full. Clean it, then fetch shallow and sparse. |
107
- | `No module named pytest` | The system interpreter is being used. Activate the project's environment first. |
@@ -1,266 +0,0 @@
1
- """Tests for check_ignored_must_check_return — discarded must-check outcomes.
2
-
3
- A bare-statement call to a function in ALL_MUST_CHECK_RETURN_FUNCTION_NAMES
4
- discards the only failure signal it produces. An assigned or branched-on
5
- call is exempt; only bare ``ast.Expr`` calls are flagged.
6
- """
7
-
8
- from __future__ import annotations
9
-
10
- import sys
11
- from pathlib import Path
12
- from types import SimpleNamespace
13
-
14
- _BLOCKING_DIRECTORY = str(Path(__file__).resolve().parent)
15
- _HOOKS_DIRECTORY = str(Path(__file__).resolve().parent.parent)
16
- if _BLOCKING_DIRECTORY not in sys.path:
17
- sys.path.insert(0, _BLOCKING_DIRECTORY)
18
- if _HOOKS_DIRECTORY not in sys.path:
19
- sys.path.insert(0, _HOOKS_DIRECTORY)
20
-
21
- from code_rules_boolean_mustcheck import ( # noqa: E402
22
- MAX_IGNORED_MUST_CHECK_RETURN_ISSUES,
23
- )
24
- from code_rules_boolean_mustcheck import ( # noqa: E402
25
- check_ignored_must_check_return as _check_ignored_must_check_return,
26
- )
27
- from code_rules_enforcer import ( # noqa: E402
28
- validate_content_for_full_gate as _validate_content,
29
- )
30
-
31
- code_rules_enforcer = SimpleNamespace(
32
- MAX_IGNORED_MUST_CHECK_RETURN_ISSUES=MAX_IGNORED_MUST_CHECK_RETURN_ISSUES,
33
- check_ignored_must_check_return=_check_ignored_must_check_return,
34
- validate_content=_validate_content,
35
- )
36
-
37
-
38
- def check_ignored_must_check_return(content: str, file_path: str) -> list[str]:
39
- return code_rules_enforcer.check_ignored_must_check_return(content, file_path)
40
-
41
-
42
- def validate_content(content: str, file_path: str, old_content: str) -> list[str]:
43
- return code_rules_enforcer.validate_content(content, file_path, old_content)
44
-
45
-
46
- PRODUCTION_FILE_PATH = "/project/src/clicker.py"
47
- TEST_FILE_PATH = "/project/src/test_clicker.py"
48
-
49
-
50
- def test_should_flag_bare_find_and_click_call() -> None:
51
- source = "def step() -> None:\n find_and_click('#submit')\n"
52
- issues = check_ignored_must_check_return(source, PRODUCTION_FILE_PATH)
53
- assert any("find_and_click" in each for each in issues), (
54
- f"Expected discarded-return flag for find_and_click, got: {issues!r}"
55
- )
56
- assert len(issues) == 1
57
-
58
-
59
- def test_should_flag_bare_write_outcome_call() -> None:
60
- source = "def step() -> None:\n write_outcome('done')\n"
61
- issues = check_ignored_must_check_return(source, PRODUCTION_FILE_PATH)
62
- assert any("write_outcome" in each for each in issues), (
63
- f"Expected discarded-return flag for write_outcome, got: {issues!r}"
64
- )
65
- assert len(issues) == 1
66
-
67
-
68
- def test_should_flag_attribute_call_with_must_check_name() -> None:
69
- source = "def step() -> None:\n self.find_and_click('#submit')\n"
70
- issues = check_ignored_must_check_return(source, PRODUCTION_FILE_PATH)
71
- assert len(issues) == 1, f"Attribute call terminal name must be resolved, got: {issues!r}"
72
-
73
-
74
- def test_should_not_flag_assigned_find_and_click() -> None:
75
- source = "def step() -> None:\n clicked = find_and_click('#submit')\n print(clicked)\n"
76
- issues = check_ignored_must_check_return(source, PRODUCTION_FILE_PATH)
77
- assert issues == [], f"Assigned call must not be flagged, got: {issues!r}"
78
-
79
-
80
- def test_should_not_flag_branched_find_and_click() -> None:
81
- source = "def step() -> None:\n if find_and_click('#submit'):\n pass\n"
82
- issues = check_ignored_must_check_return(source, PRODUCTION_FILE_PATH)
83
- assert issues == [], f"Branched-on call must not be flagged, got: {issues!r}"
84
-
85
-
86
- def test_should_flag_bare_awaited_find_and_click_call() -> None:
87
- source = "async def step() -> None:\n await find_and_click('#x')\n"
88
- issues = check_ignored_must_check_return(source, PRODUCTION_FILE_PATH)
89
- assert any("find_and_click" in each for each in issues), (
90
- f"Expected discarded-return flag for awaited find_and_click, got: {issues!r}"
91
- )
92
- assert len(issues) == 1
93
-
94
-
95
- def test_should_not_flag_assigned_awaited_find_and_click() -> None:
96
- source = (
97
- "async def step() -> None:\n"
98
- " clicked = await find_and_click('#x')\n"
99
- " print(clicked)\n"
100
- )
101
- issues = check_ignored_must_check_return(source, PRODUCTION_FILE_PATH)
102
- assert issues == [], f"Assigned awaited call must not be flagged, got: {issues!r}"
103
-
104
-
105
- def test_should_not_flag_branched_awaited_find_and_click() -> None:
106
- source = "async def step() -> None:\n if await find_and_click('#x'):\n pass\n"
107
- issues = check_ignored_must_check_return(source, PRODUCTION_FILE_PATH)
108
- assert issues == [], f"Branched-on awaited call must not be flagged, got: {issues!r}"
109
-
110
-
111
- def test_should_not_flag_unrelated_bare_call() -> None:
112
- source = "def step() -> None:\n print('hello')\n"
113
- issues = check_ignored_must_check_return(source, PRODUCTION_FILE_PATH)
114
- assert issues == [], f"Unrelated call must not be flagged, got: {issues!r}"
115
-
116
-
117
- def test_should_skip_test_file() -> None:
118
- source = "def step() -> None:\n find_and_click('#submit')\n"
119
- issues = check_ignored_must_check_return(source, TEST_FILE_PATH)
120
- assert issues == [], f"Test files exempt, got: {issues!r}"
121
-
122
-
123
- def test_should_handle_syntax_error_gracefully() -> None:
124
- issues = check_ignored_must_check_return("def step(\n", PRODUCTION_FILE_PATH)
125
- assert issues == [], f"Syntax error must yield no issues, got: {issues!r}"
126
-
127
-
128
- def test_validate_content_surfaces_discarded_return() -> None:
129
- source = "def step() -> None:\n find_and_click('#submit')\n"
130
- issues = validate_content(source, PRODUCTION_FILE_PATH, old_content="")
131
- matching_issues = [each for each in issues if "find_and_click" in each]
132
- assert matching_issues, (
133
- f"Expected validate_content to surface the discarded-return issue, got: {issues!r}"
134
- )
135
-
136
-
137
- EDIT_FULL_MODULE_SOURCE = (
138
- "async def step() -> None:\n"
139
- " await find_and_click('#x')\n"
140
- )
141
- AWAITED_CALL_LINE_NUMBER = 2
142
- UNCHANGED_LINE_NUMBER = 1
143
-
144
-
145
- def test_should_flag_when_changed_line_covers_the_bare_await() -> None:
146
- all_changed_lines = {AWAITED_CALL_LINE_NUMBER}
147
- issues = code_rules_enforcer.check_ignored_must_check_return(
148
- EDIT_FULL_MODULE_SOURCE,
149
- PRODUCTION_FILE_PATH,
150
- all_changed_lines,
151
- False,
152
- )
153
- assert len(issues) == 1, (
154
- f"An Edit touching the bare await line must surface exactly one issue, got: {issues!r}"
155
- )
156
- assert "find_and_click" in issues[0]
157
-
158
-
159
- def test_should_not_flag_when_changed_line_excludes_the_bare_await() -> None:
160
- all_changed_lines = {UNCHANGED_LINE_NUMBER}
161
- issues = code_rules_enforcer.check_ignored_must_check_return(
162
- EDIT_FULL_MODULE_SOURCE,
163
- PRODUCTION_FILE_PATH,
164
- all_changed_lines,
165
- False,
166
- )
167
- assert issues == [], (
168
- f"A pre-existing violation on an unedited line must not block the edit, got: {issues!r}"
169
- )
170
-
171
-
172
- PRE_EXISTING_BARE_CALL_COUNT = 5
173
- EDITED_BARE_CALL_LINE_NUMBER = PRE_EXISTING_BARE_CALL_COUNT + 2
174
-
175
-
176
- def _build_module_with_pre_existing_violations_before_the_edit() -> str:
177
- all_signature_lines = ["async def step() -> None:"]
178
- all_pre_existing_call_lines = [
179
- f" await find_and_click('#x{each_index}')"
180
- for each_index in range(PRE_EXISTING_BARE_CALL_COUNT)
181
- ]
182
- edited_call_line = " await find_and_click('#edited')"
183
- all_lines = all_signature_lines + all_pre_existing_call_lines + [edited_call_line]
184
- return "\n".join(all_lines) + "\n"
185
-
186
-
187
- def test_should_flag_edited_line_even_when_cap_worth_of_violations_precede_it() -> None:
188
- source = _build_module_with_pre_existing_violations_before_the_edit()
189
- all_changed_lines = {EDITED_BARE_CALL_LINE_NUMBER}
190
- issues = code_rules_enforcer.check_ignored_must_check_return(
191
- source,
192
- PRODUCTION_FILE_PATH,
193
- all_changed_lines,
194
- False,
195
- )
196
- assert len(issues) == 1, (
197
- "Collecting every violation before scoping must surface the edited-line "
198
- f"violation even with a cap's worth of earlier out-of-scope calls, got: {issues!r}"
199
- )
200
- assert f"Line {EDITED_BARE_CALL_LINE_NUMBER}:" in issues[0], (
201
- f"The single issue must name the edited line {EDITED_BARE_CALL_LINE_NUMBER}, got: {issues!r}"
202
- )
203
-
204
-
205
- def _build_module_with_more_than_cap_bare_calls() -> tuple[str, int]:
206
- bare_call_count = code_rules_enforcer.MAX_IGNORED_MUST_CHECK_RETURN_ISSUES + 3
207
- all_signature_lines = ["async def step() -> None:"]
208
- all_call_lines = [
209
- f" await find_and_click('#x{each_index}')"
210
- for each_index in range(bare_call_count)
211
- ]
212
- source = "\n".join(all_signature_lines + all_call_lines) + "\n"
213
- return source, bare_call_count
214
-
215
-
216
- def test_deferred_scope_returns_every_violation_uncapped() -> None:
217
- source, bare_call_count = _build_module_with_more_than_cap_bare_calls()
218
- issues = code_rules_enforcer.check_ignored_must_check_return(
219
- source,
220
- PRODUCTION_FILE_PATH,
221
- None,
222
- True,
223
- )
224
- assert len(issues) == bare_call_count, (
225
- "With defer_scope_to_caller=True the gate must see every violation uncapped "
226
- f"so it can scope by added line, got: {issues!r}"
227
- )
228
-
229
-
230
- def test_terminal_scope_caps_violations_at_the_module_limit() -> None:
231
- source, _ = _build_module_with_more_than_cap_bare_calls()
232
- issues = code_rules_enforcer.check_ignored_must_check_return(
233
- source,
234
- PRODUCTION_FILE_PATH,
235
- None,
236
- False,
237
- )
238
- assert len(issues) == code_rules_enforcer.MAX_IGNORED_MUST_CHECK_RETURN_ISSUES, (
239
- "The terminal hook path with all_changed_lines=None must cap at the module "
240
- f"limit, got: {issues!r}"
241
- )
242
-
243
-
244
- WRAPPED_CALL_OPEN_PAREN_LINE_NUMBER = 2
245
- WRAPPED_CALL_ARGUMENT_LINE_NUMBER = 3
246
-
247
-
248
- def test_should_flag_when_changed_line_covers_a_later_line_of_a_wrapped_call() -> None:
249
- source = (
250
- "def step() -> None:\n"
251
- " find_and_click(\n"
252
- " '#submit',\n"
253
- " )\n"
254
- )
255
- all_changed_lines = {WRAPPED_CALL_ARGUMENT_LINE_NUMBER}
256
- issues = code_rules_enforcer.check_ignored_must_check_return(
257
- source,
258
- PRODUCTION_FILE_PATH,
259
- all_changed_lines,
260
- False,
261
- )
262
- assert len(issues) == 1, (
263
- "Editing a later line of a multi-line bare must-check call must still flag it "
264
- f"because the violation span covers the whole call, got: {issues!r}"
265
- )
266
- assert "find_and_click" in issues[0]
@@ -1,87 +0,0 @@
1
- """Behavior tests for the code_rules_boolean_mustcheck code-rules check module."""
2
-
3
- from __future__ import annotations
4
-
5
- import sys
6
- from pathlib import Path
7
- from types import SimpleNamespace
8
-
9
- _BLOCKING_DIRECTORY = str(Path(__file__).resolve().parent)
10
- _HOOKS_DIRECTORY = str(Path(__file__).resolve().parent.parent)
11
- if _BLOCKING_DIRECTORY not in sys.path:
12
- sys.path.insert(0, _BLOCKING_DIRECTORY)
13
- if _HOOKS_DIRECTORY not in sys.path:
14
- sys.path.insert(0, _HOOKS_DIRECTORY)
15
-
16
- from code_rules_boolean_mustcheck import ( # noqa: E402
17
- check_ignored_must_check_return,
18
- )
19
-
20
- code_rules_enforcer = SimpleNamespace(
21
- check_ignored_must_check_return=check_ignored_must_check_return,
22
- )
23
-
24
-
25
- def test_ignored_must_check_return_flags_bare_awaited_call() -> None:
26
- """A bare ``await find_and_click(...)`` statement discards its only failure signal.
27
-
28
- The curated must-check functions are async, so the common real call site is a
29
- bare ``await``-wrapped call. Unwrapping ``ast.Await`` before the Call check is
30
- required for this case to be flagged.
31
- """
32
- source = "async def step() -> None:\n await find_and_click('#x')\n"
33
- issues = code_rules_enforcer.check_ignored_must_check_return(
34
- source, "/project/src/clicker.py"
35
- )
36
- assert any("find_and_click" in each_issue for each_issue in issues), (
37
- f"a bare awaited must-check call must be flagged; got: {issues!r}"
38
- )
39
- assert len(issues) == 1
40
-
41
-
42
- def test_ignored_must_check_return_exempts_consumed_awaited_call() -> None:
43
- """An assigned or branched-on awaited must-check call consumes its outcome."""
44
- assigned = "async def step() -> None:\n clicked = await find_and_click('#x')\n print(clicked)\n"
45
- branched = "async def step() -> None:\n if await find_and_click('#x'):\n pass\n"
46
- assert (
47
- code_rules_enforcer.check_ignored_must_check_return(assigned, "/project/src/clicker.py")
48
- == []
49
- )
50
- assert (
51
- code_rules_enforcer.check_ignored_must_check_return(branched, "/project/src/clicker.py")
52
- == []
53
- )
54
-
55
-
56
- def test_ignored_must_check_return_flags_edited_line_past_a_cap_of_earlier_violations() -> None:
57
- """The cap must apply after scoping so the edited-line violation is never dropped.
58
-
59
- Collecting only a cap's worth of violations in ``ast.walk`` order, then scoping,
60
- fills the cap with earlier out-of-scope calls and discards the edited-line one —
61
- the very violation the scoped enforcer exists to block. Every violation must be
62
- collected before scoping so the edited line survives the diff filter.
63
- """
64
- pre_existing_call_count = 5
65
- edited_call_line_number = pre_existing_call_count + 2
66
- all_pre_existing_call_lines = [
67
- f" await find_and_click('#x{each_index}')"
68
- for each_index in range(pre_existing_call_count)
69
- ]
70
- all_lines = (
71
- ["async def step() -> None:"]
72
- + all_pre_existing_call_lines
73
- + [" await find_and_click('#edited')"]
74
- )
75
- source = "\n".join(all_lines) + "\n"
76
- issues = code_rules_enforcer.check_ignored_must_check_return(
77
- source,
78
- "/project/src/clicker.py",
79
- {edited_call_line_number},
80
- False,
81
- )
82
- assert len(issues) == 1, (
83
- f"the edited-line violation must survive a cap's worth of earlier calls; got: {issues!r}"
84
- )
85
- assert f"Line {edited_call_line_number}:" in issues[0], (
86
- f"the single issue must name the edited line {edited_call_line_number}; got: {issues!r}"
87
- )