claude-dev-env 1.81.0 → 1.83.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +16 -13
- package/_shared/pr-loop/scripts/CLAUDE.md +1 -0
- package/_shared/pr-loop/scripts/README.md +1 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviewer_availability_constants.py +12 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +0 -2
- package/_shared/pr-loop/scripts/reviewer_availability.py +182 -0
- package/_shared/pr-loop/scripts/terminology_sweep.py +9 -33
- package/_shared/pr-loop/scripts/tests/CLAUDE.md +2 -0
- package/_shared/pr-loop/scripts/tests/test_reviewer_availability.py +159 -0
- package/_shared/pr-loop/scripts/tests/test_reviewer_availability_constants.py +36 -0
- package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +14 -4
- package/hooks/blocking/CLAUDE.md +3 -0
- package/hooks/blocking/code_rules_constants_config.py +159 -1
- package/hooks/blocking/code_rules_docstrings.py +312 -9
- package/hooks/blocking/code_rules_enforcer.py +29 -0
- package/hooks/blocking/code_rules_imports_logging.py +867 -1
- package/hooks/blocking/code_rules_naming_collection.py +141 -0
- package/hooks/blocking/code_rules_string_magic.py +68 -0
- package/hooks/blocking/conventional_pr_title_gate.py +444 -0
- package/hooks/blocking/pre_tool_use_dispatcher.py +3 -3
- package/hooks/blocking/reviewer_spawn_gate.py +182 -0
- package/hooks/blocking/stale_comment_reference_blocker.py +267 -0
- package/hooks/blocking/state_description_blocker.py +96 -5
- package/hooks/blocking/test_code_rules_config_duplicate_path_anchor.py +132 -0
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_delegation_summary.py +385 -0
- package/hooks/blocking/test_code_rules_enforcer_join_separator_magic.py +67 -0
- package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +40 -0
- package/hooks/blocking/test_code_rules_enforcer_naive_datetime.py +213 -0
- package/hooks/blocking/test_code_rules_enforcer_referenced_underscore_loop.py +169 -0
- package/hooks/blocking/test_code_rules_js_bare_flag_return_directive.py +266 -0
- package/hooks/blocking/test_code_rules_js_sibling_return_object_key_drift.py +490 -0
- package/hooks/blocking/test_code_rules_logging_adjacent_literals.py +171 -0
- package/hooks/blocking/test_conventional_pr_title_gate.py +640 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +9 -3
- package/hooks/blocking/test_reviewer_spawn_gate.py +230 -0
- package/hooks/blocking/test_stale_comment_reference_blocker.py +236 -0
- package/hooks/blocking/test_state_description_blocker.py +135 -0
- package/hooks/hooks.json +10 -0
- package/hooks/hooks_constants/CLAUDE.md +4 -1
- package/hooks/hooks_constants/blocking_check_limits.py +43 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +41 -0
- package/hooks/hooks_constants/conventional_pr_title_gate_constants.py +58 -0
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
- package/hooks/hooks_constants/reviewer_spawn_gate_constants.py +41 -0
- package/hooks/hooks_constants/stale_comment_reference_blocker_constants.py +76 -0
- package/hooks/hooks_constants/state_description_blocker_constants.py +8 -0
- package/package.json +1 -1
- package/rules/CLAUDE.md +4 -1
- package/rules/claude-md-orphan-file.md +5 -0
- package/rules/docstring-prose-matches-implementation.md +10 -1
- package/rules/env-var-table-code-drift.md +5 -0
- package/rules/es-exe-file-search.md +17 -0
- package/rules/no-historical-clutter.md +12 -1
- package/rules/orphan-css-class.md +5 -0
- package/rules/package-inventory-stale-entry.md +10 -0
- package/rules/paired-test-coverage.md +5 -0
- package/rules/plain-illustrative-docstrings.md +5 -0
- package/rules/verify-before-asking.md +7 -0
- package/rules/verify-runtime-state.md +40 -0
- package/rules/windows-filesystem-safe.md +8 -0
- package/rules/workers-done-before-complete.md +33 -0
- package/rules/workflow-substitution-slots.md +5 -0
- package/skills/CLAUDE.md +1 -1
- package/skills/autoconverge/SKILL.md +96 -5
- package/skills/autoconverge/reference/gotchas.md +5 -5
- package/skills/autoconverge/workflow/converge.contract.test.mjs +228 -27
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +154 -21
- package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +2 -2
- package/skills/autoconverge/workflow/converge.mjs +153 -49
- package/skills/autoconverge/workflow/converge_multi.mjs +6 -2
- package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +26 -0
- package/skills/everything-search/SKILL.md +5 -0
|
@@ -271,6 +271,147 @@ def check_loop_variable_naming(content: str, file_path: str) -> list[str]:
|
|
|
271
271
|
return issues
|
|
272
272
|
|
|
273
273
|
|
|
274
|
+
def _comprehension_rebinds_name(comprehension_node: ast.expr, target_name: str) -> bool:
|
|
275
|
+
"""True when any generator of the comprehension binds its own target_name-named variable."""
|
|
276
|
+
generators = getattr(comprehension_node, "generators", [])
|
|
277
|
+
for each_generator in generators:
|
|
278
|
+
for each_name_node in _walk_assignment_targets(each_generator.target):
|
|
279
|
+
if each_name_node.id == target_name:
|
|
280
|
+
return True
|
|
281
|
+
return False
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _function_binds_name(
|
|
285
|
+
function_node: ast.Lambda | ast.FunctionDef | ast.AsyncFunctionDef, target_name: str
|
|
286
|
+
) -> bool:
|
|
287
|
+
"""True when a lambda or nested def has a parameter named target_name."""
|
|
288
|
+
argument_specification = function_node.args
|
|
289
|
+
all_argument_nodes = [
|
|
290
|
+
*argument_specification.posonlyargs,
|
|
291
|
+
*argument_specification.args,
|
|
292
|
+
*argument_specification.kwonlyargs,
|
|
293
|
+
]
|
|
294
|
+
if argument_specification.vararg is not None:
|
|
295
|
+
all_argument_nodes.append(argument_specification.vararg)
|
|
296
|
+
if argument_specification.kwarg is not None:
|
|
297
|
+
all_argument_nodes.append(argument_specification.kwarg)
|
|
298
|
+
return any(each_argument.arg == target_name for each_argument in all_argument_nodes)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _function_enclosing_scope_expressions(
|
|
302
|
+
function_node: ast.Lambda | ast.FunctionDef | ast.AsyncFunctionDef,
|
|
303
|
+
) -> list[ast.expr]:
|
|
304
|
+
"""Return the sub-expressions a lambda or def evaluates in its enclosing scope.
|
|
305
|
+
|
|
306
|
+
Parameter defaults and decorators run before the parameters bind, so a Load of
|
|
307
|
+
the outer name there still reads the outer name; the body never does once a
|
|
308
|
+
parameter shadows it.
|
|
309
|
+
"""
|
|
310
|
+
argument_specification = function_node.args
|
|
311
|
+
enclosing_expressions: list[ast.expr] = [*argument_specification.defaults]
|
|
312
|
+
enclosing_expressions.extend(
|
|
313
|
+
each_default
|
|
314
|
+
for each_default in argument_specification.kw_defaults
|
|
315
|
+
if each_default is not None
|
|
316
|
+
)
|
|
317
|
+
if isinstance(function_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
318
|
+
enclosing_expressions.extend(function_node.decorator_list)
|
|
319
|
+
return enclosing_expressions
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _loop_rebinds_name(loop_node: ast.For | ast.AsyncFor, target_name: str) -> bool:
|
|
323
|
+
"""True when the loop's own target rebinds target_name."""
|
|
324
|
+
return any(
|
|
325
|
+
each_name_node.id == target_name
|
|
326
|
+
for each_name_node in _walk_assignment_targets(loop_node.target)
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _node_reads_name(node: ast.AST, target_name: str) -> bool:
|
|
331
|
+
"""True when node contains a genuine Load of target_name, honoring scope shadowing.
|
|
332
|
+
|
|
333
|
+
A comprehension, a lambda, a nested def, or a nested loop that rebinds
|
|
334
|
+
target_name owns that name inside its body, so a Load there reads the inner
|
|
335
|
+
variable, not the outer loop variable. Only the parts each construct evaluates
|
|
336
|
+
in the enclosing scope can still read the outer name: a comprehension's or
|
|
337
|
+
nested loop's iterable, and a function's parameter defaults and decorators.
|
|
338
|
+
"""
|
|
339
|
+
comprehension_node_types = (ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp)
|
|
340
|
+
if isinstance(node, comprehension_node_types) and _comprehension_rebinds_name(
|
|
341
|
+
node, target_name
|
|
342
|
+
):
|
|
343
|
+
outermost_iterable = node.generators[0].iter
|
|
344
|
+
return _node_reads_name(outermost_iterable, target_name)
|
|
345
|
+
if isinstance(node, (ast.Lambda, ast.FunctionDef, ast.AsyncFunctionDef)) and _function_binds_name(
|
|
346
|
+
node, target_name
|
|
347
|
+
):
|
|
348
|
+
return any(
|
|
349
|
+
_node_reads_name(each_expression, target_name)
|
|
350
|
+
for each_expression in _function_enclosing_scope_expressions(node)
|
|
351
|
+
)
|
|
352
|
+
if isinstance(node, (ast.For, ast.AsyncFor)) and _loop_rebinds_name(node, target_name):
|
|
353
|
+
return _node_reads_name(node.iter, target_name)
|
|
354
|
+
if isinstance(node, ast.Name) and node.id == target_name and isinstance(node.ctx, ast.Load):
|
|
355
|
+
return True
|
|
356
|
+
for each_child_node in ast.iter_child_nodes(node):
|
|
357
|
+
if _node_reads_name(each_child_node, target_name):
|
|
358
|
+
return True
|
|
359
|
+
return False
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _loop_body_references_name(loop_node: ast.For | ast.AsyncFor, target_name: str) -> bool:
|
|
363
|
+
for each_statement in [*loop_node.body, *loop_node.orelse]:
|
|
364
|
+
if _node_reads_name(each_statement, target_name):
|
|
365
|
+
return True
|
|
366
|
+
return False
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def check_referenced_underscore_loop_variable(content: str, file_path: str) -> list[str]:
|
|
370
|
+
"""Flag a leading-underscore loop variable the loop body reads.
|
|
371
|
+
|
|
372
|
+
A leading underscore marks a name as a throwaway the reader can ignore, so
|
|
373
|
+
``for _foreign_module_name in names:`` promises the body never touches it.
|
|
374
|
+
When the body then reads ``del sys.modules[_foreign_module_name]``, the
|
|
375
|
+
marker lies: the value carries meaning and earns a real name. This check
|
|
376
|
+
fires in test files too — a conftest that reuses an underscore-marked loop
|
|
377
|
+
variable is the exact shape this closes — because the misleading marker is a
|
|
378
|
+
naming smell everywhere, not a convention the test-file exemption waives.
|
|
379
|
+
Workflow-registry and migration files are exempt. A bare ``_`` target and a
|
|
380
|
+
genuinely unread ``_unused`` throwaway both pass.
|
|
381
|
+
|
|
382
|
+
Args:
|
|
383
|
+
content: The source text to inspect.
|
|
384
|
+
file_path: The path the source will be written to, used for exemptions.
|
|
385
|
+
|
|
386
|
+
Returns:
|
|
387
|
+
One issue per leading-underscore loop variable that its loop body reads.
|
|
388
|
+
"""
|
|
389
|
+
if is_workflow_registry_file(file_path) or is_migration_file(file_path):
|
|
390
|
+
return []
|
|
391
|
+
try:
|
|
392
|
+
tree = ast.parse(content)
|
|
393
|
+
except SyntaxError:
|
|
394
|
+
return []
|
|
395
|
+
issues: list[str] = []
|
|
396
|
+
for each_node in ast.walk(tree):
|
|
397
|
+
if not isinstance(each_node, (ast.For, ast.AsyncFor)):
|
|
398
|
+
continue
|
|
399
|
+
for each_name_node in _collect_target_names(each_node.target):
|
|
400
|
+
target_name = each_name_node.id
|
|
401
|
+
if not target_name.startswith("_"):
|
|
402
|
+
continue
|
|
403
|
+
if target_name.strip("_") == "":
|
|
404
|
+
continue
|
|
405
|
+
if not _loop_body_references_name(each_node, target_name):
|
|
406
|
+
continue
|
|
407
|
+
issues.append(
|
|
408
|
+
f"Line {each_name_node.lineno}: loop variable {target_name!r} is read"
|
|
409
|
+
f" in its body - drop the leading underscore throwaway marker and"
|
|
410
|
+
f" give it a real name (CODE_RULES §5)"
|
|
411
|
+
)
|
|
412
|
+
return issues
|
|
413
|
+
|
|
414
|
+
|
|
274
415
|
def _name_carries_token(name: str, token: str) -> bool:
|
|
275
416
|
"""True when name carries token as a whole underscore-delimited word."""
|
|
276
417
|
return re.search(POLARITY_TOKEN_BOUNDARY_PATTERN % re.escape(token), name) is not None
|
|
@@ -123,6 +123,74 @@ def check_string_literal_magic(content: str, file_path: str) -> list[str]:
|
|
|
123
123
|
return issues
|
|
124
124
|
|
|
125
125
|
|
|
126
|
+
def check_join_separator_string_magic(content: str, file_path: str) -> list[str]:
|
|
127
|
+
"""Flag a bare string-literal separator passed to ``str.join`` in a body.
|
|
128
|
+
|
|
129
|
+
A call like ``", ".join(all_paths)`` hard-codes the delimiter that stitches
|
|
130
|
+
a list into one string. That delimiter is a magic value: change the wording
|
|
131
|
+
once and every reader has to grep the whole file for the stray literal. A
|
|
132
|
+
named constant in ``config/`` gives the delimiter one home. The empty
|
|
133
|
+
separator ``"".join(...)`` is left alone because it carries no delimiter —
|
|
134
|
+
it is the idiomatic way to concatenate parts. Config files, test files,
|
|
135
|
+
workflow-registry files, and migration files are exempt.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
content: The source text to inspect.
|
|
139
|
+
file_path: The path the source will be written to, used for exemptions.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
One issue per non-empty string-literal ``.join`` separator found in a
|
|
143
|
+
function body.
|
|
144
|
+
"""
|
|
145
|
+
if is_test_file(file_path):
|
|
146
|
+
return []
|
|
147
|
+
if is_config_file(file_path):
|
|
148
|
+
return []
|
|
149
|
+
if is_workflow_registry_file(file_path) or is_migration_file(file_path):
|
|
150
|
+
return []
|
|
151
|
+
try:
|
|
152
|
+
tree = ast.parse(content)
|
|
153
|
+
except SyntaxError:
|
|
154
|
+
return []
|
|
155
|
+
issues: list[str] = []
|
|
156
|
+
flagged_node_ids: set[int] = set()
|
|
157
|
+
for each_function_node in ast.walk(tree):
|
|
158
|
+
if not isinstance(each_function_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
159
|
+
continue
|
|
160
|
+
for each_body_statement in each_function_node.body:
|
|
161
|
+
for each_descendant in _walk_skipping_nested_function_defs(each_body_statement):
|
|
162
|
+
if not isinstance(each_descendant, ast.Call):
|
|
163
|
+
continue
|
|
164
|
+
separator_literal = _join_call_string_separator(each_descendant)
|
|
165
|
+
if separator_literal is None:
|
|
166
|
+
continue
|
|
167
|
+
if id(each_descendant) in flagged_node_ids:
|
|
168
|
+
continue
|
|
169
|
+
flagged_node_ids.add(id(each_descendant))
|
|
170
|
+
issues.append(
|
|
171
|
+
f"Line {each_descendant.lineno}: string separator"
|
|
172
|
+
f" {separator_literal!r} passed to .join(...)"
|
|
173
|
+
f" - extract to a named constant in config/"
|
|
174
|
+
)
|
|
175
|
+
return issues
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _join_call_string_separator(call_node: ast.Call) -> str | None:
|
|
179
|
+
called = call_node.func
|
|
180
|
+
if not isinstance(called, ast.Attribute):
|
|
181
|
+
return None
|
|
182
|
+
if called.attr != "join":
|
|
183
|
+
return None
|
|
184
|
+
separator_node = called.value
|
|
185
|
+
if not isinstance(separator_node, ast.Constant):
|
|
186
|
+
return None
|
|
187
|
+
if not isinstance(separator_node.value, str):
|
|
188
|
+
return None
|
|
189
|
+
if separator_node.value == "":
|
|
190
|
+
return None
|
|
191
|
+
return separator_node.value
|
|
192
|
+
|
|
193
|
+
|
|
126
194
|
def check_inline_literal_collections(content: str, file_path: str) -> list[str]:
|
|
127
195
|
if is_test_file(file_path):
|
|
128
196
|
return []
|
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse hook: block a non-conventional gh pr create/edit --title in a repo whose CI enforces it.
|
|
3
|
+
|
|
4
|
+
Autoconverge's standards-deferral path opens draft environment-hardening PRs
|
|
5
|
+
across many repos. A repo whose CI runs a semantic-pull-request check (the
|
|
6
|
+
amannn/action-semantic-pull-request GitHub Action or an equivalent) rejects a
|
|
7
|
+
PR whose title is not a Conventional Commit, and that failure only surfaces
|
|
8
|
+
after the PR already exists. This hook blocks the malformed `gh pr create` or
|
|
9
|
+
`gh pr edit` before the PR is opened, so the title gets fixed first.
|
|
10
|
+
|
|
11
|
+
Detection strategy: match `gh pr create`/`gh pr edit` on the command's logical
|
|
12
|
+
first line (after joining bash/PowerShell continuations), extract the
|
|
13
|
+
--title/-t value with a small shlex-based token scan, then resolve the git
|
|
14
|
+
repo root from the tool call's cwd and scan `.github/workflows/*.yml(.yaml)`
|
|
15
|
+
for a semantic-pull-request marker string. The gate only fires when a marker
|
|
16
|
+
workflow leaves the action's `types:` input at its default Conventional
|
|
17
|
+
Commits list; every other case -- an unparseable command, a missing title, a
|
|
18
|
+
title that is an unresolvable shell variable (a `$`-prefixed value this hook
|
|
19
|
+
cannot resolve), a --repo flag pointing at a repo this hook cannot inspect on
|
|
20
|
+
disk, a directory that resolves to no repo root, a repo with no matching
|
|
21
|
+
workflow, or a marker workflow whose semantic-pull-request action step declares
|
|
22
|
+
a custom `types:` input (so this hook cannot know the repo's allowed types) --
|
|
23
|
+
fails OPEN (allow), since the authoritative CI check still runs on GitHub.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import shlex
|
|
28
|
+
import sys
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
_blocking_dir = str(Path(__file__).resolve().parent)
|
|
32
|
+
if _blocking_dir not in sys.path:
|
|
33
|
+
sys.path.insert(0, _blocking_dir)
|
|
34
|
+
|
|
35
|
+
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
36
|
+
if _hooks_dir not in sys.path:
|
|
37
|
+
sys.path.insert(0, _hooks_dir)
|
|
38
|
+
|
|
39
|
+
from verification_verdict_store import resolve_repo_root # noqa: E402
|
|
40
|
+
|
|
41
|
+
from blocking._gh_body_arg_utils import ( # noqa: E402
|
|
42
|
+
all_value_flags,
|
|
43
|
+
count_extra_tokens_to_skip_for_split_quoted_value,
|
|
44
|
+
get_logical_first_line,
|
|
45
|
+
is_flag_shaped_token,
|
|
46
|
+
is_unresolvable_shell_value,
|
|
47
|
+
iter_significant_tokens,
|
|
48
|
+
match_non_body_value_flag_equals_prefix,
|
|
49
|
+
strip_surrounding_quotes,
|
|
50
|
+
)
|
|
51
|
+
from hooks_constants.conventional_pr_title_gate_constants import ( # noqa: E402
|
|
52
|
+
ALL_GH_EXECUTABLE_BASENAMES,
|
|
53
|
+
ALL_PR_TITLE_SUBCOMMAND_VERBS,
|
|
54
|
+
ALL_SEMANTIC_TITLE_CI_MARKERS,
|
|
55
|
+
ALL_WORKFLOW_FILE_GLOB_PATTERNS,
|
|
56
|
+
BASH_TOOL_NAME,
|
|
57
|
+
CONVENTIONAL_COMMIT_TITLE_PATTERN,
|
|
58
|
+
CORRECTIVE_MESSAGE,
|
|
59
|
+
GH_PR_SUBCOMMAND_MINIMUM_TOKEN_COUNT,
|
|
60
|
+
PR_SUBCOMMAND_TOKEN,
|
|
61
|
+
REPO_LONG_FLAG,
|
|
62
|
+
REPO_SHORT_FLAG,
|
|
63
|
+
SEMANTIC_ACTION_FLOW_TYPES_INPUT_PATTERN,
|
|
64
|
+
SEMANTIC_ACTION_TYPES_INPUT_PATTERN,
|
|
65
|
+
TITLE_LONG_FLAG,
|
|
66
|
+
TITLE_SHORT_FLAG,
|
|
67
|
+
WORKFLOWS_DIRECTORY_RELATIVE_PATH,
|
|
68
|
+
YAML_LIST_ITEM_PREFIX,
|
|
69
|
+
)
|
|
70
|
+
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _matches_gh_pr_title_subcommand(command: str) -> bool:
|
|
74
|
+
all_tokens = _parsed_command_tokens(command)
|
|
75
|
+
if all_tokens is None or len(all_tokens) < GH_PR_SUBCOMMAND_MINIMUM_TOKEN_COUNT:
|
|
76
|
+
return False
|
|
77
|
+
executable_basename = Path(strip_surrounding_quotes(all_tokens[0])).name
|
|
78
|
+
if executable_basename not in ALL_GH_EXECUTABLE_BASENAMES:
|
|
79
|
+
return False
|
|
80
|
+
if strip_surrounding_quotes(all_tokens[1]) != PR_SUBCOMMAND_TOKEN:
|
|
81
|
+
return False
|
|
82
|
+
return strip_surrounding_quotes(all_tokens[2]) in ALL_PR_TITLE_SUBCOMMAND_VERBS
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _parsed_command_tokens(command: str) -> list[str] | None:
|
|
86
|
+
logical_line = get_logical_first_line(command)
|
|
87
|
+
if not logical_line:
|
|
88
|
+
return None
|
|
89
|
+
try:
|
|
90
|
+
return shlex.split(logical_line, posix=False)
|
|
91
|
+
except ValueError:
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _joined_equals_form_value(
|
|
96
|
+
equals_prefix: str, each_token: str, all_remaining_tokens: list[str]
|
|
97
|
+
) -> str | None:
|
|
98
|
+
"""Join an equals-form flag value that shlex(posix=False) split on an internal space.
|
|
99
|
+
|
|
100
|
+
shlex.split(command, posix=False) treats the quote character inside
|
|
101
|
+
`--title="fix: a b"` as starting mid-token, so it splits the quoted value
|
|
102
|
+
at each unquoted-looking space instead of keeping it as one token. This
|
|
103
|
+
rejoins those split tokens up to the matching closing quote.
|
|
104
|
+
"""
|
|
105
|
+
value_token = each_token[len(equals_prefix) :]
|
|
106
|
+
extra_tokens_to_join = count_extra_tokens_to_skip_for_split_quoted_value(
|
|
107
|
+
all_remaining_tokens, value_token
|
|
108
|
+
)
|
|
109
|
+
if extra_tokens_to_join is None:
|
|
110
|
+
return None
|
|
111
|
+
return strip_surrounding_quotes(
|
|
112
|
+
" ".join([value_token, *all_remaining_tokens[:extra_tokens_to_join]])
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _extract_flag_value(
|
|
117
|
+
command: str,
|
|
118
|
+
long_flag: str,
|
|
119
|
+
short_flag: str,
|
|
120
|
+
all_pre_tokenized: tuple[str, list[str]] | None = None,
|
|
121
|
+
) -> str | None:
|
|
122
|
+
"""Return the value of long_flag/short_flag, skipping preceding value-flag values.
|
|
123
|
+
|
|
124
|
+
Uses iter_significant_tokens so a long_flag/short_flag word exposed inside an
|
|
125
|
+
earlier value flag's split quoted value is never mistaken for the flag
|
|
126
|
+
itself, then scans the raw tokens to read the flag's own value. Recognizes
|
|
127
|
+
the space form, the equals form, and the attached short form
|
|
128
|
+
(``-Rowner/repo``). Returns None when the flag is absent or the command is
|
|
129
|
+
unparseable.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
command: The full shell command string.
|
|
133
|
+
long_flag: The flag's long spelling, such as ``--title``.
|
|
134
|
+
short_flag: The flag's short spelling, such as ``-t``.
|
|
135
|
+
all_pre_tokenized: An optional ``(logical_line, raw_tokens)`` pair reused
|
|
136
|
+
in place of recomputing the logical line and shlex split.
|
|
137
|
+
"""
|
|
138
|
+
if all_pre_tokenized is not None:
|
|
139
|
+
logical_line, all_tokens = all_pre_tokenized
|
|
140
|
+
else:
|
|
141
|
+
logical_line = get_logical_first_line(command)
|
|
142
|
+
if not logical_line:
|
|
143
|
+
return None
|
|
144
|
+
try:
|
|
145
|
+
all_tokens = shlex.split(logical_line, posix=False)
|
|
146
|
+
except ValueError:
|
|
147
|
+
return None
|
|
148
|
+
try:
|
|
149
|
+
all_significant_tokens = list(
|
|
150
|
+
iter_significant_tokens(command, pre_tokenized=(logical_line, all_tokens))
|
|
151
|
+
)
|
|
152
|
+
except ValueError:
|
|
153
|
+
return None
|
|
154
|
+
if not _flag_present_in_significant_tokens(all_significant_tokens, long_flag, short_flag):
|
|
155
|
+
return None
|
|
156
|
+
return _scan_tokens_for_flag_value(all_tokens, long_flag, short_flag)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _flag_present_in_significant_tokens(
|
|
160
|
+
all_significant_tokens: list[tuple[str, list[str]]], long_flag: str, short_flag: str
|
|
161
|
+
) -> bool:
|
|
162
|
+
return any(
|
|
163
|
+
_token_begins_target_flag(each_token, long_flag, short_flag)
|
|
164
|
+
for each_token, _all_remaining_tokens in all_significant_tokens
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _scan_tokens_for_flag_value(
|
|
169
|
+
all_tokens: list[str], long_flag: str, short_flag: str
|
|
170
|
+
) -> str | None:
|
|
171
|
+
token_index = 0
|
|
172
|
+
while token_index < len(all_tokens):
|
|
173
|
+
each_token = all_tokens[token_index]
|
|
174
|
+
all_remaining_tokens = all_tokens[token_index + 1 :]
|
|
175
|
+
if _token_begins_target_flag(each_token, long_flag, short_flag):
|
|
176
|
+
return _target_flag_value(each_token, all_remaining_tokens, long_flag, short_flag)
|
|
177
|
+
token_index = _index_after_value_flag(all_tokens, token_index)
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _token_begins_target_flag(each_token: str, long_flag: str, short_flag: str) -> bool:
|
|
182
|
+
if each_token in {long_flag, short_flag}:
|
|
183
|
+
return True
|
|
184
|
+
if each_token.startswith(f"{long_flag}=") or each_token.startswith(f"{short_flag}="):
|
|
185
|
+
return True
|
|
186
|
+
return _is_attached_short_flag(each_token, short_flag)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _is_attached_short_flag(each_token: str, short_flag: str) -> bool:
|
|
190
|
+
if each_token == short_flag or not each_token.startswith(short_flag):
|
|
191
|
+
return False
|
|
192
|
+
return not each_token.startswith(f"{short_flag}=")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _target_flag_value(
|
|
196
|
+
each_token: str, all_remaining_tokens: list[str], long_flag: str, short_flag: str
|
|
197
|
+
) -> str | None:
|
|
198
|
+
long_flag_equals_prefix = f"{long_flag}="
|
|
199
|
+
short_flag_equals_prefix = f"{short_flag}="
|
|
200
|
+
if each_token.startswith(long_flag_equals_prefix):
|
|
201
|
+
return _joined_equals_form_value(long_flag_equals_prefix, each_token, all_remaining_tokens)
|
|
202
|
+
if each_token.startswith(short_flag_equals_prefix):
|
|
203
|
+
return _joined_equals_form_value(short_flag_equals_prefix, each_token, all_remaining_tokens)
|
|
204
|
+
if each_token in {long_flag, short_flag}:
|
|
205
|
+
if not all_remaining_tokens:
|
|
206
|
+
return None
|
|
207
|
+
return strip_surrounding_quotes(all_remaining_tokens[0])
|
|
208
|
+
return strip_surrounding_quotes(each_token[len(short_flag) :])
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _index_after_value_flag(all_tokens: list[str], token_index: int) -> int:
|
|
212
|
+
each_token = all_tokens[token_index]
|
|
213
|
+
all_remaining_tokens = all_tokens[token_index + 1 :]
|
|
214
|
+
equals_prefix = match_non_body_value_flag_equals_prefix(each_token)
|
|
215
|
+
if equals_prefix is not None:
|
|
216
|
+
value_token = each_token[len(equals_prefix) :]
|
|
217
|
+
extra_tokens_to_skip = count_extra_tokens_to_skip_for_split_quoted_value(
|
|
218
|
+
all_remaining_tokens, value_token
|
|
219
|
+
)
|
|
220
|
+
return token_index + 1 + (extra_tokens_to_skip or 0)
|
|
221
|
+
if each_token not in all_value_flags:
|
|
222
|
+
return token_index + 1
|
|
223
|
+
if not all_remaining_tokens or is_flag_shaped_token(all_remaining_tokens[0]):
|
|
224
|
+
return token_index + 1
|
|
225
|
+
value_token = all_remaining_tokens[0]
|
|
226
|
+
extra_tokens_to_skip = count_extra_tokens_to_skip_for_split_quoted_value(
|
|
227
|
+
all_remaining_tokens[1:], value_token
|
|
228
|
+
)
|
|
229
|
+
return token_index + 1 + 1 + (extra_tokens_to_skip or 0)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _is_conventional_commit_title(title: str) -> bool:
|
|
233
|
+
return bool(CONVENTIONAL_COMMIT_TITLE_PATTERN.match(title))
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _repo_enforces_default_conventional_pr_titles(repo_root: str) -> bool:
|
|
237
|
+
"""Return whether a marker workflow enforces PR titles against the default type list.
|
|
238
|
+
|
|
239
|
+
A semantic-pull-request action step that declares a custom ``types:`` input
|
|
240
|
+
accepts types this gate's default Conventional Commits list omits, so the gate
|
|
241
|
+
cannot judge a title against that workflow's allowed types. A repo can run
|
|
242
|
+
several marker workflows at once: when any one of them leaves the action's type
|
|
243
|
+
list at its default, that workflow's CI check rejects a non-Conventional title,
|
|
244
|
+
so the gate blocks. The gate returns True when at least one marker workflow uses
|
|
245
|
+
the default type list, and False (fail open) only when every marker workflow
|
|
246
|
+
customizes its types -- the authoritative CI check on GitHub still validates the
|
|
247
|
+
title in that case.
|
|
248
|
+
"""
|
|
249
|
+
workflows_directory = Path(repo_root) / WORKFLOWS_DIRECTORY_RELATIVE_PATH
|
|
250
|
+
if not workflows_directory.is_dir():
|
|
251
|
+
return False
|
|
252
|
+
all_marker_workflow_texts = _all_semantic_marker_workflow_texts(workflows_directory)
|
|
253
|
+
if not all_marker_workflow_texts:
|
|
254
|
+
return False
|
|
255
|
+
return any(
|
|
256
|
+
not _workflow_customizes_semantic_types(each_workflow_text)
|
|
257
|
+
for each_workflow_text in all_marker_workflow_texts
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _all_semantic_marker_workflow_texts(workflows_directory: Path) -> list[str]:
|
|
262
|
+
all_marker_workflow_texts: list[str] = []
|
|
263
|
+
for each_glob_pattern in ALL_WORKFLOW_FILE_GLOB_PATTERNS:
|
|
264
|
+
for each_workflow_file in workflows_directory.glob(each_glob_pattern):
|
|
265
|
+
workflow_text = _read_workflow_text(each_workflow_file)
|
|
266
|
+
if _text_has_semantic_marker(workflow_text):
|
|
267
|
+
all_marker_workflow_texts.append(workflow_text)
|
|
268
|
+
return all_marker_workflow_texts
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _text_has_semantic_marker(workflow_text: str) -> bool:
|
|
272
|
+
return any(each_marker in workflow_text for each_marker in ALL_SEMANTIC_TITLE_CI_MARKERS)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _workflow_customizes_semantic_types(workflow_text: str) -> bool:
|
|
276
|
+
"""Return whether a semantic-pull-request step declares a custom ``types:`` input.
|
|
277
|
+
|
|
278
|
+
The action reads a ``types:`` input as the complete allowed-type list,
|
|
279
|
+
replacing this gate's default Conventional Commits set, so a title this gate
|
|
280
|
+
would reject may be one the repo's own CI accepts. Scoping the search to the
|
|
281
|
+
marker step's indented block keeps the top-level ``on: pull_request: types:``
|
|
282
|
+
event-activity list -- an unrelated ``types:`` key -- out of the match.
|
|
283
|
+
"""
|
|
284
|
+
all_lines = workflow_text.splitlines()
|
|
285
|
+
for each_line_index, each_line in enumerate(all_lines):
|
|
286
|
+
if not _text_has_semantic_marker(each_line):
|
|
287
|
+
continue
|
|
288
|
+
if _step_block_declares_types_input(all_lines, each_line_index):
|
|
289
|
+
return True
|
|
290
|
+
return False
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _step_block_declares_types_input(all_lines: list[str], marker_line_index: int) -> bool:
|
|
294
|
+
step_item_index = _enclosing_step_item_index(all_lines, marker_line_index)
|
|
295
|
+
step_indentation = _leading_space_count(all_lines[step_item_index])
|
|
296
|
+
for each_line in all_lines[step_item_index + 1 :]:
|
|
297
|
+
if not each_line.strip():
|
|
298
|
+
continue
|
|
299
|
+
if _leading_space_count(each_line) <= step_indentation:
|
|
300
|
+
return False
|
|
301
|
+
if _line_declares_types_input(each_line):
|
|
302
|
+
return True
|
|
303
|
+
return False
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _line_declares_types_input(line: str) -> bool:
|
|
307
|
+
"""Return whether a step-block line declares a semantic-pull-request ``types:`` input.
|
|
308
|
+
|
|
309
|
+
Matches both the block-style key (``types: |`` on its own line) and the
|
|
310
|
+
flow-style mapping (``with: { types: [feat, wip] }``), so a custom type list
|
|
311
|
+
written in either YAML shape counts.
|
|
312
|
+
"""
|
|
313
|
+
if SEMANTIC_ACTION_TYPES_INPUT_PATTERN.match(line):
|
|
314
|
+
return True
|
|
315
|
+
return bool(SEMANTIC_ACTION_FLOW_TYPES_INPUT_PATTERN.search(line))
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _enclosing_step_item_index(all_lines: list[str], marker_line_index: int) -> int:
|
|
319
|
+
"""Return the index of the ``- `` list item that encloses the marker line.
|
|
320
|
+
|
|
321
|
+
The semantic-pull-request marker sits either on the step's ``- `` list-item
|
|
322
|
+
line or on a sibling ``uses:`` key one level in. Scanning the step block from
|
|
323
|
+
that list item -- rather than the marker line -- reaches a nested
|
|
324
|
+
``with: types:`` input whether it sits above or below the ``uses:`` key,
|
|
325
|
+
since YAML mapping key order within a step is arbitrary.
|
|
326
|
+
"""
|
|
327
|
+
marker_indentation = _leading_space_count(all_lines[marker_line_index])
|
|
328
|
+
for each_candidate_index in range(marker_line_index, -1, -1):
|
|
329
|
+
candidate_line = all_lines[each_candidate_index]
|
|
330
|
+
if not _is_yaml_list_item(candidate_line):
|
|
331
|
+
continue
|
|
332
|
+
if _leading_space_count(candidate_line) <= marker_indentation:
|
|
333
|
+
return each_candidate_index
|
|
334
|
+
return marker_line_index
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _is_yaml_list_item(line: str) -> bool:
|
|
338
|
+
return line.lstrip().startswith(YAML_LIST_ITEM_PREFIX)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _leading_space_count(line: str) -> int:
|
|
342
|
+
return len(line) - len(line.lstrip())
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _read_workflow_text(workflow_file: Path) -> str:
|
|
346
|
+
try:
|
|
347
|
+
return workflow_file.read_text(encoding="utf-8", errors="replace")
|
|
348
|
+
except OSError:
|
|
349
|
+
return ""
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _pull_request_title_to_validate(command: str) -> str | None:
|
|
353
|
+
"""Return the --title/-t value this call should be checked against, or None.
|
|
354
|
+
|
|
355
|
+
Returns None for a command that is not `gh pr create`/`gh pr edit`, an
|
|
356
|
+
unparseable command, a command carrying a --repo/-R flag (a repo this hook
|
|
357
|
+
cannot inspect on the local filesystem), a command with no --title/-t
|
|
358
|
+
value, or a title that is an unresolvable shell variable (a `$`-prefixed
|
|
359
|
+
value whose resolved text this hook cannot know), which fails open so the
|
|
360
|
+
authoritative CI check on GitHub decides.
|
|
361
|
+
"""
|
|
362
|
+
if not _matches_gh_pr_title_subcommand(command):
|
|
363
|
+
return None
|
|
364
|
+
all_tokens = _parsed_command_tokens(command)
|
|
365
|
+
if all_tokens is None:
|
|
366
|
+
return None
|
|
367
|
+
all_pre_tokenized = (get_logical_first_line(command), all_tokens)
|
|
368
|
+
if _extract_flag_value(command, REPO_LONG_FLAG, REPO_SHORT_FLAG, all_pre_tokenized) is not None:
|
|
369
|
+
return None
|
|
370
|
+
extracted_title = _extract_flag_value(
|
|
371
|
+
command, TITLE_LONG_FLAG, TITLE_SHORT_FLAG, all_pre_tokenized
|
|
372
|
+
)
|
|
373
|
+
if not extracted_title or is_unresolvable_shell_value(extracted_title):
|
|
374
|
+
return None
|
|
375
|
+
return extracted_title
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _resolved_repo_root(payload_by_field: dict[str, object]) -> str | None:
|
|
379
|
+
working_directory = payload_by_field.get("cwd")
|
|
380
|
+
start_directory = (
|
|
381
|
+
working_directory
|
|
382
|
+
if isinstance(working_directory, str) and working_directory
|
|
383
|
+
else str(Path.cwd())
|
|
384
|
+
)
|
|
385
|
+
return resolve_repo_root(start_directory)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _deny_reason(payload_by_field: dict[str, object]) -> str | None:
|
|
389
|
+
tool_input = payload_by_field.get("tool_input", {})
|
|
390
|
+
if not isinstance(tool_input, dict):
|
|
391
|
+
return None
|
|
392
|
+
command = tool_input.get("command", "")
|
|
393
|
+
if not isinstance(command, str) or not command:
|
|
394
|
+
return None
|
|
395
|
+
pull_request_title = _pull_request_title_to_validate(command)
|
|
396
|
+
if not pull_request_title or _is_conventional_commit_title(pull_request_title):
|
|
397
|
+
return None
|
|
398
|
+
repo_root = _resolved_repo_root(payload_by_field)
|
|
399
|
+
if repo_root is None:
|
|
400
|
+
return None
|
|
401
|
+
if not _repo_enforces_default_conventional_pr_titles(repo_root):
|
|
402
|
+
return None
|
|
403
|
+
return CORRECTIVE_MESSAGE
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def main() -> None:
|
|
407
|
+
try:
|
|
408
|
+
hook_input = json.load(sys.stdin)
|
|
409
|
+
except json.JSONDecodeError:
|
|
410
|
+
sys.exit(0)
|
|
411
|
+
if not isinstance(hook_input, dict):
|
|
412
|
+
sys.exit(0)
|
|
413
|
+
|
|
414
|
+
tool_name = hook_input.get("tool_name", "")
|
|
415
|
+
if tool_name != BASH_TOOL_NAME:
|
|
416
|
+
sys.exit(0)
|
|
417
|
+
|
|
418
|
+
deny_reason = _deny_reason(hook_input)
|
|
419
|
+
if deny_reason is None:
|
|
420
|
+
sys.exit(0)
|
|
421
|
+
|
|
422
|
+
deny_payload = {
|
|
423
|
+
"hookSpecificOutput": {
|
|
424
|
+
"hookEventName": "PreToolUse",
|
|
425
|
+
"permissionDecision": "deny",
|
|
426
|
+
"permissionDecisionReason": deny_reason,
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
tool_input = hook_input.get("tool_input", {})
|
|
430
|
+
command_preview = tool_input.get("command", "") if isinstance(tool_input, dict) else ""
|
|
431
|
+
log_hook_block(
|
|
432
|
+
calling_hook_name="conventional_pr_title_gate.py",
|
|
433
|
+
hook_event="PreToolUse",
|
|
434
|
+
block_reason=deny_reason,
|
|
435
|
+
tool_name=tool_name,
|
|
436
|
+
offending_input_preview=command_preview,
|
|
437
|
+
)
|
|
438
|
+
print(json.dumps(deny_payload))
|
|
439
|
+
sys.stdout.flush()
|
|
440
|
+
sys.exit(0)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
if __name__ == "__main__":
|
|
444
|
+
main()
|
|
@@ -8,9 +8,9 @@ decision when any hook denied (carrying every denying reason) or exits zero to
|
|
|
8
8
|
allow.
|
|
9
9
|
|
|
10
10
|
The per-hook coverage matrix:
|
|
11
|
-
- Write -> Group A (11 hooks) + Group B (
|
|
12
|
-
- Edit -> Group A (11 hooks) + Group B (
|
|
13
|
-
- MultiEdit -> Group B only (
|
|
11
|
+
- Write -> Group A (11 hooks) + Group B (8 hooks) = 19 hooks
|
|
12
|
+
- Edit -> Group A (11 hooks) + Group B (8 hooks) + the Edit-only hook = 20 hooks
|
|
13
|
+
- MultiEdit -> Group B only (8 hooks)
|
|
14
14
|
"""
|
|
15
15
|
|
|
16
16
|
from __future__ import annotations
|