claude-dev-env 2.18.0 → 2.20.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/.agents/skills/AGENTS.md +4 -4
- package/.agents/skills/_shared/AGENTS.md +1 -0
- package/.agents/skills/_shared/pr-loop/AGENTS.md +1 -0
- package/.agents/skills/_shared/pr-loop/preflight-proposal.contract.test.mjs +119 -0
- package/.agents/skills/_shared/pr-loop/preflight-proposal.md +5 -0
- package/.agents/skills/e-code-review/SKILL.md +12 -1
- package/.agents/skills/e-code-review/reference/fix.md +5 -1
- package/.agents/skills/e-code-review/reference/loop.md +4 -0
- package/.agents/skills/e-code-review/reference/mode-contract.test.mjs +66 -0
- package/.agents/skills/e-code-review/reference/preflight-proposal.md +40 -0
- package/.agents/skills/e-code-review/reference/runner-selection.md +1 -0
- package/.agents/skills/pr-cleanup/SKILL.md +105 -75
- package/.agents/skills/pr-cleanup/reference/process-inventory.md +15 -0
- package/.agents/skills/pr-cleanup/reference/task-seeds.md +20 -0
- package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/SKILL.md +18 -12
- package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/offense-examples.md +1 -1
- package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/task-seeds.md +3 -2
- package/.agents/skills/pr-refinement/SKILL.md +31 -0
- package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/SKILL.md +24 -7
- package/.agents/skills/{small-cl → pr-small-cl}/SKILL.md +1 -1
- package/.agents/skills/prototype/scripts/test_probe_sandbox_safety.py +33 -5
- package/.agents/skills/skill-builder/SKILL.md +1 -0
- package/.agents/skills-archived/bugteam/SKILL.md +1 -1
- package/.agents/skills-archived/bugteam/test_skill_additions.py +1 -1
- package/.agents/skills-archived/comments/SKILL.md +1 -1
- package/.agents/skills-archived/descriptions/SKILL.md +1 -1
- package/.agents/skills-archived/reviews/SKILL.md +1 -1
- package/AGENTS.md +1 -1
- package/_shared/pr-loop/AGENTS.md +2 -1
- package/_shared/pr-loop/preflight-proposal.md +56 -0
- package/_shared/pr-loop/scripts/code_rules_gate.py +29 -6
- package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_arguments.py +15 -3
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_running.py +47 -4
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +4 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +47 -0
- package/bin/ever-shipped-skills.mjs +14 -0
- package/bin/install-constants.mjs +5 -0
- package/bin/install.agents-home.test.mjs +23 -0
- package/bin/install.codex-rules.test.mjs +61 -6
- package/bin/install.mjs +57 -9
- package/bin/install.prune.test.mjs +65 -3
- package/bin/install.test.mjs +202 -2
- package/docs/CODE_RULES.md +6 -0
- package/hooks/advisory/conftest.py +10 -0
- package/hooks/advisory/migration_safety_advisor.py +14 -14
- package/hooks/advisory/refactor_guard.py +250 -144
- package/hooks/advisory/refactor_guard_test_support.py +46 -0
- package/hooks/advisory/test_migration_safety_advisor.py +120 -0
- package/hooks/advisory/test_refactor_guard_advisory.py +171 -0
- package/hooks/advisory/test_refactor_guard_eligibility.py +166 -0
- package/hooks/atomic_file_writer.py +81 -0
- package/hooks/blocking/AGENTS.md +2 -1
- package/hooks/blocking/block_main_commit.py +66 -33
- package/hooks/blocking/code_rules_banned_identifiers.py +55 -4
- package/hooks/blocking/code_rules_blast_radius.py +194 -0
- package/hooks/blocking/code_rules_enforcer.py +12 -0
- package/hooks/blocking/code_rules_enforcer_test_support.py +92 -0
- package/hooks/blocking/destructive_command_blocker.py +8 -7
- package/hooks/blocking/pre_tool_use_dispatcher.py +11 -7
- package/hooks/blocking/precommit_code_rules_gate.py +14 -139
- package/hooks/blocking/sensitive_file_protector.py +8 -6
- package/hooks/blocking/session_edit_stage_gate.py +31 -1
- package/hooks/blocking/test_block_main_commit.py +145 -0
- package/hooks/blocking/test_claude_md_orphan_file_blocker.py +171 -1
- package/hooks/blocking/test_code_rules_blast_radius.py +161 -0
- package/hooks/blocking/test_code_rules_enforcer_agent_home_tooling.py +37 -12
- package/hooks/blocking/test_code_rules_enforcer_banned_noun_word.py +54 -0
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +15 -53
- package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +13 -62
- package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +309 -0
- package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +14 -71
- package/hooks/blocking/test_code_rules_enforcer_scratchpad.py +14 -16
- package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +31 -46
- package/hooks/blocking/test_code_rules_enforcer_stage.py +192 -0
- package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +13 -59
- package/hooks/blocking/test_destructive_command_blocker.py +154 -138
- package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +85 -25
- package/hooks/blocking/test_destructive_command_blocker_no_verify.py +58 -38
- package/hooks/blocking/test_destructive_command_blocker_patterns.py +133 -0
- package/hooks/blocking/test_docstring_rule_gate_count_blocker.py +113 -1
- package/hooks/blocking/test_env_var_table_code_drift_blocker.py +70 -3
- package/hooks/blocking/test_hook_subprocess_support.py +296 -0
- package/hooks/blocking/test_package_inventory_stale_blocker.py +1 -1
- package/hooks/blocking/test_pii_scanner.py +81 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +32 -3
- package/hooks/blocking/test_precommit_code_rules_gate.py +11 -16
- package/hooks/blocking/test_precommit_code_rules_gate_native_owner.py +234 -0
- package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +25 -18
- package/hooks/blocking/test_sensitive_file_protector.py +145 -5
- package/hooks/blocking/test_session_edit_stage_gate_staging.py +64 -0
- package/hooks/blocking/test_session_edit_stage_gate_support.py +204 -0
- package/hooks/blocking/test_session_edit_stage_gate_tracker.py +116 -0
- package/hooks/blocking/test_shared_stdin_adoption.py +31 -20
- package/hooks/blocking/test_test_preflight_check.py +80 -0
- package/hooks/git-hooks/AGENTS.md +1 -1
- package/hooks/git-hooks/git_hooks_constants/__init__.py +1 -0
- package/hooks/git-hooks/post_commit.py +160 -51
- package/hooks/git-hooks/pre_commit.py +9 -5
- package/hooks/git-hooks/test_post_commit.py +203 -0
- package/hooks/git-hooks/test_pre_commit.py +2 -2
- package/hooks/git-hooks/test_pre_push.py +57 -0
- package/hooks/hooks_constants/AGENTS.md +3 -1
- package/hooks/hooks_constants/atomic_file_writer_constants.py +4 -0
- package/hooks/hooks_constants/banned_identifiers_constants.py +1 -0
- package/hooks/hooks_constants/blast_radius_constants.py +14 -0
- package/hooks/hooks_constants/destructive_command_environment_constants.py +18 -0
- package/hooks/hooks_constants/destructive_command_segment_constants.py +1 -11
- package/hooks/hooks_constants/hardcoded_user_path_constants.py +9 -3
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +1 -1
- package/hooks/hooks_constants/refactor_guard_constants.py +75 -0
- package/hooks/hooks_constants/sensitive_file_protector_constants.py +2 -4
- package/hooks/hooks_constants/test_refactor_guard_constants.py +21 -0
- package/hooks/json_file_reader.py +22 -0
- package/hooks/observability/test_instructions_loaded_logger.py +54 -0
- package/hooks/pending_sidecars.py +33 -0
- package/hooks/session/test_plugin_data_dir_cleanup.py +70 -0
- package/hooks/session/test_session_edit_tracker_cleanup.py +16 -3
- package/hooks/test_atomic_file_writer.py +73 -0
- package/hooks/test_json_file_reader.py +24 -0
- package/hooks/test_pending_sidecars.py +25 -0
- package/hooks/validation/mypy_validator.py +213 -80
- package/hooks/validation/test_mypy_validator.py +288 -13
- package/hooks/workflow/auto_formatter.py +225 -93
- package/hooks/workflow/investigation_tracker_reset.py +2 -0
- package/hooks/workflow/test_auto_formatter.py +261 -12
- package/hooks/workflow/test_investigation_tracker_reset.py +90 -0
- package/package.json +1 -1
- package/rules/failure-blast-radius.md +126 -0
- /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/fetch-commands.md +0 -0
- /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/report-template.md +0 -0
- /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/rule-checklist.md +0 -0
- /package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/reference/examples.md +0 -0
- /package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/reference/offense-taxonomy.md +0 -0
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"""Banned identifier, banned noun-word, and banned function-prefix naming checks."""
|
|
2
2
|
|
|
3
3
|
import ast
|
|
4
|
+
import io
|
|
4
5
|
import sys
|
|
6
|
+
import tokenize
|
|
5
7
|
from pathlib import Path
|
|
6
8
|
|
|
7
9
|
_blocking_directory = str(Path(__file__).resolve().parent)
|
|
@@ -30,6 +32,7 @@ from hooks_constants.banned_identifiers_constants import ( # noqa: E402
|
|
|
30
32
|
ALL_BANNED_NOUN_WORDS,
|
|
31
33
|
BANNED_IDENTIFIER_MESSAGE_SUFFIX,
|
|
32
34
|
BANNED_IDENTIFIER_SKIP_ADVISORY,
|
|
35
|
+
BANNED_NOUN_COMPATIBILITY_COMMENT,
|
|
33
36
|
BANNED_NOUN_WORD_MESSAGE_SUFFIX,
|
|
34
37
|
CAMEL_CASE_WORD_PATTERN,
|
|
35
38
|
MAX_BANNED_IDENTIFIER_ISSUES,
|
|
@@ -235,8 +238,31 @@ def _is_dunder_name(identifier: str) -> bool:
|
|
|
235
238
|
return identifier.startswith("__") and identifier.endswith("__")
|
|
236
239
|
|
|
237
240
|
|
|
241
|
+
def _collect_annotated_public_definition_lines(
|
|
242
|
+
content: str, parsed_tree: ast.AST
|
|
243
|
+
) -> frozenset[int]:
|
|
244
|
+
all_annotation_lines: set[int] = set()
|
|
245
|
+
try:
|
|
246
|
+
for each_token in tokenize.generate_tokens(io.StringIO(content).readline):
|
|
247
|
+
if each_token.type != tokenize.COMMENT:
|
|
248
|
+
continue
|
|
249
|
+
if each_token.string.strip() == BANNED_NOUN_COMPATIBILITY_COMMENT:
|
|
250
|
+
all_annotation_lines.add(each_token.start[0])
|
|
251
|
+
except (IndentationError, SyntaxError, tokenize.TokenError):
|
|
252
|
+
return frozenset()
|
|
253
|
+
|
|
254
|
+
return frozenset(
|
|
255
|
+
each_node.lineno
|
|
256
|
+
for each_node in ast.walk(parsed_tree)
|
|
257
|
+
if isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
258
|
+
and not each_node.name.startswith("_")
|
|
259
|
+
and each_node.lineno in all_annotation_lines
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
238
263
|
def _collect_banned_noun_word_bindings(
|
|
239
264
|
parsed_tree: ast.AST,
|
|
265
|
+
all_annotated_public_definition_lines: frozenset[int],
|
|
240
266
|
) -> list[tuple[str, int, int, str]]:
|
|
241
267
|
"""Yield ``(identifier, lineno, col_offset, banned_word)`` for each binding.
|
|
242
268
|
|
|
@@ -248,7 +274,14 @@ def _collect_banned_noun_word_bindings(
|
|
|
248
274
|
flagged_bindings: list[tuple[str, int, int, str]] = []
|
|
249
275
|
seen_keys: set[tuple[str, int, int]] = set()
|
|
250
276
|
|
|
251
|
-
def record(
|
|
277
|
+
def record(
|
|
278
|
+
name: str,
|
|
279
|
+
lineno: int,
|
|
280
|
+
col_offset: int,
|
|
281
|
+
is_annotated_public_definition: bool = False,
|
|
282
|
+
) -> None:
|
|
283
|
+
if is_annotated_public_definition:
|
|
284
|
+
return
|
|
252
285
|
if name in ALL_BANNED_IDENTIFIERS:
|
|
253
286
|
return
|
|
254
287
|
if _is_dunder_name(name):
|
|
@@ -282,11 +315,24 @@ def _collect_banned_noun_word_bindings(
|
|
|
282
315
|
for each_name_node in _collect_target_names(each_node.optional_vars):
|
|
283
316
|
record(each_name_node.id, each_name_node.lineno, each_name_node.col_offset)
|
|
284
317
|
elif isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
285
|
-
|
|
318
|
+
is_annotated_public_definition = (
|
|
319
|
+
each_node.lineno in all_annotated_public_definition_lines
|
|
320
|
+
)
|
|
321
|
+
record(
|
|
322
|
+
each_node.name,
|
|
323
|
+
each_node.lineno,
|
|
324
|
+
each_node.col_offset,
|
|
325
|
+
is_annotated_public_definition,
|
|
326
|
+
)
|
|
286
327
|
for each_arg in _collect_annotated_arguments(each_node):
|
|
287
328
|
if each_arg.arg in ALL_SELF_AND_CLS_PARAMETER_NAMES:
|
|
288
329
|
continue
|
|
289
|
-
record(
|
|
330
|
+
record(
|
|
331
|
+
each_arg.arg,
|
|
332
|
+
each_arg.lineno,
|
|
333
|
+
each_arg.col_offset,
|
|
334
|
+
is_annotated_public_definition,
|
|
335
|
+
)
|
|
290
336
|
elif isinstance(each_node, ast.ClassDef):
|
|
291
337
|
record(each_node.name, each_node.lineno, each_node.col_offset)
|
|
292
338
|
elif isinstance(each_node, (ast.Import, ast.ImportFrom)):
|
|
@@ -368,9 +414,14 @@ def check_banned_noun_word_boundary(
|
|
|
368
414
|
except SyntaxError:
|
|
369
415
|
return []
|
|
370
416
|
|
|
417
|
+
all_annotated_public_definition_lines = _collect_annotated_public_definition_lines(
|
|
418
|
+
content, parsed_tree
|
|
419
|
+
)
|
|
371
420
|
single_line_span = 1
|
|
372
421
|
all_violations_in_walk_order: list[tuple[range, str]] = []
|
|
373
|
-
for each_name, each_lineno, _, each_word in _collect_banned_noun_word_bindings(
|
|
422
|
+
for each_name, each_lineno, _, each_word in _collect_banned_noun_word_bindings(
|
|
423
|
+
parsed_tree, all_annotated_public_definition_lines
|
|
424
|
+
):
|
|
374
425
|
span_range = range(each_lineno, each_lineno + single_line_span)
|
|
375
426
|
span_fragment = BANNED_NOUN_SPAN_FRAGMENT_TEMPLATE.format(
|
|
376
427
|
definition_line=each_lineno, line_span=single_line_span
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Blast-radius check: a raise inside per-item work must name what it stops."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import sys
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
_blocking_directory = str(Path(__file__).resolve().parent)
|
|
9
|
+
_hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
10
|
+
if _blocking_directory not in sys.path:
|
|
11
|
+
sys.path.insert(0, _blocking_directory)
|
|
12
|
+
if _hooks_directory not in sys.path:
|
|
13
|
+
sys.path.insert(0, _hooks_directory)
|
|
14
|
+
|
|
15
|
+
from code_rules_shared import ( # noqa: E402
|
|
16
|
+
is_hook_infrastructure,
|
|
17
|
+
is_test_file,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
from hooks_constants.blast_radius_constants import ( # noqa: E402
|
|
21
|
+
ALL_BLAST_RADIUS_SUFFIXES,
|
|
22
|
+
BLAST_RADIUS_MESSAGE_SUFFIX,
|
|
23
|
+
MAX_BLAST_RADIUS_ISSUES,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _raised_type_name(raise_node: ast.Raise) -> str | None:
|
|
28
|
+
"""Return the name of the exception type a raise statement constructs.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
raise_node: The raise statement to read.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
The exception type name. A ``None`` return classifies bare re-raises and
|
|
35
|
+
alternate AST forms.
|
|
36
|
+
"""
|
|
37
|
+
raised = raise_node.exc
|
|
38
|
+
if raised is None:
|
|
39
|
+
return None
|
|
40
|
+
if isinstance(raised, ast.Call):
|
|
41
|
+
raised = raised.func
|
|
42
|
+
if isinstance(raised, ast.Name):
|
|
43
|
+
return raised.id
|
|
44
|
+
if isinstance(raised, ast.Attribute):
|
|
45
|
+
return raised.attr
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _declares_blast_radius(type_name: str) -> bool:
|
|
50
|
+
"""Report whether an exception type name states what its failure stops.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
type_name: The exception type name to inspect.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
``True`` when the name ends in a recognized blast-radius suffix.
|
|
57
|
+
"""
|
|
58
|
+
return any(type_name.endswith(each_suffix) for each_suffix in ALL_BLAST_RADIUS_SUFFIXES)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _handler_type_names(handler: ast.ExceptHandler) -> list[str]:
|
|
62
|
+
"""Return named exception types caught by one except clause."""
|
|
63
|
+
caught = handler.type
|
|
64
|
+
if caught is None:
|
|
65
|
+
return []
|
|
66
|
+
all_caught = caught.elts if isinstance(caught, ast.Tuple) else [caught]
|
|
67
|
+
return [
|
|
68
|
+
each_caught.id
|
|
69
|
+
for each_caught in all_caught
|
|
70
|
+
if isinstance(each_caught, ast.Name)
|
|
71
|
+
] + [
|
|
72
|
+
each_caught.attr
|
|
73
|
+
for each_caught in all_caught
|
|
74
|
+
if isinstance(each_caught, ast.Attribute)
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _handler_names_blast_radius_type(handler: ast.ExceptHandler) -> bool:
|
|
79
|
+
"""Report whether an except clause catches a blast-radius-declaring type.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
handler: The except clause to inspect.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
``True`` when any caught type name carries a blast-radius suffix.
|
|
86
|
+
"""
|
|
87
|
+
return any(_declares_blast_radius(each_name) for each_name in _handler_type_names(handler))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _handler_matches_raise(handler: ast.ExceptHandler, type_name: str | None) -> bool:
|
|
91
|
+
"""Report whether a handler corresponds to the raise it encloses."""
|
|
92
|
+
all_handler_names = _handler_type_names(handler)
|
|
93
|
+
if type_name is None:
|
|
94
|
+
return _handler_names_blast_radius_type(handler)
|
|
95
|
+
return type_name in all_handler_names
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _walk_loop_body(node: ast.AST) -> Iterator[ast.AST]:
|
|
99
|
+
"""Walk loop statements without entering nested definition scopes."""
|
|
100
|
+
for each_child in ast.iter_child_nodes(node):
|
|
101
|
+
if isinstance(
|
|
102
|
+
each_child,
|
|
103
|
+
(ast.AsyncFunctionDef, ast.ClassDef, ast.FunctionDef, ast.Lambda),
|
|
104
|
+
):
|
|
105
|
+
continue
|
|
106
|
+
yield each_child
|
|
107
|
+
yield from _walk_loop_body(each_child)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _boundary_guarded_raise_lines(tree: ast.Module) -> set[int]:
|
|
111
|
+
"""Collect raise lines already sitting inside a blast-radius boundary.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
tree: The parsed module to walk.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
The line numbers of raise statements enclosed by a try whose handlers
|
|
118
|
+
name a blast-radius-declaring type.
|
|
119
|
+
"""
|
|
120
|
+
all_guarded: set[int] = set()
|
|
121
|
+
for each_node in ast.walk(tree):
|
|
122
|
+
if not isinstance(each_node, ast.Try):
|
|
123
|
+
continue
|
|
124
|
+
for each_body_node in each_node.body:
|
|
125
|
+
for each_inner in ast.walk(each_body_node):
|
|
126
|
+
if isinstance(each_inner, ast.Raise) and any(
|
|
127
|
+
_handler_matches_raise(each_handler, _raised_type_name(each_inner))
|
|
128
|
+
for each_handler in each_node.handlers
|
|
129
|
+
):
|
|
130
|
+
all_guarded.add(each_inner.lineno)
|
|
131
|
+
return all_guarded
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _per_item_raise_nodes(tree: ast.Module) -> list[ast.Raise]:
|
|
135
|
+
"""Collect raise statements that sit inside a loop body.
|
|
136
|
+
|
|
137
|
+
A raise reached through per-item iteration ends every remaining item by
|
|
138
|
+
default. A declared radius identifies the intended scope, so each raise
|
|
139
|
+
in this path benefits from an explicit name.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
tree: The parsed module to walk.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
Every raise statement found under a for or while loop body.
|
|
146
|
+
"""
|
|
147
|
+
all_raises: list[ast.Raise] = []
|
|
148
|
+
for each_node in ast.walk(tree):
|
|
149
|
+
if not isinstance(each_node, (ast.For, ast.AsyncFor, ast.While)):
|
|
150
|
+
continue
|
|
151
|
+
for each_inner in _walk_loop_body(each_node):
|
|
152
|
+
if isinstance(each_inner, ast.Raise):
|
|
153
|
+
all_raises.append(each_inner)
|
|
154
|
+
return all_raises
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def check_blast_radius_declared(content: str, file_path: str) -> list[str]:
|
|
158
|
+
"""Check that raises inside per-item work name their stopping scope.
|
|
159
|
+
|
|
160
|
+
A raise reached through a loop body ends the whole batch by default, so a
|
|
161
|
+
one-item defect discards every item that already succeeded. Naming the type
|
|
162
|
+
``*RunFatal`` or ``*ItemBlocked`` states the intent, and a boundary catching
|
|
163
|
+
a declared type already handles it.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
content: The file body to inspect.
|
|
167
|
+
file_path: The path the body will be written to.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
One advisory line per raise that needs a declared blast radius, capped
|
|
171
|
+
at the configured maximum.
|
|
172
|
+
"""
|
|
173
|
+
if is_test_file(file_path) or is_hook_infrastructure(file_path):
|
|
174
|
+
return []
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
parsed_tree = ast.parse(content)
|
|
178
|
+
except SyntaxError:
|
|
179
|
+
return []
|
|
180
|
+
|
|
181
|
+
all_guarded_lines = _boundary_guarded_raise_lines(parsed_tree)
|
|
182
|
+
all_issues: list[str] = []
|
|
183
|
+
all_reported_lines: set[int] = set()
|
|
184
|
+
for each_raise in _per_item_raise_nodes(parsed_tree):
|
|
185
|
+
if each_raise.lineno in all_guarded_lines or each_raise.lineno in all_reported_lines:
|
|
186
|
+
continue
|
|
187
|
+
type_name = _raised_type_name(each_raise)
|
|
188
|
+
if type_name is None or _declares_blast_radius(type_name):
|
|
189
|
+
continue
|
|
190
|
+
all_reported_lines.add(each_raise.lineno)
|
|
191
|
+
all_issues.append(f"Line {each_raise.lineno}: {type_name} {BLAST_RADIUS_MESSAGE_SUFFIX}")
|
|
192
|
+
if len(all_issues) >= MAX_BLAST_RADIUS_ISSUES:
|
|
193
|
+
break
|
|
194
|
+
return all_issues
|
|
@@ -41,6 +41,9 @@ from code_rules_banned_identifiers import ( # noqa: E402
|
|
|
41
41
|
check_banned_noun_word_boundary,
|
|
42
42
|
check_banned_prefixes,
|
|
43
43
|
)
|
|
44
|
+
from code_rules_blast_radius import ( # noqa: E402
|
|
45
|
+
check_blast_radius_declared,
|
|
46
|
+
)
|
|
44
47
|
from code_rules_boolean_mustcheck import ( # noqa: E402
|
|
45
48
|
check_boolean_naming,
|
|
46
49
|
check_ignored_must_check_return,
|
|
@@ -295,6 +298,15 @@ def validate_content(
|
|
|
295
298
|
defer_scope_to_caller,
|
|
296
299
|
)
|
|
297
300
|
)
|
|
301
|
+
all_issues.extend(
|
|
302
|
+
_fragment_or_deferred_check(
|
|
303
|
+
check_blast_radius_declared,
|
|
304
|
+
old_content,
|
|
305
|
+
content,
|
|
306
|
+
file_path,
|
|
307
|
+
defer_scope_to_caller,
|
|
308
|
+
)
|
|
309
|
+
)
|
|
298
310
|
all_issues.extend(check_fstring_structural_literals(content, file_path))
|
|
299
311
|
all_issues.extend(check_constants_outside_config(content, file_path))
|
|
300
312
|
all_issues.extend(check_config_duplicate_path_anchor(content, file_path))
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Shared entrypoint helpers for code-rules enforcer behavior tests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
_ENFORCER_SCRIPT_PATH = Path(__file__).with_name("code_rules_enforcer.py")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def build_write_payload(file_path: str, content: str) -> str:
|
|
18
|
+
"""Build the JSON payload sent by a Write tool call."""
|
|
19
|
+
return json.dumps(
|
|
20
|
+
{
|
|
21
|
+
"tool_name": "Write",
|
|
22
|
+
"tool_input": {"file_path": file_path, "content": content},
|
|
23
|
+
}
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def run_serialized_payload_entrypoint(
|
|
28
|
+
entrypoint: Callable[[list[str]], None],
|
|
29
|
+
serialized_payload: str,
|
|
30
|
+
) -> tuple[str, int | None]:
|
|
31
|
+
"""Run a callable hook entrypoint with a serialized stdin payload."""
|
|
32
|
+
input_stream = io.StringIO(serialized_payload)
|
|
33
|
+
captured_stream = io.StringIO()
|
|
34
|
+
original_stdin = sys.stdin
|
|
35
|
+
original_stdout = sys.stdout
|
|
36
|
+
exit_code: int | None = None
|
|
37
|
+
try:
|
|
38
|
+
sys.stdin = input_stream
|
|
39
|
+
sys.stdout = captured_stream
|
|
40
|
+
entrypoint([])
|
|
41
|
+
except SystemExit as each_exit:
|
|
42
|
+
raw_code = each_exit.code
|
|
43
|
+
exit_code = raw_code if isinstance(raw_code, int) else None
|
|
44
|
+
finally:
|
|
45
|
+
sys.stdin = original_stdin
|
|
46
|
+
sys.stdout = original_stdout
|
|
47
|
+
return captured_stream.getvalue(), exit_code
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def run_write_entrypoint(
|
|
51
|
+
entrypoint: Callable[[list[str]], None],
|
|
52
|
+
file_path: str,
|
|
53
|
+
content: str,
|
|
54
|
+
) -> tuple[str, int]:
|
|
55
|
+
"""Drive a real hook entrypoint with a Write payload and capture stdout."""
|
|
56
|
+
captured_stdout, exit_code = run_serialized_payload_entrypoint(
|
|
57
|
+
entrypoint, build_write_payload(file_path, content)
|
|
58
|
+
)
|
|
59
|
+
return captured_stdout, int(exit_code or 0)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def run_enforcer_cli(
|
|
63
|
+
script_path: Path,
|
|
64
|
+
all_arguments: list[str],
|
|
65
|
+
extra_environment_by_name: dict[str, str] | None,
|
|
66
|
+
) -> subprocess.CompletedProcess[str]:
|
|
67
|
+
"""Run an enforcer script through its real command-line entrypoint."""
|
|
68
|
+
subprocess_environment: dict[str, str] | None = None
|
|
69
|
+
if extra_environment_by_name is not None:
|
|
70
|
+
subprocess_environment = os.environ.copy()
|
|
71
|
+
subprocess_environment.update(extra_environment_by_name)
|
|
72
|
+
return subprocess.run(
|
|
73
|
+
[sys.executable, str(script_path), *all_arguments],
|
|
74
|
+
stdin=subprocess.DEVNULL,
|
|
75
|
+
capture_output=True,
|
|
76
|
+
text=True,
|
|
77
|
+
check=False,
|
|
78
|
+
env=subprocess_environment,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def run_precheck(
|
|
83
|
+
candidate_path: Path,
|
|
84
|
+
target_path: str,
|
|
85
|
+
extra_environment_by_name: dict[str, str] | None,
|
|
86
|
+
) -> subprocess.CompletedProcess[str]:
|
|
87
|
+
"""Run an enforcer script through its real precheck command."""
|
|
88
|
+
return run_enforcer_cli(
|
|
89
|
+
_ENFORCER_SCRIPT_PATH,
|
|
90
|
+
["--check", str(candidate_path), "--as", target_path],
|
|
91
|
+
extra_environment_by_name,
|
|
92
|
+
)
|
|
@@ -20,12 +20,16 @@ from hooks_constants.convergence_branch_constants import ( # noqa: E402
|
|
|
20
20
|
CONVERGENCE_FORCE_PUSH_DETECTION_PATTERN,
|
|
21
21
|
)
|
|
22
22
|
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
23
|
-
from hooks_constants.
|
|
24
|
-
ALL_BENIGN_COMPOUND_SEGMENT_COMMANDS,
|
|
25
|
-
ALL_COMMAND_LAUNCHER_WRAPPER_COMMANDS,
|
|
23
|
+
from hooks_constants.destructive_command_environment_constants import ( # noqa: E402
|
|
26
24
|
ALL_KNOWN_TEMPORARY_ENVIRONMENT_VARIABLE_NAMES,
|
|
27
25
|
ALL_TRUTHY_ENV_VALUES,
|
|
28
26
|
DESTRUCTIVE_DENY_MODE_ENV_VAR,
|
|
27
|
+
EPHEMERAL_AUTO_ALLOW_DISABLE_ENV_VAR,
|
|
28
|
+
GH_REDIRECT_ACTIVE_ENV_VAR,
|
|
29
|
+
)
|
|
30
|
+
from hooks_constants.destructive_command_segment_constants import ( # noqa: E402
|
|
31
|
+
ALL_BENIGN_COMPOUND_SEGMENT_COMMANDS,
|
|
32
|
+
ALL_COMMAND_LAUNCHER_WRAPPER_COMMANDS,
|
|
29
33
|
ALL_FILE_WRITING_OUTPUT_FLAGS_BY_BENIGN_PROGRAM,
|
|
30
34
|
ALL_FIND_EXEC_ACTION_FLAGS,
|
|
31
35
|
ALL_FIND_EXEC_ACTION_TERMINATORS,
|
|
@@ -57,7 +61,6 @@ from hooks_constants.destructive_command_segment_constants import ( # noqa: E40
|
|
|
57
61
|
)
|
|
58
62
|
|
|
59
63
|
CLAUDE_DIRECTORY_PATH = os.path.normpath(os.path.expanduser("~/.claude"))
|
|
60
|
-
GH_REDIRECT_ACTIVE_ENV_VAR = "CLAUDE_GH_REDIRECT_ACTIVE"
|
|
61
64
|
|
|
62
65
|
|
|
63
66
|
def gh_redirect_is_active() -> bool:
|
|
@@ -87,9 +90,7 @@ def directory_is_ephemeral(directory_path: str) -> bool:
|
|
|
87
90
|
Returns:
|
|
88
91
|
True when the directory belongs to the ephemeral auto-allow namespace.
|
|
89
92
|
"""
|
|
90
|
-
|
|
91
|
-
truthy_string_values = frozenset({"1", "true", "yes", "on"})
|
|
92
|
-
if os.environ.get(ephemeral_auto_allow_disabled_env_var, "").strip().lower() in truthy_string_values:
|
|
93
|
+
if os.environ.get(EPHEMERAL_AUTO_ALLOW_DISABLE_ENV_VAR, "").strip().lower() in ALL_TRUTHY_ENV_VALUES:
|
|
93
94
|
return False
|
|
94
95
|
forward_slash_normalized_directory_path = os.path.normpath(directory_path).replace("\\", "/").lower()
|
|
95
96
|
all_worktree_path_segments = ("/worktrees/", "/worktree/")
|
|
@@ -486,16 +486,20 @@ def _emit_allow_decision(decision: DispatcherDecision) -> None:
|
|
|
486
486
|
|
|
487
487
|
Matches the shape a standalone hosted hook emits when it auto-approves the
|
|
488
488
|
write, so a write a hosted hook allows explicitly is auto-approved under the
|
|
489
|
-
dispatcher rather than falling back to the default permission flow.
|
|
490
|
-
|
|
491
|
-
that notice rides on the allow payload without denying.
|
|
489
|
+
dispatcher rather than falling back to the default permission flow. Advisory
|
|
490
|
+
systemMessage and additionalContext fields ride on the allow payload.
|
|
492
491
|
"""
|
|
492
|
+
allow_hook_specific: dict[str, object] = {
|
|
493
|
+
"hookEventName": HOOK_EVENT_NAME,
|
|
494
|
+
"permissionDecision": ALLOW_DECISION,
|
|
495
|
+
}
|
|
493
496
|
allow_payload: dict[str, object] = {
|
|
494
|
-
"hookSpecificOutput":
|
|
495
|
-
"hookEventName": HOOK_EVENT_NAME,
|
|
496
|
-
"permissionDecision": ALLOW_DECISION,
|
|
497
|
-
}
|
|
497
|
+
"hookSpecificOutput": allow_hook_specific,
|
|
498
498
|
}
|
|
499
|
+
if decision.all_additional_context:
|
|
500
|
+
allow_hook_specific["additionalContext"] = CONTEXT_JOIN_SEPARATOR.join(
|
|
501
|
+
decision.all_additional_context
|
|
502
|
+
)
|
|
499
503
|
if decision.all_system_messages:
|
|
500
504
|
allow_payload["systemMessage"] = SYSTEM_MESSAGE_JOIN_SEPARATOR.join(
|
|
501
505
|
decision.all_system_messages
|
|
@@ -1,40 +1,26 @@
|
|
|
1
|
-
"""PreToolUse hook that
|
|
1
|
+
"""PreToolUse hook that passes Git commit validation to Git hooks.
|
|
2
2
|
|
|
3
3
|
Intercepts Bash `git commit` invocations (including `git -C <path> commit`),
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
CODE_RULES violations is denied with the gate's file:line report so the
|
|
7
|
-
violations surface before the commit instead of stalling converge loops at
|
|
8
|
-
commit time. Non-commit commands, repositories with no staged Python files,
|
|
9
|
-
and clean staged changes pass through silently. A gate-engine failure denies
|
|
10
|
-
the commit with the failure detail — the gate never fails open.
|
|
4
|
+
emits neutral evidence, and returns control to Git. Configured Git hooks
|
|
5
|
+
receive the commit at the commit boundary.
|
|
11
6
|
"""
|
|
12
7
|
|
|
13
|
-
import json
|
|
14
8
|
import re
|
|
15
9
|
import subprocess
|
|
16
10
|
import sys
|
|
17
11
|
from pathlib import Path
|
|
18
12
|
|
|
19
|
-
|
|
20
|
-
if
|
|
21
|
-
sys.path.insert(0,
|
|
22
|
-
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
23
|
-
if _hooks_dir not in sys.path:
|
|
24
|
-
sys.path.insert(0, _hooks_dir)
|
|
13
|
+
_hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
14
|
+
if _hooks_directory not in sys.path:
|
|
15
|
+
sys.path.insert(0, _hooks_directory)
|
|
25
16
|
|
|
26
17
|
from block_main_commit import ( # noqa: E402
|
|
27
|
-
extract_git_working_directory,
|
|
28
18
|
is_commit_command,
|
|
29
19
|
parse_bash_command_from_stdin,
|
|
30
|
-
resolve_directory,
|
|
31
20
|
)
|
|
32
|
-
|
|
21
|
+
|
|
33
22
|
from hooks_constants.precommit_code_rules_gate_constants import ( # noqa: E402
|
|
34
23
|
ALL_GIT_REPOSITORY_ROOT_COMMAND,
|
|
35
|
-
ALL_STAGED_PYTHON_FILES_COMMAND,
|
|
36
|
-
GATE_RELATIVE_PATH,
|
|
37
|
-
GATE_TIMEOUT_SECONDS,
|
|
38
24
|
GIT_COMMAND_TIMEOUT_SECONDS,
|
|
39
25
|
GIT_DASH_C_COMMIT_PATTERN,
|
|
40
26
|
)
|
|
@@ -59,20 +45,19 @@ def is_git_commit_invocation(bash_command: str) -> bool:
|
|
|
59
45
|
|
|
60
46
|
|
|
61
47
|
def resolve_repository_root(working_directory: str | None) -> Path | None:
|
|
62
|
-
"""Resolve the
|
|
48
|
+
"""Resolve the Git repository root for a directory.
|
|
63
49
|
|
|
64
50
|
Args:
|
|
65
|
-
working_directory: Directory the
|
|
66
|
-
hook's current working directory.
|
|
51
|
+
working_directory: Directory inside the repository.
|
|
67
52
|
|
|
68
53
|
Returns:
|
|
69
|
-
|
|
70
|
-
a git repository or git is unavailable.
|
|
54
|
+
Repository root. Returns None when Git cannot resolve the directory.
|
|
71
55
|
"""
|
|
72
56
|
try:
|
|
73
57
|
completed_process = subprocess.run(
|
|
74
58
|
list(ALL_GIT_REPOSITORY_ROOT_COMMAND),
|
|
75
|
-
check=False,
|
|
59
|
+
check=False,
|
|
60
|
+
capture_output=True,
|
|
76
61
|
text=True,
|
|
77
62
|
timeout=GIT_COMMAND_TIMEOUT_SECONDS,
|
|
78
63
|
cwd=working_directory,
|
|
@@ -87,122 +72,12 @@ def resolve_repository_root(working_directory: str | None) -> Path | None:
|
|
|
87
72
|
return Path(top_level_text)
|
|
88
73
|
|
|
89
74
|
|
|
90
|
-
def list_staged_python_files(repository_root: Path) -> list[str]:
|
|
91
|
-
"""List repository-relative paths of staged Python files.
|
|
92
|
-
|
|
93
|
-
Args:
|
|
94
|
-
repository_root: Repository root used as the git working directory.
|
|
95
|
-
|
|
96
|
-
Returns:
|
|
97
|
-
Repository-relative paths of Python files staged for add, copy,
|
|
98
|
-
modify, or rename. Empty when the listing command fails — the
|
|
99
|
-
caller then allows the commit because git itself will surface the
|
|
100
|
-
repository problem.
|
|
101
|
-
"""
|
|
102
|
-
try:
|
|
103
|
-
completed_process = subprocess.run(
|
|
104
|
-
list(ALL_STAGED_PYTHON_FILES_COMMAND),
|
|
105
|
-
check=False, capture_output=True,
|
|
106
|
-
text=True,
|
|
107
|
-
timeout=GIT_COMMAND_TIMEOUT_SECONDS,
|
|
108
|
-
cwd=str(repository_root),
|
|
109
|
-
)
|
|
110
|
-
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
111
|
-
return []
|
|
112
|
-
if completed_process.returncode != 0:
|
|
113
|
-
return []
|
|
114
|
-
return [
|
|
115
|
-
each_line.strip()
|
|
116
|
-
for each_line in completed_process.stdout.splitlines()
|
|
117
|
-
if each_line.strip()
|
|
118
|
-
]
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
def run_staged_gate(repository_root: Path) -> tuple[int, str]:
|
|
122
|
-
"""Run the shared code_rules_gate engine in staged mode.
|
|
123
|
-
|
|
124
|
-
Args:
|
|
125
|
-
repository_root: Repository root passed to the gate's --repo-root
|
|
126
|
-
and used as the gate subprocess working directory, so relative
|
|
127
|
-
paths the gate's checks resolve land in the committing worktree
|
|
128
|
-
rather than the hook's own working directory.
|
|
129
|
-
|
|
130
|
-
Returns:
|
|
131
|
-
Tuple of the gate exit code and its stderr report. A missing gate
|
|
132
|
-
script or a gate timeout returns a non-zero code with an
|
|
133
|
-
explanatory message so the commit is denied rather than waved
|
|
134
|
-
through on infrastructure failure.
|
|
135
|
-
"""
|
|
136
|
-
gate_path = Path(__file__).resolve().parents[2] / GATE_RELATIVE_PATH
|
|
137
|
-
if not gate_path.is_file():
|
|
138
|
-
return 1, f"precommit_code_rules_gate: gate engine missing at {gate_path}"
|
|
139
|
-
try:
|
|
140
|
-
completed_process = subprocess.run(
|
|
141
|
-
[
|
|
142
|
-
sys.executable,
|
|
143
|
-
str(gate_path),
|
|
144
|
-
"--repo-root",
|
|
145
|
-
str(repository_root),
|
|
146
|
-
"--staged",
|
|
147
|
-
],
|
|
148
|
-
check=False, capture_output=True,
|
|
149
|
-
text=True,
|
|
150
|
-
encoding="utf-8",
|
|
151
|
-
errors="replace",
|
|
152
|
-
timeout=GATE_TIMEOUT_SECONDS,
|
|
153
|
-
cwd=str(repository_root),
|
|
154
|
-
)
|
|
155
|
-
except subprocess.TimeoutExpired:
|
|
156
|
-
return 1, (
|
|
157
|
-
f"precommit_code_rules_gate: gate engine timed out after {GATE_TIMEOUT_SECONDS}s"
|
|
158
|
-
)
|
|
159
|
-
return completed_process.returncode, completed_process.stderr
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
def build_denial_response(gate_report: str) -> dict:
|
|
163
|
-
"""Build the PreToolUse deny payload carrying the gate report.
|
|
164
|
-
|
|
165
|
-
Args:
|
|
166
|
-
gate_report: The gate's stderr report listing file:line violations.
|
|
167
|
-
|
|
168
|
-
Returns:
|
|
169
|
-
The hookSpecificOutput deny mapping for the PreToolUse protocol.
|
|
170
|
-
"""
|
|
171
|
-
denial_reason = (
|
|
172
|
-
f"BLOCKED: staged files violate CODE_RULES; fix before committing.\n{gate_report.strip()}"
|
|
173
|
-
)
|
|
174
|
-
return {
|
|
175
|
-
"hookSpecificOutput": {
|
|
176
|
-
"hookEventName": "PreToolUse",
|
|
177
|
-
"permissionDecision": "deny",
|
|
178
|
-
"permissionDecisionReason": denial_reason,
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
|
|
183
75
|
def main() -> None:
|
|
184
|
-
"""
|
|
76
|
+
"""Pass Git commit validation through to configured Git hooks."""
|
|
185
77
|
bash_command = parse_bash_command_from_stdin()
|
|
186
78
|
if not is_git_commit_invocation(bash_command):
|
|
187
79
|
sys.exit(0)
|
|
188
|
-
|
|
189
|
-
repository_root = resolve_repository_root(working_directory)
|
|
190
|
-
if repository_root is None:
|
|
191
|
-
sys.exit(0)
|
|
192
|
-
if not list_staged_python_files(repository_root):
|
|
193
|
-
sys.exit(0)
|
|
194
|
-
gate_exit_code, gate_report = run_staged_gate(repository_root)
|
|
195
|
-
if gate_exit_code == 0:
|
|
196
|
-
sys.exit(0)
|
|
197
|
-
denial = build_denial_response(gate_report)
|
|
198
|
-
log_hook_block(
|
|
199
|
-
calling_hook_name="precommit_code_rules_gate.py",
|
|
200
|
-
hook_event="PreToolUse",
|
|
201
|
-
block_reason=denial["hookSpecificOutput"]["permissionDecisionReason"],
|
|
202
|
-
tool_name="Bash",
|
|
203
|
-
offending_input_preview=bash_command,
|
|
204
|
-
)
|
|
205
|
-
print(json.dumps(denial))
|
|
80
|
+
sys.stderr.write("Git commit proceeds to configured Git hooks.\n")
|
|
206
81
|
sys.exit(0)
|
|
207
82
|
|
|
208
83
|
|