claude-dev-env 1.79.0 → 1.81.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/CLAUDE.md +3 -1
- package/_shared/pr-loop/scripts/code_rules_gate.py +116 -30
- package/_shared/pr-loop/scripts/copilot_quota.py +360 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +2 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +13 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/copilot_quota_constants.py +24 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +113 -0
- package/_shared/pr-loop/scripts/terminology_sweep.py +467 -0
- package/_shared/pr-loop/scripts/tests/CLAUDE.md +8 -0
- package/_shared/pr-loop/scripts/tests/fixtures/copilot_internal_user_jonecho.json +76 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +339 -0
- package/_shared/pr-loop/scripts/tests/test_copilot_quota.py +242 -0
- package/_shared/pr-loop/scripts/tests/test_copilot_quota_constants.py +63 -0
- package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +297 -0
- package/audit-rubrics/CLAUDE.md +3 -2
- package/audit-rubrics/category_rubrics/CLAUDE.md +2 -1
- package/audit-rubrics/category_rubrics/category-a-api-contracts.md +2 -1
- package/audit-rubrics/category_rubrics/category-j-code-rules-compliance.md +13 -1
- package/audit-rubrics/category_rubrics/category-p-name-vs-behavior-contract.md +19 -0
- package/audit-rubrics/category_rubrics/category-q-cross-surface-claims.md +46 -0
- package/audit-rubrics/prompts/CLAUDE.md +2 -1
- package/audit-rubrics/prompts/category-a-api-contracts.md +1 -0
- package/audit-rubrics/prompts/category-j-code-rules-compliance.md +19 -7
- package/audit-rubrics/prompts/category-p-name-vs-behavior-contract.md +14 -0
- package/audit-rubrics/prompts/category-q-cross-surface-claims.md +51 -0
- package/docs/CODE_RULES.md +2 -2
- package/hooks/blocking/CLAUDE.md +2 -0
- package/hooks/blocking/code_rules_annotations_length.py +59 -11
- package/hooks/blocking/code_rules_banned_identifiers.py +48 -9
- package/hooks/blocking/code_rules_command_dispatch.py +140 -0
- package/hooks/blocking/code_rules_docstrings.py +93 -0
- package/hooks/blocking/code_rules_enforcer.py +58 -4
- package/hooks/blocking/code_rules_imports_logging.py +136 -1
- package/hooks/blocking/code_rules_js_conventions.py +246 -0
- package/hooks/blocking/code_rules_naming_collection.py +5 -0
- package/hooks/blocking/code_rules_test_assertions.py +3 -0
- package/hooks/blocking/code_rules_unused_imports.py +7 -66
- package/hooks/blocking/duplicate_rmtree_helper_blocker.py +7 -0
- package/hooks/blocking/test_code_rules_command_dispatch.py +95 -0
- package/hooks/blocking/test_code_rules_enforcer_annotations.py +20 -2
- package/hooks/blocking/test_code_rules_enforcer_banned_identifier.py +10 -2
- package/hooks/blocking/test_code_rules_enforcer_banned_import_alias.py +8 -4
- package/hooks/blocking/test_code_rules_enforcer_dispatch_wiring.py +9 -3
- package/hooks/blocking/test_code_rules_enforcer_docstring_type_checking_gate.py +164 -0
- package/hooks/blocking/test_code_rules_enforcer_js_returns_object.py +72 -0
- package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +112 -18
- package/hooks/blocking/test_code_rules_js_conventions.py +167 -0
- package/hooks/blocking/test_code_rules_js_returns_object_schemaless.py +167 -0
- package/hooks/hooks_constants/CLAUDE.md +2 -0
- package/hooks/hooks_constants/blocking_check_limits.py +10 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +14 -0
- package/hooks/hooks_constants/command_dispatch_constants.py +28 -0
- package/hooks/hooks_constants/js_conventions_constants.py +54 -0
- package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +93 -0
- package/hooks/hooks_constants/unused_module_import_constants.py +0 -1
- package/package.json +1 -1
- package/rules/docstring-prose-matches-implementation.md +3 -1
- package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +43 -1
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/CLAUDE.md +2 -2
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +1 -4
- package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +100 -4
- package/skills/autoconverge/SKILL.md +28 -7
- package/skills/autoconverge/reference/convergence.md +3 -3
- package/skills/autoconverge/reference/stop-conditions.md +1 -1
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +4 -4
- package/skills/autoconverge/workflow/converge.contract.test.mjs +58 -122
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +289 -5
- package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +50 -46
- package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +6 -6
- package/skills/autoconverge/workflow/converge.mjs +235 -247
- package/skills/autoconverge/workflow/converge.run-input.test.mjs +11 -0
- package/skills/autoconverge/workflow/converge_multi.mjs +23 -7
- package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +28 -2
- package/skills/pr-converge/SKILL.md +28 -2
- package/skills/pr-converge/reference/convergence-gates.md +13 -1
- package/skills/pr-converge/reference/state-schema.md +11 -0
package/docs/CODE_RULES.md
CHANGED
|
@@ -23,7 +23,7 @@ Compact reference for agents. ⚡ marks rules enforced by `code_rules_enforcer.p
|
|
|
23
23
|
|
|
24
24
|
`code_rules_enforcer.py` blocks each of these at Write/Edit and explains the specific violation when it fires; exact patterns and exemption lists live in the hook:
|
|
25
25
|
|
|
26
|
-
no new comments · imports at top · logging format args (`log_*("...", arg)`) · no `%s`/`%d` printf tokens in a `str.format`-logger message (`log_*` imported from `automation_logging`; `str.format` drops the args — use `{}`) · no magic values in production bodies (0, 1, -1 exempt) · UPPER_SNAKE constants only in `config/` (exempt: `config/*`, `/migrations/`, workflow registries `/workflow/` + `_tab.py` + `/states.py` + `/modules.py`, test files) · no hardcoded user home paths · guarded `sys.path.insert` · no unused module-level imports · banned identifiers (`ctx`, `cfg`, `msg`, `btn`, `idx`, `cnt`, `tmp`, `elem`, `val`) · banned function prefixes (`handle_`, `process_`, `manage_`, `do_`) · no type escape hatches (`Any` import, `cast()`, inline `Any`, a parameter typed bare `object` whose body reads `param.attribute`) outside boundary files · no bare/broad `except` · no `Any` in signatures or class attributes · no stub bodies (`pass`/`...`/`raise NotImplementedError`) outside abstract/Protocol · TypedDict `_encode_*`/`_decode_*` companions in the same module · no test-mode branching in production (use dependency injection) · no thin wrapper modules · Google-style docstrings on public functions with `Args:` matching the signature · boolean names prefixed `is_`/`has_`/`should_`/`can_`/`was_`/`did_` (assignments AND bool-typed parameters) · must-check returns (`find_and_click`, `write_outcome`) assigned and checked · known pytest fixture parameters in test files annotated with their single documented type (`tmp_path: Path`, `monkeypatch: pytest.MonkeyPatch`, `capsys`, `caplog`, `request`, …) · known pytest fixture parameters a test function declares but never references (drop the unused parameter — pytest still pays its setup cost)
|
|
26
|
+
no new comments · imports at top · logging format args (`log_*("...", arg)`) · no `%s`/`%d` printf tokens in a `str.format`-logger message (`log_*` imported from `automation_logging`; `str.format` drops the args — use `{}`) · no magic values in production bodies (0, 1, -1 exempt) · UPPER_SNAKE constants only in `config/` (exempt: `config/*`, `/migrations/`, workflow registries `/workflow/` + `_tab.py` + `/states.py` + `/modules.py`, test files) · no hardcoded user home paths · guarded `sys.path.insert` · no unused module-level imports · banned identifiers (`ctx`, `cfg`, `msg`, `btn`, `idx`, `cnt`, `tmp`, `elem`, `val`) · banned function prefixes (`handle_`, `process_`, `manage_`, `do_`) · no type escape hatches (`Any` import, `cast()`, inline `Any`, a parameter typed bare `object` whose body reads `param.attribute`) outside boundary files · no bare/broad `except` · no `Any` in signatures or class attributes · no stub bodies (`pass`/`...`/`raise NotImplementedError`) outside abstract/Protocol · TypedDict `_encode_*`/`_decode_*` companions in the same module · no test-mode branching in production (use dependency injection) · no thin wrapper modules · Google-style docstrings on public functions with `Args:` matching the signature · boolean names prefixed `is_`/`has_`/`should_`/`can_`/`was_`/`did_` (assignments AND bool-typed parameters) · must-check returns (`find_and_click`, `write_outcome`) assigned and checked · known pytest fixture parameters in test files annotated with their single documented type (`tmp_path: Path`, `monkeypatch: pytest.MonkeyPatch`, `capsys`, `caplog`, `request`, …) · known pytest fixture parameters a test function declares but never references (drop the unused parameter — pytest still pays its setup cost) · JavaScript/TypeScript boolean declarations (`const`/`let`/`var` bound to a boolean literal or negation) and `@param {boolean}` JSDoc names prefixed `is`/`has`/`should`/`can`/`was`/`did` (camelCase forms) · banned identifiers as `.mjs`/`.js` declaration names (`result`, `data`, `ctx`, `msg`, …), scoped to changed lines · in test files, banned identifiers and unused module-level imports fire on changed lines, and pytest-collectable `test_*` functions need a return annotation · a `hooks/blocking/` command classifier anchors its multi-word command regex to the command start (`^`/`\A`) or tokenizes the first word (`shlex.split`), never matching a command as a bare substring
|
|
27
27
|
|
|
28
28
|
Test files are exempt from most checks. The one annotation the test-file exemption does NOT cover is a known pytest builtin fixture parameter: `tmp_path`, `monkeypatch`, `capsys`, `capfd`, `caplog`, `request`, and `tmp_path_factory` each have a single documented injected type, so the gate requires that annotation (`tmp_path: Path`) even inside a test file. The same set of fixtures is also subject to a use check: a pytest-collected test function that declares one of these parameters and never references it in its body fails the gate, because pytest materializes the fixture's setup (the temp directory, the monkeypatch context, the output capture) on every run whether or not the body reads the value — drop the unused parameter. A parameter counts as referenced when its name is read, augmented-assigned, or deleted anywhere in the body, including inside a nested function or comprehension. Only pytest-collectable functions are inspected — those at module top level or defined directly in a class body; a function nested inside another function's body is a local helper pytest never collects, so its fixture-named parameter is exempt. A `@pytest.fixture`-decorated function is exempt from the use check, since injecting one fixture into another purely to order its setup is intentional. Ordinary test parameters stay exempt from both checks. See also the file-global constants use-count rule: [`rules/file-global-constants.md`](../rules/file-global-constants.md).
|
|
29
29
|
|
|
@@ -94,4 +94,4 @@ If you already have the data, don't fetch it again.
|
|
|
94
94
|
|
|
95
95
|
## 11. ENFORCEMENT SURFACES
|
|
96
96
|
|
|
97
|
-
⚡ **Hooks** block pattern-matchable violations at Write/Edit time. 🤖 **Prompt context** carries judgment principles (SRP, Right-Sized Engineering, conservative-action, BDD discovery, docstring-prose-matches-implementation; the `/code` skill prepends strict mode for a session: no `Any`/`cast()`, immutable TypedDicts with `_encode_*`/`_decode_*` + `require_*` validation, per-module `_test_hooks.py` DI, 100% statement + branch coverage, zero mocks). 👥 **Audit rubrics** (`/check`, `packages/claude-dev-env/audit-rubrics/` categories A–
|
|
97
|
+
⚡ **Hooks** block pattern-matchable violations at Write/Edit time. 🤖 **Prompt context** carries judgment principles (SRP, Right-Sized Engineering, conservative-action, BDD discovery, docstring-prose-matches-implementation; the `/code` skill prepends strict mode for a session: no `Any`/`cast()`, immutable TypedDicts with `_encode_*`/`_decode_*` + `require_*` validation, per-module `_test_hooks.py` DI, 100% statement + branch coverage, zero mocks). 👥 **Audit rubrics** (`/check`, `packages/claude-dev-env/audit-rubrics/` categories A–Q) cover cross-file architectural concerns. Rules with documented-but-pending hook coverage live in `~/.claude/rules/*.md` and `skills/code/SKILL.md`; each names its own promotion path. The docstring-prose standard (free-form enumerations match the body) lives in `packages/claude-dev-env/rules/docstring-prose-matches-implementation.md`, enforced via Category O6 audit.
|
package/hooks/blocking/CLAUDE.md
CHANGED
|
@@ -23,6 +23,7 @@ The check modules it calls are the `code_rules_<concern>.py` files below.
|
|
|
23
23
|
| `code_rules_annotations_length.py` | Parameter/return annotations, function length, pytest fixture annotation requirements |
|
|
24
24
|
| `code_rules_banned_identifiers.py` | Banned short names (`ctx`, `cfg`, `msg`, etc.), banned prefixes (`handle_`, `process_`, etc.) |
|
|
25
25
|
| `code_rules_boolean_mustcheck.py` | Boolean naming (`is_`/`has_`/… prefixes) and must-check return values |
|
|
26
|
+
| `code_rules_command_dispatch.py` | A `hooks/blocking/` command classifier matching a multi-word command regex without a start anchor or first-word tokenization |
|
|
26
27
|
| `code_rules_comments.py` | No new inline comments; no deletion of existing ones |
|
|
27
28
|
| `code_rules_constants_config.py` | Constants must live in `config/`; file-global constant use-count |
|
|
28
29
|
| `code_rules_dead_argparse_argument.py` | Argparse arguments with no references in the same file |
|
|
@@ -33,6 +34,7 @@ The check modules it calls are the `code_rules_<concern>.py` files below.
|
|
|
33
34
|
| `code_rules_docstrings.py` | Google-style docstrings; `Args:` section matches signature; fallback-branch coverage |
|
|
34
35
|
| `code_rules_duplicate_body.py` | A function body copied from a sibling module, or a helper body inlined as a block inside a larger function in the same file |
|
|
35
36
|
| `code_rules_imports_logging.py` | Imports at top of file; logging format-arg style; printf tokens in `str.format`-logger messages |
|
|
37
|
+
| `code_rules_js_conventions.py` | Boolean-prefix naming and banned identifiers for JavaScript/TypeScript declarations and `@param {boolean}` JSDoc, scoped to changed lines |
|
|
36
38
|
| `code_rules_magic_values.py` | No magic numbers or strings in production code bodies |
|
|
37
39
|
| `code_rules_mock_completeness.py` | Mock calls that skip required arguments |
|
|
38
40
|
| `code_rules_naming_collection.py` | Collection names must use `all_*` prefix |
|
|
@@ -384,24 +384,72 @@ def check_unused_known_pytest_fixture_parameters(
|
|
|
384
384
|
return issues
|
|
385
385
|
|
|
386
386
|
|
|
387
|
-
def check_return_annotations(
|
|
388
|
-
|
|
389
|
-
|
|
387
|
+
def check_return_annotations(
|
|
388
|
+
content: str,
|
|
389
|
+
file_path: str,
|
|
390
|
+
all_changed_lines: set[int] | None = None,
|
|
391
|
+
defer_scope_to_caller: bool = False,
|
|
392
|
+
) -> list[str]:
|
|
393
|
+
"""Flag functions missing a return type annotation, scoped to changed lines.
|
|
394
|
+
|
|
395
|
+
In a production file every function definition needs a return annotation. In
|
|
396
|
+
a test file the requirement narrows to pytest-collectable test functions —
|
|
397
|
+
``test_*`` at module top level or in a class body — which typically declare
|
|
398
|
+
``-> None``; an ordinary helper in a test file stays exempt. Findings scope
|
|
399
|
+
to *all_changed_lines* so a pre-existing unannotated function on an untouched
|
|
400
|
+
line never blocks while a newly written one does.
|
|
401
|
+
|
|
402
|
+
Args:
|
|
403
|
+
content: The source text to inspect — the reconstructed full file on an
|
|
404
|
+
Edit so the parse succeeds.
|
|
405
|
+
file_path: The path the source will be written to, used for exemptions.
|
|
406
|
+
all_changed_lines: Post-edit line numbers the current edit touched, or
|
|
407
|
+
None to treat the whole file as in scope. When provided, a violation
|
|
408
|
+
blocks only when the function's definition line is among the changed
|
|
409
|
+
lines.
|
|
410
|
+
defer_scope_to_caller: When True, return every violation so the
|
|
411
|
+
commit/push gate scopes by added line through its ``Line N:``
|
|
412
|
+
partitioning.
|
|
413
|
+
|
|
414
|
+
Returns:
|
|
415
|
+
One issue per function missing a return type annotation, scoped to the
|
|
416
|
+
changed lines unless *defer_scope_to_caller* is True or *all_changed_lines*
|
|
417
|
+
is None.
|
|
418
|
+
"""
|
|
390
419
|
if is_workflow_registry_file(file_path) or is_migration_file(file_path):
|
|
391
420
|
return []
|
|
392
421
|
try:
|
|
393
422
|
tree = ast.parse(content)
|
|
394
423
|
except SyntaxError:
|
|
395
424
|
return []
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
425
|
+
if is_test_file(file_path):
|
|
426
|
+
candidate_functions: list[ast.FunctionDef | ast.AsyncFunctionDef] = [
|
|
427
|
+
each_node
|
|
428
|
+
for each_node in _collect_pytest_collectable_functions(tree)
|
|
429
|
+
if _is_pytest_test_function(each_node)
|
|
430
|
+
]
|
|
431
|
+
else:
|
|
432
|
+
candidate_functions = [
|
|
433
|
+
each_node
|
|
434
|
+
for each_node in ast.walk(tree)
|
|
435
|
+
if isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
436
|
+
]
|
|
437
|
+
all_violations_in_source_order: list[tuple[range, str]] = []
|
|
438
|
+
for each_node in candidate_functions:
|
|
439
|
+
if each_node.returns is not None:
|
|
399
440
|
continue
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
441
|
+
message = (
|
|
442
|
+
f"Line {each_node.lineno}: function {each_node.name!r} missing "
|
|
443
|
+
"return type annotation (CODE_RULES §6)"
|
|
444
|
+
)
|
|
445
|
+
all_violations_in_source_order.append(
|
|
446
|
+
(range(each_node.lineno, each_node.lineno + 1), message)
|
|
447
|
+
)
|
|
448
|
+
return _scope_violations_to_changed_lines(
|
|
449
|
+
all_violations_in_source_order,
|
|
450
|
+
all_changed_lines,
|
|
451
|
+
defer_scope_to_caller,
|
|
452
|
+
)
|
|
405
453
|
|
|
406
454
|
|
|
407
455
|
def check_function_length(
|
|
@@ -118,9 +118,39 @@ def _collect_banned_names_from_node(node: ast.AST) -> list[ast.Name]:
|
|
|
118
118
|
return []
|
|
119
119
|
|
|
120
120
|
|
|
121
|
-
def check_banned_identifiers(
|
|
122
|
-
|
|
123
|
-
|
|
121
|
+
def check_banned_identifiers(
|
|
122
|
+
content: str,
|
|
123
|
+
file_path: str,
|
|
124
|
+
all_changed_lines: set[int] | None = None,
|
|
125
|
+
defer_scope_to_caller: bool = False,
|
|
126
|
+
) -> list[str]:
|
|
127
|
+
"""Flag assignments to identifiers banned by the project Naming rules.
|
|
128
|
+
|
|
129
|
+
Test files are scoped rather than exempt: a banned identifier a test binds
|
|
130
|
+
is flagged only when its binding line is among the changed lines, so a
|
|
131
|
+
pre-existing ``result`` on an untouched line never blocks an edit while a
|
|
132
|
+
newly written one does. Production files scope the same way, and a new-file
|
|
133
|
+
or full-file write treats every line as changed. Hook infrastructure stays
|
|
134
|
+
exempt.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
content: The source text to inspect — the reconstructed full file on an
|
|
138
|
+
Edit so the parse succeeds and the diff scoping is meaningful.
|
|
139
|
+
file_path: The path the source will be written to, used for exemptions.
|
|
140
|
+
all_changed_lines: Post-edit line numbers the current edit touched, or
|
|
141
|
+
None to treat the whole file as in scope. When provided, a violation
|
|
142
|
+
blocks only when its binding line is among the changed lines.
|
|
143
|
+
defer_scope_to_caller: When True, return every violation so the
|
|
144
|
+
commit/push gate scopes by added line through its ``Line N:``
|
|
145
|
+
partitioning.
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
One issue per banned binding, scoped to the changed lines unless
|
|
149
|
+
*defer_scope_to_caller* is True or *all_changed_lines* is None. The
|
|
150
|
+
terminal result is capped at the module limit; the deferred result is
|
|
151
|
+
uncapped so the gate can scope by added line and apply its own ceiling.
|
|
152
|
+
"""
|
|
153
|
+
if is_hook_infrastructure(file_path):
|
|
124
154
|
return []
|
|
125
155
|
|
|
126
156
|
try:
|
|
@@ -135,15 +165,24 @@ def check_banned_identifiers(content: str, file_path: str) -> list[str]:
|
|
|
135
165
|
|
|
136
166
|
banned_name_nodes.sort(key=lambda each_name: (each_name.lineno, each_name.col_offset))
|
|
137
167
|
|
|
138
|
-
|
|
168
|
+
all_violations_in_source_order: list[tuple[range, str]] = []
|
|
139
169
|
for each_name in banned_name_nodes:
|
|
140
|
-
|
|
141
|
-
f"Line {each_name.lineno}: Banned identifier '{each_name.id}' -
|
|
170
|
+
message = (
|
|
171
|
+
f"Line {each_name.lineno}: Banned identifier '{each_name.id}' - "
|
|
172
|
+
f"{BANNED_IDENTIFIER_MESSAGE_SUFFIX}"
|
|
173
|
+
)
|
|
174
|
+
all_violations_in_source_order.append(
|
|
175
|
+
(range(each_name.lineno, each_name.lineno + 1), message)
|
|
142
176
|
)
|
|
143
|
-
if len(issues) >= MAX_BANNED_IDENTIFIER_ISSUES:
|
|
144
|
-
break
|
|
145
177
|
|
|
146
|
-
|
|
178
|
+
scoped_issues = _scope_violations_to_changed_lines(
|
|
179
|
+
all_violations_in_source_order,
|
|
180
|
+
all_changed_lines,
|
|
181
|
+
defer_scope_to_caller,
|
|
182
|
+
)
|
|
183
|
+
if defer_scope_to_caller:
|
|
184
|
+
return scoped_issues
|
|
185
|
+
return scoped_issues[:MAX_BANNED_IDENTIFIER_ISSUES]
|
|
147
186
|
|
|
148
187
|
|
|
149
188
|
def _identifier_word_parts(identifier: str) -> list[str]:
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Meta-gate flagging an unanchored multi-word command pattern in a blocker.
|
|
2
|
+
|
|
3
|
+
A hook that classifies a shell command reads the command text and matches it
|
|
4
|
+
against a regex. When that regex names a multi-word command such as
|
|
5
|
+
``gh\\s+pr\\s+(create|edit)`` and is matched with a bare ``re.search`` — no
|
|
6
|
+
start anchor and no first-word tokenization — the pattern matches the command
|
|
7
|
+
as a substring anywhere in the string. A benign command like
|
|
8
|
+
``echo gh pr create --title x`` then trips the gate. This check flags that shape
|
|
9
|
+
at write time on files under ``hooks/blocking/`` so a new blocker anchors its
|
|
10
|
+
command match to the start of the command or tokenizes the first word.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import ast
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
_blocking_directory = str(Path(__file__).resolve().parent)
|
|
18
|
+
_hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
19
|
+
if _blocking_directory not in sys.path:
|
|
20
|
+
sys.path.insert(0, _blocking_directory)
|
|
21
|
+
if _hooks_directory not in sys.path:
|
|
22
|
+
sys.path.insert(0, _hooks_directory)
|
|
23
|
+
|
|
24
|
+
from code_rules_shared import ( # noqa: E402
|
|
25
|
+
_scope_violations_to_changed_lines,
|
|
26
|
+
is_test_file,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
from hooks_constants.command_dispatch_constants import ( # noqa: E402
|
|
30
|
+
ALL_REGEX_START_ANCHOR_TOKENS,
|
|
31
|
+
COMMAND_DISPATCH_LITERAL_PATTERN,
|
|
32
|
+
COMMAND_DISPATCH_MESSAGE_SUFFIX,
|
|
33
|
+
COMMAND_DISPATCH_PATH_MARKER,
|
|
34
|
+
COMMAND_KEY_ACCESS_PATTERN,
|
|
35
|
+
FIRST_TOKEN_TOKENIZATION_PATTERN,
|
|
36
|
+
MAX_COMMAND_DISPATCH_ISSUES,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _is_under_hooks_blocking(file_path: str) -> bool:
|
|
41
|
+
"""Return whether the path sits under the ``hooks/blocking`` directory."""
|
|
42
|
+
return COMMAND_DISPATCH_PATH_MARKER in file_path.replace("\\", "/")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _command_literal_is_anchored(literal_value: str, match_start: int) -> bool:
|
|
46
|
+
"""Return whether a start anchor precedes the command word in the pattern.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
literal_value: The regex string literal the command word appears in.
|
|
50
|
+
match_start: The index where the command word match begins.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
True when a ``^`` or ``\\A`` anchor appears before the command word, so
|
|
54
|
+
the pattern binds the command to the start of the string.
|
|
55
|
+
"""
|
|
56
|
+
prefix = literal_value[:match_start]
|
|
57
|
+
return any(each_anchor in prefix for each_anchor in ALL_REGEX_START_ANCHOR_TOKENS)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _unanchored_command_literals(
|
|
61
|
+
parsed_tree: ast.AST,
|
|
62
|
+
) -> list[tuple[range, str]]:
|
|
63
|
+
"""Return one span-tagged violation per unanchored command-regex literal.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
parsed_tree: The parsed module to scan for string-constant nodes.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
``(line_range, message)`` pairs in source order.
|
|
70
|
+
"""
|
|
71
|
+
all_violations: list[tuple[range, str]] = []
|
|
72
|
+
for each_node in ast.walk(parsed_tree):
|
|
73
|
+
if not isinstance(each_node, ast.Constant) or not isinstance(
|
|
74
|
+
each_node.value, str
|
|
75
|
+
):
|
|
76
|
+
continue
|
|
77
|
+
literal_match = COMMAND_DISPATCH_LITERAL_PATTERN.search(each_node.value)
|
|
78
|
+
if literal_match is None:
|
|
79
|
+
continue
|
|
80
|
+
if _command_literal_is_anchored(each_node.value, literal_match.start()):
|
|
81
|
+
continue
|
|
82
|
+
end_line = each_node.end_lineno or each_node.lineno
|
|
83
|
+
message = (
|
|
84
|
+
f"Line {each_node.lineno}: command pattern {each_node.value!r} "
|
|
85
|
+
f"{COMMAND_DISPATCH_MESSAGE_SUFFIX}"
|
|
86
|
+
)
|
|
87
|
+
all_violations.append((range(each_node.lineno, end_line + 1), message))
|
|
88
|
+
return all_violations
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def check_unanchored_command_dispatch(
|
|
92
|
+
content: str,
|
|
93
|
+
file_path: str,
|
|
94
|
+
all_changed_lines: set[int] | None = None,
|
|
95
|
+
defer_scope_to_caller: bool = False,
|
|
96
|
+
) -> list[str]:
|
|
97
|
+
"""Flag an unanchored multi-word command regex in a hooks/blocking file.
|
|
98
|
+
|
|
99
|
+
The check fires only on a file under ``hooks/blocking/`` that reads a
|
|
100
|
+
``command`` key (a shell-command classifier) and does not tokenize the
|
|
101
|
+
command's first word (no ``shlex.split`` or ``.split(`` nearby). Under those
|
|
102
|
+
conditions, a regex string literal naming a known command word followed by
|
|
103
|
+
``\\s+`` without a leading ``^``/``\\A`` anchor is flagged. Findings scope to
|
|
104
|
+
*all_changed_lines* so a pre-existing pattern on an untouched line does not
|
|
105
|
+
block an edit while a newly written one does.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
content: The source text to inspect — the reconstructed full file on an
|
|
109
|
+
Edit so the parse succeeds.
|
|
110
|
+
file_path: The path the source will be written to, used for scoping to
|
|
111
|
+
``hooks/blocking`` and skipping test files.
|
|
112
|
+
all_changed_lines: Post-edit line numbers the current edit touched, or
|
|
113
|
+
None to treat the whole file as in scope.
|
|
114
|
+
defer_scope_to_caller: When True, return every violation so a downstream
|
|
115
|
+
scoper classifies by added line.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
One issue per unanchored command-regex literal, scoped to the changed
|
|
119
|
+
lines unless *defer_scope_to_caller* is True or *all_changed_lines* is
|
|
120
|
+
None, capped at the module limit.
|
|
121
|
+
"""
|
|
122
|
+
if is_test_file(file_path) or not _is_under_hooks_blocking(file_path):
|
|
123
|
+
return []
|
|
124
|
+
if COMMAND_KEY_ACCESS_PATTERN.search(content) is None:
|
|
125
|
+
return []
|
|
126
|
+
if FIRST_TOKEN_TOKENIZATION_PATTERN.search(content) is not None:
|
|
127
|
+
return []
|
|
128
|
+
try:
|
|
129
|
+
parsed_tree = ast.parse(content)
|
|
130
|
+
except SyntaxError:
|
|
131
|
+
return []
|
|
132
|
+
all_violations_in_source_order = _unanchored_command_literals(parsed_tree)
|
|
133
|
+
scoped_issues = _scope_violations_to_changed_lines(
|
|
134
|
+
all_violations_in_source_order,
|
|
135
|
+
all_changed_lines,
|
|
136
|
+
defer_scope_to_caller,
|
|
137
|
+
)
|
|
138
|
+
if defer_scope_to_caller:
|
|
139
|
+
return scoped_issues
|
|
140
|
+
return scoped_issues[:MAX_COMMAND_DISPATCH_ISSUES]
|
|
@@ -22,6 +22,7 @@ from code_rules_shared import ( # noqa: E402
|
|
|
22
22
|
)
|
|
23
23
|
|
|
24
24
|
from hooks_constants.blocking_check_limits import ( # noqa: E402
|
|
25
|
+
ALL_ABSENT_TYPE_CHECKING_GATE_DOCSTRING_PHRASES,
|
|
25
26
|
ALL_DATA_SCHEMA_CONSTANT_NAME_MARKERS,
|
|
26
27
|
ALL_DATA_SCHEMA_DOCSTRING_ACKNOWLEDGEMENT_PHRASES,
|
|
27
28
|
ALL_DOCSTRING_EXCLUSIVE_SCOPE_PHRASES,
|
|
@@ -74,6 +75,7 @@ from hooks_constants.blocking_check_limits import ( # noqa: E402
|
|
|
74
75
|
MAX_DOCSTRING_RUNON_SENTENCE_ISSUES,
|
|
75
76
|
MAX_DOCSTRING_STEP_DISPATCH_ISSUES,
|
|
76
77
|
MAX_DOCSTRING_TUPLE_ENUMERATION_ISSUES,
|
|
78
|
+
MAX_DOCSTRING_TYPE_CHECKING_GATE_ISSUES,
|
|
77
79
|
MAX_DOCSTRING_UNDEFINED_CONSTANT_ISSUES,
|
|
78
80
|
MAX_DOCSTRING_UNGUARDED_PAYLOAD_CLAIM_ISSUES,
|
|
79
81
|
MAX_LENGTH_CONSTANT_SUPERLATIVE_ISSUES,
|
|
@@ -90,6 +92,7 @@ from hooks_constants.blocking_check_limits import ( # noqa: E402
|
|
|
90
92
|
MODULE_DOCSTRING_DATA_SCHEMA_CONSTANT_SAMPLE_LIMIT,
|
|
91
93
|
PYTHON_MODULE_FILE_SUFFIX,
|
|
92
94
|
SINGLE_DICT_KEY_COUNT_FOR_PLURAL_CARDINALITY_DRIFT,
|
|
95
|
+
TYPE_CHECKING_IDENTIFIER_MARKER,
|
|
93
96
|
WORD_BOUNDARY_REGEX,
|
|
94
97
|
ZIPFILE_ALLOW_ZIP64_KEYWORD,
|
|
95
98
|
ZIPFILE_ALLOW_ZIP64_POSITIONAL_INDEX,
|
|
@@ -959,6 +962,96 @@ def check_docstring_no_network_claim_with_metadata_access(
|
|
|
959
962
|
return issues[:MAX_DOCSTRING_NO_NETWORK_CLAIM_ISSUES]
|
|
960
963
|
|
|
961
964
|
|
|
965
|
+
def _module_code_references_type_checking(parsed_tree: ast.Module) -> bool:
|
|
966
|
+
"""Return True when a code identifier in the module handles TYPE_CHECKING.
|
|
967
|
+
|
|
968
|
+
A module that names TYPE_CHECKING in code genuinely gates on it. That covers
|
|
969
|
+
a Name reference (any context — load, store, or delete), an import alias, an
|
|
970
|
+
attribute access, or a function or class definition whose name carries the
|
|
971
|
+
type_checking marker. Docstring text is an ast.Constant and never counts, so
|
|
972
|
+
the check can still flag a gate the prose names while the body performs none.
|
|
973
|
+
"""
|
|
974
|
+
for each_node in ast.walk(parsed_tree):
|
|
975
|
+
if isinstance(each_node, ast.Name) and (
|
|
976
|
+
TYPE_CHECKING_IDENTIFIER_MARKER in each_node.id.lower()
|
|
977
|
+
):
|
|
978
|
+
return True
|
|
979
|
+
if isinstance(each_node, ast.Attribute) and (
|
|
980
|
+
TYPE_CHECKING_IDENTIFIER_MARKER in each_node.attr.lower()
|
|
981
|
+
):
|
|
982
|
+
return True
|
|
983
|
+
if isinstance(each_node, ast.alias) and any(
|
|
984
|
+
each_alias_name and TYPE_CHECKING_IDENTIFIER_MARKER in each_alias_name.lower()
|
|
985
|
+
for each_alias_name in (each_node.name, each_node.asname)
|
|
986
|
+
):
|
|
987
|
+
return True
|
|
988
|
+
if isinstance(
|
|
989
|
+
each_node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
|
|
990
|
+
) and TYPE_CHECKING_IDENTIFIER_MARKER in each_node.name.lower():
|
|
991
|
+
return True
|
|
992
|
+
return False
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
def _docstring_names_absent_type_checking_gate(docstring_text: str) -> str:
|
|
996
|
+
collapsed_docstring = " ".join(docstring_text.replace("`", " ").lower().split())
|
|
997
|
+
for each_phrase in ALL_ABSENT_TYPE_CHECKING_GATE_DOCSTRING_PHRASES:
|
|
998
|
+
if each_phrase in collapsed_docstring:
|
|
999
|
+
return each_phrase
|
|
1000
|
+
return ""
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
def check_docstring_names_absent_type_checking_gate(
|
|
1004
|
+
content: str, file_path: str
|
|
1005
|
+
) -> list[str]:
|
|
1006
|
+
"""Flag a docstring naming a TYPE_CHECKING gate the module's code never runs.
|
|
1007
|
+
|
|
1008
|
+
Picture a hook whose docstring advertises a `TYPE_CHECKING` gate-detection
|
|
1009
|
+
step, or a module summary that names `type-checking-gate` helpers, while no
|
|
1010
|
+
identifier in the body handles TYPE_CHECKING. A reader trusts the prose and
|
|
1011
|
+
looks for the gate, but the code performs none, so the claim points at
|
|
1012
|
+
machinery the module does not hold.
|
|
1013
|
+
|
|
1014
|
+
This is the deterministic slice of Category O6 docstring-prose-vs-
|
|
1015
|
+
implementation drift for a TYPE_CHECKING gate claim. The check reads the
|
|
1016
|
+
module docstring and every function and class docstring. It fires only when
|
|
1017
|
+
a docstring names a `TYPE_CHECKING` gate or a `type-checking-gate` helper
|
|
1018
|
+
family while no code identifier in the module carries the `type_checking`
|
|
1019
|
+
marker, so a module that genuinely gates on TYPE_CHECKING is left alone.
|
|
1020
|
+
Hook infrastructure is in scope, since the import-scan hooks that carry this
|
|
1021
|
+
drift class are themselves hooks. Test files are exempt.
|
|
1022
|
+
|
|
1023
|
+
Args:
|
|
1024
|
+
content: The source text to inspect.
|
|
1025
|
+
file_path: The path the source will be written to, used for exemptions.
|
|
1026
|
+
|
|
1027
|
+
Returns:
|
|
1028
|
+
One issue per docstring naming a TYPE_CHECKING gate the code never
|
|
1029
|
+
performs, capped at the module limit.
|
|
1030
|
+
"""
|
|
1031
|
+
if is_strict_test_file(file_path):
|
|
1032
|
+
return []
|
|
1033
|
+
try:
|
|
1034
|
+
parsed_tree = ast.parse(content)
|
|
1035
|
+
except SyntaxError:
|
|
1036
|
+
return []
|
|
1037
|
+
if _module_code_references_type_checking(parsed_tree):
|
|
1038
|
+
return []
|
|
1039
|
+
issues: list[str] = []
|
|
1040
|
+
for each_line_number, each_docstring in _documentable_nodes_with_docstrings(parsed_tree):
|
|
1041
|
+
matched_phrase = _docstring_names_absent_type_checking_gate(each_docstring)
|
|
1042
|
+
if not matched_phrase:
|
|
1043
|
+
continue
|
|
1044
|
+
issues.append(
|
|
1045
|
+
f"Line {each_line_number}: docstring names a '{matched_phrase}' the module's "
|
|
1046
|
+
"code never performs — no identifier in the body handles TYPE_CHECKING, so the "
|
|
1047
|
+
"gate-detection claim is stale; drop the TYPE_CHECKING gate wording or add the "
|
|
1048
|
+
"detection (Category O6 docstring-vs-implementation drift)"
|
|
1049
|
+
)
|
|
1050
|
+
if len(issues) >= MAX_DOCSTRING_TYPE_CHECKING_GATE_ISSUES:
|
|
1051
|
+
break
|
|
1052
|
+
return issues[:MAX_DOCSTRING_TYPE_CHECKING_GATE_ISSUES]
|
|
1053
|
+
|
|
1054
|
+
|
|
962
1055
|
def _module_docstring_claims_no_inline_literal(module_docstring: str) -> str:
|
|
963
1056
|
collapsed_docstring = " ".join(module_docstring.lower().split())
|
|
964
1057
|
for each_phrase in ALL_DOCSTRING_NO_INLINE_LITERAL_CLAIM_PHRASES:
|
|
@@ -44,6 +44,9 @@ from code_rules_boolean_mustcheck import ( # noqa: E402
|
|
|
44
44
|
check_boolean_naming,
|
|
45
45
|
check_ignored_must_check_return,
|
|
46
46
|
)
|
|
47
|
+
from code_rules_command_dispatch import ( # noqa: E402
|
|
48
|
+
check_unanchored_command_dispatch,
|
|
49
|
+
)
|
|
47
50
|
from code_rules_comments import ( # noqa: E402
|
|
48
51
|
check_comment_changes,
|
|
49
52
|
)
|
|
@@ -77,6 +80,7 @@ from code_rules_docstrings import ( # noqa: E402
|
|
|
77
80
|
check_docstring_field_runmode_outcome,
|
|
78
81
|
check_docstring_format,
|
|
79
82
|
check_docstring_length_constant_superlative_vs_exact_gate,
|
|
83
|
+
check_docstring_names_absent_type_checking_gate,
|
|
80
84
|
check_docstring_names_undefined_constant,
|
|
81
85
|
check_docstring_no_consumer_claim,
|
|
82
86
|
check_docstring_no_inline_literal_claim,
|
|
@@ -102,11 +106,16 @@ from code_rules_imports_logging import ( # noqa: E402
|
|
|
102
106
|
check_import_block_sorted,
|
|
103
107
|
check_imports_at_top,
|
|
104
108
|
check_js_resume_task_enumeration_coverage,
|
|
109
|
+
check_js_returns_object_schemaless_branch,
|
|
105
110
|
check_library_print,
|
|
106
111
|
check_logging_fstrings,
|
|
107
112
|
check_logging_printf_tokens,
|
|
108
113
|
check_windows_api_none,
|
|
109
114
|
)
|
|
115
|
+
from code_rules_js_conventions import ( # noqa: E402
|
|
116
|
+
check_js_banned_identifiers,
|
|
117
|
+
check_js_boolean_naming,
|
|
118
|
+
)
|
|
110
119
|
from code_rules_magic_values import ( # noqa: E402
|
|
111
120
|
check_fstring_structural_literals,
|
|
112
121
|
check_magic_values,
|
|
@@ -283,7 +292,14 @@ def validate_content(
|
|
|
283
292
|
)
|
|
284
293
|
)
|
|
285
294
|
all_issues.extend(check_type_escape_hatches(effective_content, file_path))
|
|
286
|
-
all_issues.extend(
|
|
295
|
+
all_issues.extend(
|
|
296
|
+
check_banned_identifiers(
|
|
297
|
+
effective_content,
|
|
298
|
+
file_path,
|
|
299
|
+
all_changed_lines,
|
|
300
|
+
defer_scope_to_caller,
|
|
301
|
+
)
|
|
302
|
+
)
|
|
287
303
|
all_issues.extend(
|
|
288
304
|
check_banned_noun_word_boundary(
|
|
289
305
|
effective_content,
|
|
@@ -367,6 +383,9 @@ def validate_content(
|
|
|
367
383
|
all_issues.extend(
|
|
368
384
|
check_docstring_names_undefined_constant(effective_content, file_path)
|
|
369
385
|
)
|
|
386
|
+
all_issues.extend(
|
|
387
|
+
check_docstring_names_absent_type_checking_gate(effective_content, file_path)
|
|
388
|
+
)
|
|
370
389
|
all_issues.extend(
|
|
371
390
|
check_docstring_args_single_line_scope_vs_span(effective_content, file_path)
|
|
372
391
|
)
|
|
@@ -427,7 +446,14 @@ def validate_content(
|
|
|
427
446
|
all_issues.extend(
|
|
428
447
|
check_unused_known_pytest_fixture_parameters(content, file_path)
|
|
429
448
|
)
|
|
430
|
-
all_issues.extend(
|
|
449
|
+
all_issues.extend(
|
|
450
|
+
check_return_annotations(
|
|
451
|
+
effective_content,
|
|
452
|
+
file_path,
|
|
453
|
+
all_changed_lines,
|
|
454
|
+
defer_scope_to_caller,
|
|
455
|
+
)
|
|
456
|
+
)
|
|
431
457
|
all_issues.extend(
|
|
432
458
|
check_function_length(
|
|
433
459
|
effective_content,
|
|
@@ -465,9 +491,28 @@ def validate_content(
|
|
|
465
491
|
if not is_test_file(file_path):
|
|
466
492
|
all_issues.extend(check_comment_changes(old_content, content, file_path))
|
|
467
493
|
all_issues.extend(check_e2e_test_naming(content, file_path))
|
|
494
|
+
all_issues.extend(
|
|
495
|
+
check_js_boolean_naming(
|
|
496
|
+
effective_content,
|
|
497
|
+
file_path,
|
|
498
|
+
all_changed_lines,
|
|
499
|
+
defer_scope_to_caller,
|
|
500
|
+
)
|
|
501
|
+
)
|
|
502
|
+
all_issues.extend(
|
|
503
|
+
check_js_banned_identifiers(
|
|
504
|
+
effective_content,
|
|
505
|
+
file_path,
|
|
506
|
+
all_changed_lines,
|
|
507
|
+
defer_scope_to_caller,
|
|
508
|
+
)
|
|
509
|
+
)
|
|
468
510
|
all_issues.extend(
|
|
469
511
|
check_js_resume_task_enumeration_coverage(content, file_path)
|
|
470
512
|
)
|
|
513
|
+
all_issues.extend(
|
|
514
|
+
check_js_returns_object_schemaless_branch(content, file_path)
|
|
515
|
+
)
|
|
471
516
|
|
|
472
517
|
if extension in ALL_CODE_EXTENSIONS:
|
|
473
518
|
advise_file_line_count(content, file_path)
|
|
@@ -575,8 +620,10 @@ def _hook_infrastructure_blocking_issues(
|
|
|
575
620
|
runs the checks that must still guard them: the cross-file duplicate-body
|
|
576
621
|
check and the same-file inline-duplicate-body check, each span-scoped to the
|
|
577
622
|
lines an edit touched exactly as ``validate_content`` scopes it for production
|
|
578
|
-
code;
|
|
579
|
-
its motivating case, run over the whole post-edit file
|
|
623
|
+
code; the zero-payload alias check, whose docstring names hook modules as
|
|
624
|
+
its motivating case, run over the whole post-edit file; and the
|
|
625
|
+
unanchored command-dispatch check, which guards a ``hooks/blocking``
|
|
626
|
+
command classifier against matching a multi-word command as a substring.
|
|
580
627
|
|
|
581
628
|
Args:
|
|
582
629
|
content: The fragment or whole-file body under validation.
|
|
@@ -609,6 +656,13 @@ def _hook_infrastructure_blocking_issues(
|
|
|
609
656
|
)
|
|
610
657
|
)
|
|
611
658
|
all_issues.extend(check_zero_payload_function_alias(effective_content, file_path))
|
|
659
|
+
all_issues.extend(
|
|
660
|
+
check_unanchored_command_dispatch(
|
|
661
|
+
effective_content,
|
|
662
|
+
file_path,
|
|
663
|
+
all_changed_lines,
|
|
664
|
+
)
|
|
665
|
+
)
|
|
612
666
|
return all_issues
|
|
613
667
|
|
|
614
668
|
|