claude-dev-env 1.84.0 → 1.85.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/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -1
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +8 -0
- package/_shared/pr-loop/scripts/terminology_sweep.py +85 -39
- package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +94 -4
- package/hooks/blocking/claude_md_orphan_file_blocker.py +1 -1
- package/hooks/blocking/code_rules_docstrings.py +17 -13
- package/hooks/blocking/code_rules_imports_logging.py +10 -6
- package/hooks/blocking/code_rules_naming_collection.py +5 -3
- package/hooks/blocking/code_rules_paired_test.py +3 -2
- package/hooks/blocking/code_rules_string_magic.py +1 -1
- package/hooks/blocking/code_rules_unused_imports.py +2 -2
- package/hooks/blocking/test_code_rules_enforcer_docstring_args_span_scope.py +29 -0
- package/hooks/blocking/test_code_rules_enforcer_naive_datetime.py +24 -0
- package/hooks/hooks_constants/blocking_check_limits.py +1 -0
- package/package.json +1 -1
- package/rules/docstring-prose-matches-implementation.md +1 -1
- package/rules/env-var-table-code-drift.md +1 -1
- package/rules/package-inventory-stale-entry.md +1 -1
- package/rules/paired-test-coverage.md +1 -1
- package/skills/_shared/pr-loop/CLAUDE.md +1 -0
- package/skills/_shared/pr-loop/scripts/CLAUDE.md +1 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/CLAUDE.md +1 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/handoff_constants.py +45 -0
- package/skills/_shared/pr-loop/scripts/test_write_handoff.py +201 -0
- package/skills/_shared/pr-loop/scripts/write_handoff.py +309 -0
- package/skills/autoconverge/SKILL.md +46 -3
- package/skills/autoconverge/workflow/converge.mjs +1 -1
- package/skills/pr-converge/SKILL.md +27 -1
- package/skills/pr-converge/reference/state-schema.md +12 -0
|
@@ -16,7 +16,7 @@ Named constants for every script in `_shared/pr-loop/scripts/`. Each module owns
|
|
|
16
16
|
| `preflight_self_heal_constants.py` | `preflight_self_heal.py` — git config keys and local-scope detection strings |
|
|
17
17
|
| `reviews_disabled_constants.py` | `reviews_disabled.py` — `CLAUDE_REVIEWS_DISABLED` token taxonomy |
|
|
18
18
|
| `copilot_quota_constants.py` | `copilot_quota.py` — `COPILOT_QUOTA_ACCOUNT` env-var name, `copilot_internal/user` API path, the `premium_interactions` gating field names, the four skip/run exit codes, and the default `.env` path |
|
|
19
|
-
| `terminology_sweep_constants.py` | `terminology_sweep.py` — identifier and prose-token regexes, diff-parsing prefixes, code-file extensions, and the finding-message template |
|
|
19
|
+
| `terminology_sweep_constants.py` | `terminology_sweep.py` — identifier and prose-token regexes, diff-parsing prefixes, code-file extensions, test-file detection patterns, and the finding-message template |
|
|
20
20
|
| `reviewer_availability_constants.py` | `reviewer_availability.py` — the available/down exit codes for the unified reviewer-availability pre-check |
|
|
21
21
|
| `__init__.py` | Empty package marker |
|
|
22
22
|
|
|
@@ -8,6 +8,8 @@ ALL_SWEEP_CODE_FILE_EXTENSIONS: frozenset[str] = frozenset(
|
|
|
8
8
|
|
|
9
9
|
MARKDOWN_FILE_EXTENSION: str = ".md"
|
|
10
10
|
|
|
11
|
+
PYTHON_FILE_EXTENSION: str = ".py"
|
|
12
|
+
|
|
11
13
|
SNAKE_CASE_IDENTIFIER_PATTERN: re.Pattern[str] = re.compile(
|
|
12
14
|
r"\b[a-z][a-z0-9]*(?:_[a-z0-9]+)+\b"
|
|
13
15
|
)
|
|
@@ -28,6 +30,12 @@ STRING_LITERAL_CONTENT_PATTERN: re.Pattern[str] = re.compile(
|
|
|
28
30
|
r"\"([^\"]*)\"|'([^']*)'|`([^`]*)`"
|
|
29
31
|
)
|
|
30
32
|
|
|
33
|
+
TEST_DIRECTORY_PATH_SEGMENT: str = "/tests/"
|
|
34
|
+
|
|
35
|
+
TEST_FILE_NAME_PREFIX: str = "test_"
|
|
36
|
+
|
|
37
|
+
ALL_TEST_FILE_NAME_INFIX_MARKERS: tuple[str, ...] = ("_test.", ".test.", ".spec.")
|
|
38
|
+
|
|
31
39
|
DIFF_FILE_HEADER_PREFIX: str = "+++ "
|
|
32
40
|
|
|
33
41
|
DIFF_HUNK_HEADER_PATTERN: re.Pattern[str] = re.compile(
|
|
@@ -1,40 +1,27 @@
|
|
|
1
|
-
"""Flag prose
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
Only hyphenated
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
A shared leading token alone is too weak a signal: ordinary English compounds
|
|
23
|
-
such as ``read-only`` and ``data-driven`` collide with unrelated identifiers
|
|
24
|
-
(``read_config``, ``data_source``) and would block a commit falsely. To keep the
|
|
25
|
-
grounding case (``premium-request`` against ``premium_interactions``) firing
|
|
26
|
-
while sparing those compounds, a candidate whose final token is a common English
|
|
27
|
-
compound tail word (``only``, ``driven``, ``safe``, and the rest listed in
|
|
28
|
-
``ALL_COMMON_ENGLISH_COMPOUND_TAIL_WORDS``) is treated as ordinary prose, not a
|
|
29
|
-
near-miss.
|
|
30
|
-
|
|
31
|
-
Ordinary singular/plural prose is spared the same way. A candidate that differs
|
|
32
|
-
from an identifier only by a singular/plural form of one or more tokens
|
|
33
|
-
(``test files`` against ``test_file``, ``retry policies`` against
|
|
34
|
-
``retry_policy``) is treated as the same term, not a near-miss.
|
|
1
|
+
"""Flag a prose term that near-misses a code identifier a diff introduces.
|
|
2
|
+
|
|
3
|
+
A change adds the field ``premium_interactions`` while the same branch's docs
|
|
4
|
+
call it the ``premium-request`` budget: two names for one thing, and a reader
|
|
5
|
+
who searches one never finds the other.
|
|
6
|
+
|
|
7
|
+
::
|
|
8
|
+
|
|
9
|
+
code adds: premium_interactions (the identifier)
|
|
10
|
+
prose adds: "the premium-request budget" (a hyphenated term)
|
|
11
|
+
flag: premium-request vs premium_interactions -- shared first token, tail diverges
|
|
12
|
+
ok: premium-interactions -- exact hyphen form, no drift
|
|
13
|
+
ok: read-only, test files -- English compound or plural, not drift
|
|
14
|
+
|
|
15
|
+
Only hyphenated prose is a candidate, since a hyphen marks a deliberate compound
|
|
16
|
+
while a spaced word run is ordinary prose. Markdown scans in full; a JavaScript
|
|
17
|
+
or TypeScript line contributes its comments and string literals; a Python file
|
|
18
|
+
contributes identifiers only; a test code file is skipped whole. Each near-miss
|
|
19
|
+
prints one ``file:line`` finding and the run exits non-zero, so a commit gate
|
|
20
|
+
blocks on it.
|
|
35
21
|
"""
|
|
36
22
|
|
|
37
23
|
import argparse
|
|
24
|
+
import os
|
|
38
25
|
import subprocess
|
|
39
26
|
import sys
|
|
40
27
|
from pathlib import Path
|
|
@@ -48,6 +35,7 @@ from pr_loop_shared_constants.terminology_sweep_constants import ( # noqa: E402
|
|
|
48
35
|
ALL_DIFF_FILE_PATH_STRIP_PREFIXES,
|
|
49
36
|
ALL_GIT_DIFF_CACHED_UNIFIED_ZERO_COMMAND,
|
|
50
37
|
ALL_SWEEP_CODE_FILE_EXTENSIONS,
|
|
38
|
+
ALL_TEST_FILE_NAME_INFIX_MARKERS,
|
|
51
39
|
CAMEL_CASE_IDENTIFIER_PATTERN,
|
|
52
40
|
CAMEL_CASE_WORD_PATTERN,
|
|
53
41
|
DIFF_ADDED_LINE_PREFIX,
|
|
@@ -64,10 +52,13 @@ from pr_loop_shared_constants.terminology_sweep_constants import ( # noqa: E402
|
|
|
64
52
|
MARKDOWN_FILE_EXTENSION,
|
|
65
53
|
MINIMUM_IDENTIFIER_TOKEN_COUNT,
|
|
66
54
|
PYTHON_COMMENT_MARKER,
|
|
55
|
+
PYTHON_FILE_EXTENSION,
|
|
67
56
|
SNAKE_CASE_IDENTIFIER_PATTERN,
|
|
68
57
|
STRING_LITERAL_CONTENT_PATTERN,
|
|
69
58
|
TERMINOLOGY_FINDING_TEMPLATE,
|
|
70
59
|
TERMINOLOGY_SWEEP_DESCRIPTION,
|
|
60
|
+
TEST_DIRECTORY_PATH_SEGMENT,
|
|
61
|
+
TEST_FILE_NAME_PREFIX,
|
|
71
62
|
)
|
|
72
63
|
|
|
73
64
|
IdentifierTuple = tuple[str, ...]
|
|
@@ -124,6 +115,35 @@ def _file_extension(file_path: str) -> str:
|
|
|
124
115
|
return Path(file_path).suffix.lower()
|
|
125
116
|
|
|
126
117
|
|
|
118
|
+
def _is_test_file(file_path: str) -> bool:
|
|
119
|
+
"""Return True when the diff path is a test code file the sweep should skip.
|
|
120
|
+
|
|
121
|
+
A test code file's prose is scaffolding: its docstrings and string fixtures
|
|
122
|
+
name the identifiers under test on purpose, so scanning it only adds noise. A
|
|
123
|
+
Markdown or other non-code file is prose worth scanning whatever its name, so
|
|
124
|
+
a ``test_plan.md`` design doc is scanned rather than skipped.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
file_path: The repository-relative diff path.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
True for a code file under a ``tests`` directory, a ``test_`` prefixed
|
|
131
|
+
code file, or a code file whose name carries a
|
|
132
|
+
``_test.``/``.test.``/``.spec.`` marker. False for any non-code file.
|
|
133
|
+
"""
|
|
134
|
+
if _file_extension(file_path) not in ALL_SWEEP_CODE_FILE_EXTENSIONS:
|
|
135
|
+
return False
|
|
136
|
+
normalized_path = file_path.replace("\\", "/").lower()
|
|
137
|
+
if TEST_DIRECTORY_PATH_SEGMENT in f"/{normalized_path}":
|
|
138
|
+
return True
|
|
139
|
+
basename = normalized_path.rsplit("/", 1)[-1]
|
|
140
|
+
if basename.startswith(TEST_FILE_NAME_PREFIX):
|
|
141
|
+
return True
|
|
142
|
+
return any(
|
|
143
|
+
each_marker in basename for each_marker in ALL_TEST_FILE_NAME_INFIX_MARKERS
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
127
147
|
def _identifier_token_tuple(identifier: str) -> IdentifierTuple:
|
|
128
148
|
"""Return the lowercase word tokens of a snake_case or camelCase identifier."""
|
|
129
149
|
if "_" in identifier:
|
|
@@ -175,8 +195,11 @@ def _collect_introduced_identifiers(
|
|
|
175
195
|
def _prose_fragments(file_path: str, line_text: str) -> list[str]:
|
|
176
196
|
"""Return the prose fragments of an added line worth scanning for terms.
|
|
177
197
|
|
|
178
|
-
A Markdown line is prose in full. A
|
|
179
|
-
its JSDoc continuation text, and the contents of its string
|
|
198
|
+
A Markdown line is prose in full. A JavaScript or TypeScript line contributes
|
|
199
|
+
its comment tail, its JSDoc continuation text, and the contents of its string
|
|
200
|
+
literals. A Python line contributes no prose: to avoid flagging a module's own
|
|
201
|
+
vocabulary as drift, the sweep ignores Python comments, docstrings, and string
|
|
202
|
+
literals and scans Python files for identifiers only.
|
|
180
203
|
|
|
181
204
|
Args:
|
|
182
205
|
file_path: The path the added line belongs to.
|
|
@@ -185,9 +208,12 @@ def _prose_fragments(file_path: str, line_text: str) -> list[str]:
|
|
|
185
208
|
Returns:
|
|
186
209
|
The prose fragments to scan for near-miss terms.
|
|
187
210
|
"""
|
|
188
|
-
|
|
211
|
+
extension = _file_extension(file_path)
|
|
212
|
+
if extension == MARKDOWN_FILE_EXTENSION:
|
|
189
213
|
return [line_text]
|
|
190
|
-
if
|
|
214
|
+
if extension not in ALL_SWEEP_CODE_FILE_EXTENSIONS:
|
|
215
|
+
return []
|
|
216
|
+
if extension == PYTHON_FILE_EXTENSION:
|
|
191
217
|
return []
|
|
192
218
|
all_fragments: list[str] = []
|
|
193
219
|
stripped_line = line_text.strip()
|
|
@@ -352,7 +378,11 @@ def sweep_diff(diff_text: str) -> list[str]:
|
|
|
352
378
|
Returns:
|
|
353
379
|
One finding string per near-miss term on an added prose line.
|
|
354
380
|
"""
|
|
355
|
-
all_added_lines =
|
|
381
|
+
all_added_lines = [
|
|
382
|
+
each_added_line
|
|
383
|
+
for each_added_line in _parse_added_lines(diff_text)
|
|
384
|
+
if not _is_test_file(each_added_line[0])
|
|
385
|
+
]
|
|
356
386
|
all_identifier_tuples, identifiers_by_first_token = _collect_introduced_identifiers(
|
|
357
387
|
all_added_lines
|
|
358
388
|
)
|
|
@@ -372,6 +402,21 @@ def sweep_diff(diff_text: str) -> list[str]:
|
|
|
372
402
|
return all_findings
|
|
373
403
|
|
|
374
404
|
|
|
405
|
+
def _git_environment() -> dict[str, str]:
|
|
406
|
+
"""Return the process environment with every GIT_ variable removed.
|
|
407
|
+
|
|
408
|
+
A git commit hook runs this sweep with GIT_INDEX_FILE pointed at the commit
|
|
409
|
+
index. Stripping every GIT_ variable makes ``git diff --cached`` read the
|
|
410
|
+
repository's own index, so the sweep sees the staged diff of the repository
|
|
411
|
+
it was handed rather than an ambient override.
|
|
412
|
+
"""
|
|
413
|
+
return {
|
|
414
|
+
each_key: each_setting
|
|
415
|
+
for each_key, each_setting in os.environ.items()
|
|
416
|
+
if not each_key.startswith("GIT_")
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
|
|
375
420
|
def staged_terminology_findings(repository_root: Path) -> list[str]:
|
|
376
421
|
"""Return terminology near-miss findings for a repository's staged diff.
|
|
377
422
|
|
|
@@ -391,6 +436,7 @@ def staged_terminology_findings(repository_root: Path) -> list[str]:
|
|
|
391
436
|
errors="replace",
|
|
392
437
|
timeout=GIT_DIFF_SUBPROCESS_TIMEOUT_SECONDS,
|
|
393
438
|
check=False,
|
|
439
|
+
env=_git_environment(),
|
|
394
440
|
)
|
|
395
441
|
if diff_process.returncode != 0:
|
|
396
442
|
return []
|
|
@@ -23,13 +23,22 @@ main = sweep_module.main
|
|
|
23
23
|
parse_added_lines = sweep_module._parse_added_lines
|
|
24
24
|
|
|
25
25
|
|
|
26
|
+
_git_environment = sweep_module._git_environment
|
|
27
|
+
|
|
28
|
+
|
|
26
29
|
def _init_git_repository(repository_path: Path) -> None:
|
|
27
30
|
for each_command in (
|
|
28
31
|
["git", "init"],
|
|
29
32
|
["git", "config", "user.email", "test@example.com"],
|
|
30
33
|
["git", "config", "user.name", "Test"],
|
|
31
34
|
):
|
|
32
|
-
subprocess.run(
|
|
35
|
+
subprocess.run(
|
|
36
|
+
each_command,
|
|
37
|
+
cwd=repository_path,
|
|
38
|
+
check=True,
|
|
39
|
+
capture_output=True,
|
|
40
|
+
env=_git_environment(),
|
|
41
|
+
)
|
|
33
42
|
|
|
34
43
|
|
|
35
44
|
CODE_AND_PROSE_DIFF = (
|
|
@@ -220,17 +229,86 @@ def test_does_not_flag_ordinary_sentence_sharing_loop_variable_prefix() -> None:
|
|
|
220
229
|
|
|
221
230
|
|
|
222
231
|
def test_flags_near_miss_inside_code_comment() -> None:
|
|
232
|
+
diff = (
|
|
233
|
+
"diff --git a/api/quota.mjs b/api/quota.mjs\n"
|
|
234
|
+
"--- a/api/quota.mjs\n"
|
|
235
|
+
"+++ b/api/quota.mjs\n"
|
|
236
|
+
"@@ -0,0 +1,2 @@\n"
|
|
237
|
+
"+const premium_interactions = 5\n"
|
|
238
|
+
"+// the premium-request path resets the counter\n"
|
|
239
|
+
)
|
|
240
|
+
findings = sweep_diff(diff)
|
|
241
|
+
assert len(findings) == 1
|
|
242
|
+
assert "premium-request" in findings[0]
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def test_flags_near_miss_inside_code_string_literal() -> None:
|
|
246
|
+
diff = (
|
|
247
|
+
"diff --git a/api/quota.mjs b/api/quota.mjs\n"
|
|
248
|
+
"--- a/api/quota.mjs\n"
|
|
249
|
+
"+++ b/api/quota.mjs\n"
|
|
250
|
+
"@@ -0,0 +1,2 @@\n"
|
|
251
|
+
"+const premium_interactions = 5\n"
|
|
252
|
+
'+throw new Error("the premium-request budget")\n'
|
|
253
|
+
)
|
|
254
|
+
findings = sweep_diff(diff)
|
|
255
|
+
assert len(findings) == 1
|
|
256
|
+
assert "premium-request" in findings[0]
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def test_does_not_flag_prose_inside_code_string_literal() -> None:
|
|
260
|
+
diff = (
|
|
261
|
+
"diff --git a/api/quota.py b/api/quota.py\n"
|
|
262
|
+
"--- a/api/quota.py\n"
|
|
263
|
+
"+++ b/api/quota.py\n"
|
|
264
|
+
"@@ -0,0 +1,2 @@\n"
|
|
265
|
+
"+premium_interactions = 5\n"
|
|
266
|
+
'+message = "the premium-request budget"\n'
|
|
267
|
+
)
|
|
268
|
+
assert sweep_diff(diff) == []
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def test_does_not_flag_prose_inside_code_docstring() -> None:
|
|
223
272
|
diff = (
|
|
224
273
|
"diff --git a/api/quota.py b/api/quota.py\n"
|
|
225
274
|
"--- a/api/quota.py\n"
|
|
226
275
|
"+++ b/api/quota.py\n"
|
|
227
276
|
"@@ -0,0 +1,2 @@\n"
|
|
228
277
|
"+premium_interactions = 5\n"
|
|
229
|
-
"
|
|
278
|
+
'+ """Gate the run on the premium-request budget."""\n'
|
|
279
|
+
)
|
|
280
|
+
assert sweep_diff(diff) == []
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def test_does_not_flag_prose_in_test_file() -> None:
|
|
284
|
+
diff = (
|
|
285
|
+
"diff --git a/api/quota.test.mjs b/api/quota.test.mjs\n"
|
|
286
|
+
"--- a/api/quota.test.mjs\n"
|
|
287
|
+
"+++ b/api/quota.test.mjs\n"
|
|
288
|
+
"@@ -0,0 +1,2 @@\n"
|
|
289
|
+
"+const premium_interactions = 5\n"
|
|
290
|
+
"+// the premium-request path resets the counter\n"
|
|
291
|
+
)
|
|
292
|
+
assert sweep_diff(diff) == []
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def test_flags_prose_in_test_named_markdown_doc() -> None:
|
|
296
|
+
diff = (
|
|
297
|
+
"diff --git a/api/quota.py b/api/quota.py\n"
|
|
298
|
+
"--- a/api/quota.py\n"
|
|
299
|
+
"+++ b/api/quota.py\n"
|
|
300
|
+
"@@ -0,0 +1,1 @@\n"
|
|
301
|
+
"+premium_interactions = 5\n"
|
|
302
|
+
"diff --git a/docs/test_plan.md b/docs/test_plan.md\n"
|
|
303
|
+
"--- a/docs/test_plan.md\n"
|
|
304
|
+
"+++ b/docs/test_plan.md\n"
|
|
305
|
+
"@@ -0,0 +1,1 @@\n"
|
|
306
|
+
"+The premium-request budget gates the run.\n"
|
|
230
307
|
)
|
|
231
308
|
findings = sweep_diff(diff)
|
|
232
309
|
assert len(findings) == 1
|
|
233
310
|
assert "premium-request" in findings[0]
|
|
311
|
+
assert "test_plan.md" in findings[0]
|
|
234
312
|
|
|
235
313
|
|
|
236
314
|
def test_no_findings_when_no_multiword_identifier_introduced() -> None:
|
|
@@ -273,7 +351,13 @@ def test_staged_terminology_findings_flags_staged_prose(tmp_path: Path) -> None:
|
|
|
273
351
|
(tmp_path / "README.md").write_text(
|
|
274
352
|
"The premium-request budget gates the run.\n", encoding="utf-8"
|
|
275
353
|
)
|
|
276
|
-
subprocess.run(
|
|
354
|
+
subprocess.run(
|
|
355
|
+
["git", "add", "-A"],
|
|
356
|
+
cwd=tmp_path,
|
|
357
|
+
check=True,
|
|
358
|
+
capture_output=True,
|
|
359
|
+
env=_git_environment(),
|
|
360
|
+
)
|
|
277
361
|
findings = staged_terminology_findings(tmp_path)
|
|
278
362
|
assert any("premium-request" in each_finding for each_finding in findings)
|
|
279
363
|
|
|
@@ -281,7 +365,13 @@ def test_staged_terminology_findings_flags_staged_prose(tmp_path: Path) -> None:
|
|
|
281
365
|
def test_staged_terminology_findings_empty_when_clean(tmp_path: Path) -> None:
|
|
282
366
|
_init_git_repository(tmp_path)
|
|
283
367
|
(tmp_path / "README.md").write_text("Nothing notable here.\n", encoding="utf-8")
|
|
284
|
-
subprocess.run(
|
|
368
|
+
subprocess.run(
|
|
369
|
+
["git", "add", "-A"],
|
|
370
|
+
cwd=tmp_path,
|
|
371
|
+
check=True,
|
|
372
|
+
capture_output=True,
|
|
373
|
+
env=_git_environment(),
|
|
374
|
+
)
|
|
285
375
|
assert staged_terminology_findings(tmp_path) == []
|
|
286
376
|
|
|
287
377
|
|
|
@@ -45,11 +45,11 @@ from hooks_constants.claude_md_orphan_file_blocker_constants import ( # noqa: E
|
|
|
45
45
|
SEPARATOR_CELL_PATTERN,
|
|
46
46
|
TABLE_ROW_PATTERN,
|
|
47
47
|
)
|
|
48
|
+
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
48
49
|
from hooks_constants.multi_edit_reconstruction import ( # noqa: E402
|
|
49
50
|
apply_edits,
|
|
50
51
|
edits_for_tool,
|
|
51
52
|
)
|
|
52
|
-
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
53
53
|
from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
|
|
54
54
|
read_hook_input_dictionary_from_stdin,
|
|
55
55
|
)
|
|
@@ -93,6 +93,7 @@ from hooks_constants.blocking_check_limits import ( # noqa: E402
|
|
|
93
93
|
MINIMUM_NAMED_MARKS_FOR_PROSE_ENUMERATION,
|
|
94
94
|
MINIMUM_PUBLIC_CHECKS_FOR_MODULE_DOCSTRING_ROSTER,
|
|
95
95
|
MINIMUM_PUBLIC_METHODS_FOR_CLASS_DOCSTRING_BREADTH,
|
|
96
|
+
MINIMUM_RANGE_ARGS_FOR_SPAN,
|
|
96
97
|
MINIMUM_SIBLING_OCCURRENCES_FOR_SHARED_TOKEN,
|
|
97
98
|
MINIMUM_TOKENS_FOR_DISPATCH_CALLEE,
|
|
98
99
|
MINIMUM_TUPLE_MEMBERS_FOR_DOCSTRING_ENUMERATION,
|
|
@@ -2019,13 +2020,14 @@ def _documentable_docstrings_with_line(parsed_tree: ast.Module) -> list[tuple[in
|
|
|
2019
2020
|
def check_docstring_cardinal_count_matches_constant_family(
|
|
2020
2021
|
content: str, file_path: str
|
|
2021
2022
|
) -> list[str]:
|
|
2022
|
-
"""Flag a docstring cardinal count that
|
|
2023
|
+
"""Flag a docstring cardinal count that differs from a referenced constant family.
|
|
2023
2024
|
|
|
2024
2025
|
The drift this catches: a docstring states a cardinal count of an outcome
|
|
2025
2026
|
family (``Covers the four outcome branches: ...``) and enumerates that
|
|
2026
|
-
family's members in prose, while the module references
|
|
2027
|
-
same ``UPPER_SNAKE`` constant family
|
|
2028
|
-
|
|
2027
|
+
family's members in prose, while the module references a member set of the
|
|
2028
|
+
same ``UPPER_SNAKE`` constant family whose size differs from the stated
|
|
2029
|
+
count — above it and below it both trip. The prose
|
|
2030
|
+
misdescribes the code — a reader trusts the count and the enumeration to
|
|
2029
2031
|
be the full set, but the module imports and exercises an outcome the summary
|
|
2030
2032
|
omits. The check binds only when the docstring states a cardinal beside an
|
|
2031
2033
|
outcome noun, names two or more members of one referenced family, leaves at
|
|
@@ -2038,7 +2040,8 @@ def check_docstring_cardinal_count_matches_constant_family(
|
|
|
2038
2040
|
|
|
2039
2041
|
Args:
|
|
2040
2042
|
content: The source text to inspect.
|
|
2041
|
-
file_path:
|
|
2043
|
+
file_path: Accepted for the uniform check interface; the body does not
|
|
2044
|
+
read it.
|
|
2042
2045
|
|
|
2043
2046
|
Returns:
|
|
2044
2047
|
One issue per docstring whose cardinal count and enumeration omit a
|
|
@@ -2473,12 +2476,12 @@ def _argument_prose_scopes_a_single_line(argument_prose: str) -> bool:
|
|
|
2473
2476
|
)
|
|
2474
2477
|
|
|
2475
2478
|
|
|
2476
|
-
def
|
|
2479
|
+
def _call_node_builds_explicit_start_range(call_node: ast.Call) -> bool:
|
|
2477
2480
|
callee = call_node.func
|
|
2478
2481
|
return (
|
|
2479
2482
|
isinstance(callee, ast.Name)
|
|
2480
2483
|
and callee.id == "range"
|
|
2481
|
-
and len(call_node.args) >=
|
|
2484
|
+
and len(call_node.args) >= MINIMUM_RANGE_ARGS_FOR_SPAN
|
|
2482
2485
|
)
|
|
2483
2486
|
|
|
2484
2487
|
|
|
@@ -2490,7 +2493,7 @@ def _function_body_scopes_a_span_by_intersection(
|
|
|
2490
2493
|
for each_node in ast.walk(function_node):
|
|
2491
2494
|
if not isinstance(each_node, ast.Call):
|
|
2492
2495
|
continue
|
|
2493
|
-
if
|
|
2496
|
+
if _call_node_builds_explicit_start_range(each_node):
|
|
2494
2497
|
builds_range_span = True
|
|
2495
2498
|
if _call_callee_name(each_node) in ALL_DOCSTRING_SPAN_RANGE_BODY_CALLEE_NAMES:
|
|
2496
2499
|
calls_span_scoper = True
|
|
@@ -2510,11 +2513,12 @@ def check_docstring_args_single_line_scope_vs_span(content: str, file_path: str)
|
|
|
2510
2513
|
is the deterministic slice of Category O6 docstring-vs-implementation drift
|
|
2511
2514
|
for an Args single-line scope claim disagreeing with a span-intersection body.
|
|
2512
2515
|
|
|
2513
|
-
An entry is left alone when its prose says "any line of" / "any line in"
|
|
2514
|
-
|
|
2515
|
-
span
|
|
2516
|
-
|
|
2517
|
-
|
|
2516
|
+
An entry is left alone when its prose says "any line of" / "any line in" /
|
|
2517
|
+
"any of its lines" / "any span line" about its span, since that wording
|
|
2518
|
+
matches the span body. The body is judged a span-intersection scoper only
|
|
2519
|
+
when it both builds an explicit-start ``range(...)`` (two or more
|
|
2520
|
+
arguments) and calls a known span scoper, so a body that scopes by a
|
|
2521
|
+
single line never trips the check. Hook infrastructure is in scope here — the import-sort gate
|
|
2518
2522
|
that carries this drift class is itself a hook — and test files are exempt.
|
|
2519
2523
|
|
|
2520
2524
|
Args:
|
|
@@ -244,7 +244,8 @@ def check_import_block_sorted(
|
|
|
244
244
|
give ruff the filename it needs for config discovery.
|
|
245
245
|
all_changed_lines: Post-edit line numbers the current edit touched, or
|
|
246
246
|
None to treat the whole file as in scope. When provided, a finding
|
|
247
|
-
blocks only when its block
|
|
247
|
+
blocks only when any line of its block span is among the changed
|
|
248
|
+
lines.
|
|
248
249
|
defer_scope_to_caller: When True, return every finding so the commit/push
|
|
249
250
|
gate's ``Line N:`` partitioning scopes by added line.
|
|
250
251
|
|
|
@@ -813,9 +814,9 @@ def _naive_datetime_remediation(constructor_name: str) -> str:
|
|
|
813
814
|
"""Return the fix guidance for a specific naive datetime constructor.
|
|
814
815
|
|
|
815
816
|
``utcnow`` and ``utcfromtimestamp`` accept no ``tz`` argument, so each points
|
|
816
|
-
at its aware replacement (``
|
|
817
|
-
|
|
818
|
-
only needs a ``tz=`` argument.
|
|
817
|
+
at its aware replacement (``now(tz=...)`` and ``fromtimestamp(..., tz=...)``
|
|
818
|
+
on the same datetime class, whichever import form the call used);
|
|
819
|
+
``fromtimestamp`` keeps its shape and only needs a ``tz=`` argument.
|
|
819
820
|
|
|
820
821
|
Args:
|
|
821
822
|
constructor_name: The naive constructor the call used.
|
|
@@ -824,9 +825,12 @@ def _naive_datetime_remediation(constructor_name: str) -> str:
|
|
|
824
825
|
The remediation clause to embed in the emitted issue.
|
|
825
826
|
"""
|
|
826
827
|
if constructor_name == NAIVE_DATETIME_UTCNOW_CONSTRUCTOR:
|
|
827
|
-
return "
|
|
828
|
+
return "replace utcnow() with now(tz=timezone.utc) on the same datetime class"
|
|
828
829
|
if constructor_name == NAIVE_DATETIME_UTCFROMTIMESTAMP_CONSTRUCTOR:
|
|
829
|
-
return
|
|
830
|
+
return (
|
|
831
|
+
"replace utcfromtimestamp(...) with fromtimestamp(..., tz=timezone.utc) "
|
|
832
|
+
"on the same datetime class"
|
|
833
|
+
)
|
|
830
834
|
return "pass tz= (e.g. tz=timezone.utc)"
|
|
831
835
|
|
|
832
836
|
|
|
@@ -434,7 +434,7 @@ def _contradicting_polarity_pair(target_name: str, called_name: str) -> tuple[st
|
|
|
434
434
|
|
|
435
435
|
|
|
436
436
|
def check_polarity_name_contradiction(content: str, file_path: str) -> list[str]:
|
|
437
|
-
"""Flag
|
|
437
|
+
"""Flag an assignment whose target and callee assert opposite polarity.
|
|
438
438
|
|
|
439
439
|
::
|
|
440
440
|
|
|
@@ -445,8 +445,10 @@ def check_polarity_name_contradiction(content: str, file_path: str) -> list[str]
|
|
|
445
445
|
flag: is_inside_allowed = _point_hits_any_forbidden(...)
|
|
446
446
|
|
|
447
447
|
The target token and the callee token contradict each other, so the reader
|
|
448
|
-
cannot tell which name states the truth.
|
|
449
|
-
|
|
448
|
+
cannot tell which name states the truth. Any single-name assignment from a
|
|
449
|
+
call is inspected — collections and counts included, not only booleans.
|
|
450
|
+
Rename the callee to a neutral form the two names agree on at every call
|
|
451
|
+
site.
|
|
450
452
|
"""
|
|
451
453
|
if is_test_file(file_path):
|
|
452
454
|
return []
|
|
@@ -164,8 +164,9 @@ def _stem_matched_test_path(module_path: Path) -> Path | None:
|
|
|
164
164
|
|
|
165
165
|
Returns:
|
|
166
166
|
The path of the first existing ``test_<stem>.py`` or ``<stem>_test.py``
|
|
167
|
-
file
|
|
168
|
-
None when the module has no dedicated test
|
|
167
|
+
file beside the module, or ``test_<stem>.py`` under an ancestor
|
|
168
|
+
``tests`` directory — or None when the module has no dedicated test
|
|
169
|
+
file.
|
|
169
170
|
"""
|
|
170
171
|
module_directory = module_path.parent
|
|
171
172
|
module_stem = module_path.stem
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Bare string-literal magic, inline literal-collection, inline tuple string-magic, and whitespace-indentation magic checks."""
|
|
1
|
+
"""Bare string-literal magic, join-separator string magic, inline literal-collection, inline tuple string-magic, and whitespace-indentation magic checks."""
|
|
2
2
|
|
|
3
3
|
import ast
|
|
4
4
|
import re
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Unused module-level import check and its import-range
|
|
1
|
+
"""Unused module-level import check and its import-range helpers."""
|
|
2
2
|
|
|
3
3
|
import ast
|
|
4
4
|
import sys
|
|
@@ -143,7 +143,7 @@ def check_unused_module_level_imports(
|
|
|
143
143
|
|
|
144
144
|
When ``full_file_content`` is provided, ``content`` is treated as an Edit
|
|
145
145
|
fragment containing the imports being added or replaced, while the
|
|
146
|
-
``__all__``
|
|
146
|
+
``__all__`` gate detection and reference scanning run
|
|
147
147
|
against ``full_file_content`` (the post-edit file as it will look once the
|
|
148
148
|
Edit applies). This prevents false-positive flags on imports added in the
|
|
149
149
|
same Edit as their consumers.
|
|
@@ -75,6 +75,35 @@ def test_flags_anchor_line_scope_against_span_intersection_body() -> None:
|
|
|
75
75
|
assert "all_changed_lines" in issues[0]
|
|
76
76
|
|
|
77
77
|
|
|
78
|
+
def test_flags_single_line_scope_against_stepped_range_span_body() -> None:
|
|
79
|
+
content = (
|
|
80
|
+
"def check_import_block_sorted(\n"
|
|
81
|
+
" content: str, file_path: str, all_changed_lines: set[int] | None\n"
|
|
82
|
+
") -> list[str]:\n"
|
|
83
|
+
' """Flag an unsorted import block scoped to the changed lines.\n'
|
|
84
|
+
"\n"
|
|
85
|
+
" Args:\n"
|
|
86
|
+
" content: The full file content the write would leave on disk.\n"
|
|
87
|
+
" file_path: The destination path used to gate by extension.\n"
|
|
88
|
+
" all_changed_lines: Post-edit line numbers, or None. When provided,\n"
|
|
89
|
+
" a finding blocks only when its block-anchor line is among the\n"
|
|
90
|
+
" changed lines.\n"
|
|
91
|
+
"\n"
|
|
92
|
+
" Returns:\n"
|
|
93
|
+
" One issue string per detected finding.\n"
|
|
94
|
+
' """\n'
|
|
95
|
+
" span_range = range(line_number, block_end_line_number + 1, 1)\n"
|
|
96
|
+
" all_violations.append((span_range, message))\n"
|
|
97
|
+
" return _scope_violations_to_changed_lines(\n"
|
|
98
|
+
" all_violations, all_changed_lines, defer_scope_to_caller\n"
|
|
99
|
+
" )\n"
|
|
100
|
+
)
|
|
101
|
+
issues = check_docstring_args_single_line_scope_vs_span(content, PRODUCTION_FILE_PATH)
|
|
102
|
+
assert len(issues) == 1
|
|
103
|
+
assert "check_import_block_sorted" in issues[0]
|
|
104
|
+
assert "all_changed_lines" in issues[0]
|
|
105
|
+
|
|
106
|
+
|
|
78
107
|
def test_flags_the_line_is_among_phrasing() -> None:
|
|
79
108
|
content = (
|
|
80
109
|
"def check_block(\n"
|
|
@@ -181,6 +181,30 @@ def test_utcfromtimestamp_remediation_uses_generic_placeholder() -> None:
|
|
|
181
181
|
assert "stamp, tz=" not in issues[0]
|
|
182
182
|
|
|
183
183
|
|
|
184
|
+
def test_utcnow_remediation_is_import_agnostic() -> None:
|
|
185
|
+
content = (
|
|
186
|
+
"import datetime\n"
|
|
187
|
+
"def stamp() -> object:\n"
|
|
188
|
+
" return datetime.datetime.utcnow()\n"
|
|
189
|
+
)
|
|
190
|
+
issues = check_naive_datetime_construction(content, PRODUCTION_FILE_PATH)
|
|
191
|
+
assert len(issues) == 1
|
|
192
|
+
assert "use datetime.now(" not in issues[0]
|
|
193
|
+
assert "on the same datetime class" in issues[0]
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def test_utcfromtimestamp_remediation_is_import_agnostic() -> None:
|
|
197
|
+
content = (
|
|
198
|
+
"import datetime\n"
|
|
199
|
+
"def build(stamp: float) -> object:\n"
|
|
200
|
+
" return datetime.datetime.utcfromtimestamp(stamp)\n"
|
|
201
|
+
)
|
|
202
|
+
issues = check_naive_datetime_construction(content, PRODUCTION_FILE_PATH)
|
|
203
|
+
assert len(issues) == 1
|
|
204
|
+
assert "use datetime.fromtimestamp(" not in issues[0]
|
|
205
|
+
assert "on the same datetime class" in issues[0]
|
|
206
|
+
|
|
207
|
+
|
|
184
208
|
def test_remediation_does_not_mandate_astimezone() -> None:
|
|
185
209
|
content = (
|
|
186
210
|
"from datetime import datetime\n"
|
|
@@ -99,6 +99,7 @@ ALL_PUNCTUATION_MARK_GLYPH_PROSE_NAMES: dict[str, tuple[str, ...]] = {
|
|
|
99
99
|
MAX_DOCSTRING_STEP_DISPATCH_ISSUES: int = 5
|
|
100
100
|
MINIMUM_NAMED_LINEAR_STEPS_FOR_DISPATCH_CHECK: int = 2
|
|
101
101
|
MINIMUM_TOKENS_FOR_DISPATCH_CALLEE: int = 2
|
|
102
|
+
MINIMUM_RANGE_ARGS_FOR_SPAN: int = 2
|
|
102
103
|
MAX_DOCSTRING_UNDEFINED_CONSTANT_ISSUES: int = 3
|
|
103
104
|
MAX_DOCSTRING_RETURNS_PLURAL_CARDINALITY_ISSUES: int = 5
|
|
104
105
|
SINGLE_DICT_KEY_COUNT_FOR_PLURAL_CARDINALITY_DRIFT: int = 1
|