claude-dev-env 2.31.1 → 2.33.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/agents/clean-coder.md +8 -7
- package/.agents/agents/code-quality-agent.md +4 -2
- package/.agents/agents/pr-description-writer.md +8 -3
- package/.agents/agents/reference/pr-description-illustrative-voice.md +6 -1
- package/.agents/agents/test_agent_frontmatter.py +0 -9
- package/.agents/agents/test_pr_description_writer_style.py +24 -1
- package/.agents/skills/e-simplify/SKILL.md +1 -15
- package/.agents/skills/grok-spawn/reference/worker-briefs.md +4 -0
- package/.agents/skills/pr-refinement/SKILL.md +2 -2
- package/.agents/skills/pr-shared-extraction/SKILL.md +2 -2
- package/.agents/skills/pr-title-description/SKILL.md +6 -2
- package/.agents/skills/review-tier/SKILL.md +1 -1
- package/.agents/skills/review-tier/reference/tier-policy.json +2 -2
- package/.agents/skills/review-tier/scripts/review_tier.py +31 -4
- package/.agents/skills/review-tier/scripts/test_generation.py +7 -19
- package/.agents/skills/review-tier/scripts/test_review_tier.py +11 -11
- package/AGENTS.md +83 -0
- package/_shared/pr-loop/code-rules-gate.md +5 -2
- package/_shared/pr-loop/scripts/code_rules_gate.py +7 -3
- package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_arguments.py +13 -1
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_arguments.py +7 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +47 -1
- package/audit-rubrics/audit-categories.json +2 -2
- package/audit-rubrics/category_rubrics/category-e-dead-code.md +1 -1
- package/audit-rubrics/category_rubrics/category-j-code-rules-compliance.md +5 -3
- package/audit-rubrics/category_rubrics/category-l-behavior-equivalence.md +6 -3
- package/audit-rubrics/prompts/category-e-dead-code.md +6 -6
- package/audit-rubrics/prompts/category-j-code-rules-compliance.md +25 -19
- package/audit-rubrics/prompts/category-l-behavior-equivalence.md +28 -25
- package/bin/install.test.mjs +11 -3
- package/docs/CODE_RULES.md +8 -9
- package/hooks/blocking/bash_post_call_dispatcher.py +70 -0
- package/hooks/blocking/bash_pre_tool_use_dispatcher.py +8 -21
- package/hooks/blocking/code_rules_comments.py +118 -59
- package/hooks/blocking/code_rules_duplicate_body.py +13 -7
- package/hooks/blocking/code_rules_enforcer.py +19 -7
- package/hooks/blocking/code_rules_enforcer_test_support.py +39 -0
- package/hooks/blocking/code_rules_type_escape.py +16 -23
- package/hooks/blocking/javascript_comment_scanner.py +99 -0
- package/hooks/blocking/shell_substitution_blocker.py +175 -8
- package/hooks/blocking/stop_dispatcher.py +6 -9
- package/hooks/blocking/tdd_enforcer.py +3 -1
- package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +7 -0
- package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +141 -23
- package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +114 -1
- package/hooks/blocking/test_bash_post_call_dispatcher.py +112 -0
- package/hooks/blocking/test_code_rules_enforcer_any_type_ignore.py +24 -4
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +0 -1
- package/hooks/blocking/test_code_rules_enforcer_comment_string_awareness.py +190 -3
- package/hooks/blocking/test_code_rules_enforcer_javascript_comments.py +44 -0
- package/hooks/blocking/test_code_rules_enforcer_join_separator_magic.py +0 -1
- package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +8 -1
- package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +7 -2
- package/hooks/blocking/test_shell_substitution_blocker.py +105 -0
- package/hooks/blocking/test_state_description_blocker.py +56 -71
- package/hooks/blocking/test_tdd_enforcer.py +67 -1
- package/hooks/git-hooks/pull_request_handoff.py +1 -1
- package/hooks/git-hooks/test_post_commit.py +8 -0
- package/hooks/hooks.json +2 -2
- package/hooks/hooks_constants/bash_post_call_dispatcher_constants.py +22 -0
- package/hooks/hooks_constants/hosted_hook_runner.py +53 -0
- package/hooks/hooks_constants/piped_pytest_blocker_constants.py +9 -2
- package/hooks/hooks_constants/shell_substitution_blocker_constants.py +10 -0
- package/hooks/hooks_constants/stale_comment_reference_blocker_constants.py +3 -4
- package/hooks/hooks_constants/state_file_lock.py +76 -0
- package/hooks/hooks_constants/test_bash_post_call_dispatcher_constants.py +27 -0
- package/hooks/hooks_constants/test_failure_recorder_constants.py +35 -0
- package/hooks/hooks_constants/test_hosted_hook_runner.py +55 -1
- package/hooks/hooks_constants/test_unused_module_import_constants.py +9 -1
- package/hooks/hooks_constants/unused_module_import_constants.py +1 -1
- package/hooks/observability/session_file_edit_tracker.py +2 -55
- package/hooks/observability/test_failure_recorder.py +219 -0
- package/hooks/observability/test_test_failure_recorder.py +173 -0
- package/hooks/validators/comment_checks.py +2 -2
- package/hooks/validators/config/directory_exemption_constants.py +0 -9
- package/hooks/validators/config/system_temporary_roots_constants.py +15 -0
- package/hooks/validators/fast_save_validators.py +0 -9
- package/hooks/validators/git_checks.py +0 -84
- package/hooks/validators/mypy_integration.py +10 -36
- package/hooks/validators/project_roots.py +58 -0
- package/hooks/validators/run_all_validators.py +89 -42
- package/hooks/validators/system_temporary_roots.py +1 -1
- package/hooks/validators/test_fast_save_validators.py +2 -8
- package/hooks/validators/test_git_checks.py +0 -249
- package/hooks/validators/test_run_all_validators.py +22 -0
- package/hooks/validators/test_run_all_validators_mutation_tool_parity.py +249 -0
- package/hooks/validators/test_run_all_validators_reporting.py +66 -0
- package/hooks/validators/test_todo_checks.py +1 -0
- package/hooks/validators/todo_checks.py +1 -1
- package/package.json +1 -1
- package/rules/git-workflow.md +0 -8
- package/rules/shell-invocation.md +1 -1
- package/rules/testing.md +4 -0
- package/scripts/check.ps1 +35 -1
- package/scripts/codex_compat_materializer.py +1 -0
- package/scripts/dev_env_scripts_constants/grok_worker_constants.py +9 -5
- package/scripts/dev_env_scripts_constants/shared_tree_constants.py +17 -0
- package/scripts/shared_tree_paths.py +59 -25
- package/scripts/test_shared_tree_paths.py +30 -0
- package/scripts/tests/test_rules.py +111 -0
- package/system-prompts/software-engineer.xml +6 -19
- package/.agents/skills-archived/split-pr/SKILL.md +0 -85
- package/.agents/skills-archived/split-pr/reference/path-layers.md +0 -16
- package/.agents/skills-archived/split-pr/reference/proposal-format.md +0 -15
- package/.agents/skills-archived/split-pr/reference/split-further-loop.md +0 -10
- package/.agents/skills-archived/split-pr/reference/splitting-principles.md +0 -26
- package/.agents/skills-archived/split-pr/scripts/analyze_pr.py +0 -279
- package/.agents/skills-archived/split-pr/scripts/categorize_files.py +0 -106
- package/.agents/skills-archived/split-pr/scripts/config/__init__.py +0 -1
- package/.agents/skills-archived/split-pr/scripts/config/dependency_constants.py +0 -14
- package/.agents/skills-archived/split-pr/scripts/config/git_operations_constants.py +0 -36
- package/.agents/skills-archived/split-pr/scripts/config/packing_constants.py +0 -61
- package/.agents/skills-archived/split-pr/scripts/config/plan_constants.py +0 -49
- package/.agents/skills-archived/split-pr/scripts/config/split_pr_constants.py +0 -110
- package/.agents/skills-archived/split-pr/scripts/execute_split_slices.py +0 -82
- package/.agents/skills-archived/split-pr/scripts/pack_files_into_slices.py +0 -212
- package/.agents/skills-archived/split-pr/scripts/split_pr_dependency_graph.py +0 -70
- package/.agents/skills-archived/split-pr/scripts/split_pr_git_operations.py +0 -184
- package/.agents/skills-archived/split-pr/scripts/split_pr_layer_order.py +0 -58
- package/.agents/skills-archived/split-pr/scripts/split_pr_paginate.py +0 -119
- package/.agents/skills-archived/split-pr/scripts/split_pr_process_runner.py +0 -52
- package/.agents/skills-archived/split-pr/scripts/split_pr_script_types.py +0 -126
- package/.agents/skills-archived/split-pr/scripts/split_pr_title.py +0 -41
- package/.agents/skills-archived/split-pr/scripts/test_analyze_pr.py +0 -228
- package/.agents/skills-archived/split-pr/scripts/test_categorize_files.py +0 -55
- package/.agents/skills-archived/split-pr/scripts/test_categorize_files_packing.py +0 -59
- package/.agents/skills-archived/split-pr/scripts/test_execute_split_slices.py +0 -99
- package/.agents/skills-archived/split-pr/scripts/test_split_pr_dependency_graph.py +0 -47
- package/.agents/skills-archived/split-pr/scripts/test_split_pr_git_operations.py +0 -125
- package/.agents/skills-archived/split-pr/scripts/test_split_pr_layer_order.py +0 -36
- package/.agents/skills-archived/split-pr/scripts/test_split_pr_paginate.py +0 -65
- package/.agents/skills-archived/split-pr/scripts/test_split_pr_script_types.py +0 -73
- package/.agents/skills-archived/split-pr/scripts/test_split_pr_title.py +0 -28
- package/.agents/skills-archived/split-pr/scripts/test_verify_dependency_graph.py +0 -46
- package/.agents/skills-archived/split-pr/scripts/test_verify_plan.py +0 -56
- package/.agents/skills-archived/split-pr/scripts/test_verify_plan_contract.py +0 -50
- package/.agents/skills-archived/split-pr/scripts/test_verify_plan_path_normalization.py +0 -45
- package/.agents/skills-archived/split-pr/scripts/verify_dependency_graph.py +0 -111
- package/.agents/skills-archived/split-pr/scripts/verify_plan.py +0 -139
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Behavioral tests for validator coverage across every mutation tool.
|
|
2
|
+
|
|
3
|
+
The PreToolUse gate has to reach a MultiEdit and a Codex apply_patch the same
|
|
4
|
+
way it reaches a Write and an Edit. Two things carry that: the hooks.json
|
|
5
|
+
matcher the harness dispatches on, and the payload shape the gate reconstructs
|
|
6
|
+
post-edit content from.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import subprocess
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
from hooks_constants.pre_tool_use_dispatcher_constants import (
|
|
16
|
+
ALL_WRITE_EDIT_MULTI_EDIT_APPLY_PATCH_TOOL_NAMES,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from .run_all_validators import run_validators_entrypoint_subprocess
|
|
20
|
+
|
|
21
|
+
pytestmark = pytest.mark.usefixtures("ephemeral_exempt_off")
|
|
22
|
+
|
|
23
|
+
HOOKS_JSON_PATH = Path(__file__).resolve().parents[1] / "hooks.json"
|
|
24
|
+
VALIDATORS_RUNNER_COMMAND_MARKER = "validators.run_all_validators"
|
|
25
|
+
MATCHER_TOOL_NAME_SEPARATOR = "|"
|
|
26
|
+
DENY_DECISION_FRAGMENT = '"permissionDecision": "deny"'
|
|
27
|
+
MAGIC_VALUE_VALIDATOR_NAME = "Magic Values"
|
|
28
|
+
|
|
29
|
+
CLEAN_MODULE_SOURCE = (
|
|
30
|
+
"def clean_marker() -> int:\n computed_value = 1\n return computed_value\n"
|
|
31
|
+
)
|
|
32
|
+
CLEAN_SECOND_MODULE_SOURCE = (
|
|
33
|
+
"def second_marker() -> int:\n counted_value = 1\n return counted_value\n"
|
|
34
|
+
)
|
|
35
|
+
CLEAN_RETURN_LINE = " return computed_value\n"
|
|
36
|
+
MAGIC_VALUE_RETURN_LINE = " return computed_value * 199\n"
|
|
37
|
+
CLEAN_REWRITTEN_RETURN_LINE = " return computed_value + 1\n"
|
|
38
|
+
|
|
39
|
+
UPDATE_PATCH_TEMPLATE = (
|
|
40
|
+
"*** Begin Patch\n"
|
|
41
|
+
"*** Update File: {relative_path}\n"
|
|
42
|
+
"@@\n"
|
|
43
|
+
"-{removed_line}"
|
|
44
|
+
"+{added_line}"
|
|
45
|
+
"*** End Patch\n"
|
|
46
|
+
)
|
|
47
|
+
ADD_VIOLATING_FILE_PATCH_COMMAND = (
|
|
48
|
+
"*** Begin Patch\n"
|
|
49
|
+
"*** Add File: added_module.py\n"
|
|
50
|
+
"+def added_marker() -> int:\n"
|
|
51
|
+
"+ return 199\n"
|
|
52
|
+
"*** End Patch\n"
|
|
53
|
+
)
|
|
54
|
+
DELETE_FILE_PATCH_COMMAND = (
|
|
55
|
+
"*** Begin Patch\n*** Delete File: legacy_module.py\n*** End of File\n*** End Patch\n"
|
|
56
|
+
)
|
|
57
|
+
UNPARSEABLE_PATCH_COMMAND = "not a codex patch at all\n"
|
|
58
|
+
TWO_FILE_PATCH_COMMAND = (
|
|
59
|
+
"*** Begin Patch\n"
|
|
60
|
+
"*** Update File: first_module.py\n"
|
|
61
|
+
"@@\n"
|
|
62
|
+
"- return computed_value\n"
|
|
63
|
+
"+ return computed_value + 1\n"
|
|
64
|
+
"*** Update File: second_module.py\n"
|
|
65
|
+
"@@\n"
|
|
66
|
+
"- return counted_value\n"
|
|
67
|
+
"+ return counted_value * 199\n"
|
|
68
|
+
"*** End Patch\n"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def run_gate(payload: dict[str, object]) -> "subprocess.CompletedProcess[str]":
|
|
73
|
+
"""Drive the real --pre-tool-use entry point with one payload."""
|
|
74
|
+
return run_validators_entrypoint_subprocess(["--pre-tool-use"], stdin_text=json.dumps(payload))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def update_patch_command(relative_path: str, added_line: str) -> str:
|
|
78
|
+
"""Build an Update File patch rewriting the module's return line."""
|
|
79
|
+
return UPDATE_PATCH_TEMPLATE.format(
|
|
80
|
+
relative_path=relative_path,
|
|
81
|
+
removed_line=CLEAN_RETURN_LINE,
|
|
82
|
+
added_line=added_line,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def write_clean_module(target_directory: Path, module_name: str) -> Path:
|
|
87
|
+
"""Write the clean Python module the payloads target."""
|
|
88
|
+
target_file = target_directory / module_name
|
|
89
|
+
target_file.write_text(CLEAN_MODULE_SOURCE, encoding="utf-8")
|
|
90
|
+
return target_file
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class TestMutationToolRegistration:
|
|
94
|
+
def test_validators_gate_is_registered_on_every_mutation_tool(self) -> None:
|
|
95
|
+
hooks_document = json.loads(HOOKS_JSON_PATH.read_text(encoding="utf-8"))
|
|
96
|
+
all_matchers = [
|
|
97
|
+
each_group["matcher"]
|
|
98
|
+
for each_group in hooks_document["hooks"]["PreToolUse"]
|
|
99
|
+
for each_hook in each_group.get("hooks", [])
|
|
100
|
+
if VALIDATORS_RUNNER_COMMAND_MARKER in each_hook.get("command", "")
|
|
101
|
+
]
|
|
102
|
+
assert len(all_matchers) == 1, all_matchers
|
|
103
|
+
assert (
|
|
104
|
+
set(all_matchers[0].split(MATCHER_TOOL_NAME_SEPARATOR))
|
|
105
|
+
== ALL_WRITE_EDIT_MULTI_EDIT_APPLY_PATCH_TOOL_NAMES
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class TestMultiEditPayloadLane:
|
|
110
|
+
def test_multi_edit_introducing_a_violation_denies(self, tmp_path: Path) -> None:
|
|
111
|
+
target_file = write_clean_module(tmp_path, "legacy_module.py")
|
|
112
|
+
completed = run_gate(
|
|
113
|
+
{
|
|
114
|
+
"tool_name": "MultiEdit",
|
|
115
|
+
"tool_input": {
|
|
116
|
+
"file_path": str(target_file),
|
|
117
|
+
"edits": [
|
|
118
|
+
{
|
|
119
|
+
"old_string": CLEAN_RETURN_LINE,
|
|
120
|
+
"new_string": MAGIC_VALUE_RETURN_LINE,
|
|
121
|
+
}
|
|
122
|
+
],
|
|
123
|
+
},
|
|
124
|
+
}
|
|
125
|
+
)
|
|
126
|
+
assert completed.returncode == 0, completed.stderr
|
|
127
|
+
assert DENY_DECISION_FRAGMENT in completed.stdout
|
|
128
|
+
assert MAGIC_VALUE_VALIDATOR_NAME in completed.stdout
|
|
129
|
+
|
|
130
|
+
def test_multi_edit_leaving_clean_content_allows(self, tmp_path: Path) -> None:
|
|
131
|
+
target_file = write_clean_module(tmp_path, "clean_module.py")
|
|
132
|
+
completed = run_gate(
|
|
133
|
+
{
|
|
134
|
+
"tool_name": "MultiEdit",
|
|
135
|
+
"tool_input": {
|
|
136
|
+
"file_path": str(target_file),
|
|
137
|
+
"edits": [
|
|
138
|
+
{
|
|
139
|
+
"old_string": CLEAN_RETURN_LINE,
|
|
140
|
+
"new_string": CLEAN_REWRITTEN_RETURN_LINE,
|
|
141
|
+
}
|
|
142
|
+
],
|
|
143
|
+
},
|
|
144
|
+
}
|
|
145
|
+
)
|
|
146
|
+
assert completed.returncode == 0, completed.stderr
|
|
147
|
+
assert DENY_DECISION_FRAGMENT not in completed.stdout
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class TestApplyPatchPayloadLane:
|
|
151
|
+
def test_apply_patch_introducing_a_violation_denies(self, tmp_path: Path) -> None:
|
|
152
|
+
write_clean_module(tmp_path, "legacy_module.py")
|
|
153
|
+
completed = run_gate(
|
|
154
|
+
{
|
|
155
|
+
"tool_name": "apply_patch",
|
|
156
|
+
"tool_input": {
|
|
157
|
+
"command": update_patch_command("legacy_module.py", MAGIC_VALUE_RETURN_LINE)
|
|
158
|
+
},
|
|
159
|
+
"cwd": str(tmp_path),
|
|
160
|
+
}
|
|
161
|
+
)
|
|
162
|
+
assert completed.returncode == 0, completed.stderr
|
|
163
|
+
assert DENY_DECISION_FRAGMENT in completed.stdout
|
|
164
|
+
assert MAGIC_VALUE_VALIDATOR_NAME in completed.stdout
|
|
165
|
+
|
|
166
|
+
def test_apply_patch_deny_reason_names_the_patched_file(self, tmp_path: Path) -> None:
|
|
167
|
+
target_file = write_clean_module(tmp_path, "legacy_module.py")
|
|
168
|
+
completed = run_gate(
|
|
169
|
+
{
|
|
170
|
+
"tool_name": "apply_patch",
|
|
171
|
+
"tool_input": {
|
|
172
|
+
"command": update_patch_command("legacy_module.py", MAGIC_VALUE_RETURN_LINE)
|
|
173
|
+
},
|
|
174
|
+
"cwd": str(tmp_path),
|
|
175
|
+
}
|
|
176
|
+
)
|
|
177
|
+
assert completed.returncode == 0, completed.stderr
|
|
178
|
+
assert target_file.name in completed.stdout
|
|
179
|
+
|
|
180
|
+
def test_apply_patch_leaving_clean_content_allows(self, tmp_path: Path) -> None:
|
|
181
|
+
write_clean_module(tmp_path, "clean_module.py")
|
|
182
|
+
completed = run_gate(
|
|
183
|
+
{
|
|
184
|
+
"tool_name": "apply_patch",
|
|
185
|
+
"tool_input": {
|
|
186
|
+
"command": update_patch_command("clean_module.py", CLEAN_REWRITTEN_RETURN_LINE)
|
|
187
|
+
},
|
|
188
|
+
"cwd": str(tmp_path),
|
|
189
|
+
}
|
|
190
|
+
)
|
|
191
|
+
assert completed.returncode == 0, completed.stderr
|
|
192
|
+
assert DENY_DECISION_FRAGMENT not in completed.stdout
|
|
193
|
+
|
|
194
|
+
def test_apply_patch_adding_a_violating_file_denies(self, tmp_path: Path) -> None:
|
|
195
|
+
completed = run_gate(
|
|
196
|
+
{
|
|
197
|
+
"tool_name": "apply_patch",
|
|
198
|
+
"tool_input": {"command": ADD_VIOLATING_FILE_PATCH_COMMAND},
|
|
199
|
+
"cwd": str(tmp_path),
|
|
200
|
+
}
|
|
201
|
+
)
|
|
202
|
+
assert completed.returncode == 0, completed.stderr
|
|
203
|
+
assert DENY_DECISION_FRAGMENT in completed.stdout
|
|
204
|
+
assert MAGIC_VALUE_VALIDATOR_NAME in completed.stdout
|
|
205
|
+
|
|
206
|
+
def test_apply_patch_deleting_a_file_allows(self, tmp_path: Path) -> None:
|
|
207
|
+
write_clean_module(tmp_path, "legacy_module.py")
|
|
208
|
+
completed = run_gate(
|
|
209
|
+
{
|
|
210
|
+
"tool_name": "apply_patch",
|
|
211
|
+
"tool_input": {"command": DELETE_FILE_PATCH_COMMAND},
|
|
212
|
+
"cwd": str(tmp_path),
|
|
213
|
+
}
|
|
214
|
+
)
|
|
215
|
+
assert completed.returncode == 0, completed.stderr
|
|
216
|
+
assert DENY_DECISION_FRAGMENT not in completed.stdout
|
|
217
|
+
|
|
218
|
+
def test_unparseable_apply_patch_command_allows(self, tmp_path: Path) -> None:
|
|
219
|
+
"""A patch this lane cannot read is the enforcer's to deny, not this gate's.
|
|
220
|
+
|
|
221
|
+
``code_rules_enforcer`` denies a payload whose patch markers it cannot
|
|
222
|
+
parse. Both hooks run on the same call and any denial wins, so this lane
|
|
223
|
+
allows rather than raising a second denial for one cause. Moving that
|
|
224
|
+
denial out of the enforcer leaves a malformed patch with no reader.
|
|
225
|
+
"""
|
|
226
|
+
completed = run_gate(
|
|
227
|
+
{
|
|
228
|
+
"tool_name": "apply_patch",
|
|
229
|
+
"tool_input": {"command": UNPARSEABLE_PATCH_COMMAND},
|
|
230
|
+
"cwd": str(tmp_path),
|
|
231
|
+
}
|
|
232
|
+
)
|
|
233
|
+
assert completed.returncode == 0, completed.stderr
|
|
234
|
+
assert DENY_DECISION_FRAGMENT not in completed.stdout
|
|
235
|
+
|
|
236
|
+
def test_apply_patch_denies_a_violation_in_its_second_file(self, tmp_path: Path) -> None:
|
|
237
|
+
write_clean_module(tmp_path, "first_module.py")
|
|
238
|
+
second_file = tmp_path / "second_module.py"
|
|
239
|
+
second_file.write_text(CLEAN_SECOND_MODULE_SOURCE, encoding="utf-8")
|
|
240
|
+
completed = run_gate(
|
|
241
|
+
{
|
|
242
|
+
"tool_name": "apply_patch",
|
|
243
|
+
"tool_input": {"command": TWO_FILE_PATCH_COMMAND},
|
|
244
|
+
"cwd": str(tmp_path),
|
|
245
|
+
}
|
|
246
|
+
)
|
|
247
|
+
assert completed.returncode == 0, completed.stderr
|
|
248
|
+
assert DENY_DECISION_FRAGMENT in completed.stdout
|
|
249
|
+
assert second_file.name in completed.stdout
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Tests for the run report run_all_validators renders after a full run.
|
|
2
|
+
|
|
3
|
+
Timing metrics and the version header are the two pieces of that report. They
|
|
4
|
+
moved out of test_run_all_validators.py so the paired file keeps room for the
|
|
5
|
+
gate behavior it exists to pin.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from .run_all_validators import (
|
|
11
|
+
add_timing,
|
|
12
|
+
build_json_output,
|
|
13
|
+
create_timing_metrics,
|
|
14
|
+
format_timing_report,
|
|
15
|
+
print_header,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TestTimingMetrics:
|
|
20
|
+
def test_create_timing_metrics_empty(self) -> None:
|
|
21
|
+
metrics = create_timing_metrics({})
|
|
22
|
+
assert metrics.total_seconds == 0.0
|
|
23
|
+
assert metrics.validator_times == {}
|
|
24
|
+
|
|
25
|
+
def test_create_timing_metrics_with_data(self) -> None:
|
|
26
|
+
timings = {"Validator A": 1.5, "Validator B": 2.0}
|
|
27
|
+
metrics = create_timing_metrics(timings)
|
|
28
|
+
assert metrics.total_seconds == 3.5
|
|
29
|
+
assert metrics.validator_times == timings
|
|
30
|
+
|
|
31
|
+
def test_add_timing_returns_new_instance(self) -> None:
|
|
32
|
+
metrics1 = create_timing_metrics({})
|
|
33
|
+
metrics2 = add_timing(metrics1, "Test", 1.5)
|
|
34
|
+
|
|
35
|
+
assert metrics1.total_seconds == 0.0
|
|
36
|
+
assert metrics2.total_seconds == 1.5
|
|
37
|
+
assert "Test" not in metrics1.validator_times
|
|
38
|
+
assert metrics2.validator_times["Test"] == 1.5
|
|
39
|
+
|
|
40
|
+
def test_format_report_includes_all_timings(self) -> None:
|
|
41
|
+
metrics = create_timing_metrics({"Fast": 0.1, "Slow": 2.5})
|
|
42
|
+
report = format_timing_report(metrics)
|
|
43
|
+
|
|
44
|
+
assert "Fast" in report
|
|
45
|
+
assert "Slow" in report
|
|
46
|
+
assert "2.6" in report
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class TestVersionHeader:
|
|
50
|
+
def test_print_header_includes_version(self, capsys: pytest.CaptureFixture[str]) -> None:
|
|
51
|
+
print_header()
|
|
52
|
+
captured = capsys.readouterr()
|
|
53
|
+
|
|
54
|
+
assert "PRE-PUSH VALIDATOR RESULTS" in captured.out
|
|
55
|
+
assert "(v" in captured.out
|
|
56
|
+
|
|
57
|
+
def test_build_json_output_includes_version(self) -> None:
|
|
58
|
+
json_output = build_json_output(
|
|
59
|
+
results=[],
|
|
60
|
+
metrics=create_timing_metrics({}),
|
|
61
|
+
include_timing=False,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
assert "version" in json_output
|
|
65
|
+
assert "timestamp" in json_output
|
|
66
|
+
assert isinstance(json_output["version"], str)
|
|
@@ -27,6 +27,7 @@ class TestUntrackedTodos:
|
|
|
27
27
|
violations = check_untracked_todos(code, "test.py")
|
|
28
28
|
assert len(violations) == 1
|
|
29
29
|
assert "TODO" in violations[0].message
|
|
30
|
+
assert "remove it from source code and track the work outside the file" in violations[0].message
|
|
30
31
|
|
|
31
32
|
def test_todo_with_issue_passes(self) -> None:
|
|
32
33
|
code = "# TODO #123: fix this later\ndef foo(): pass"
|
|
@@ -24,7 +24,7 @@ def check_untracked_todos(source: str, filename: str) -> List[Violation]:
|
|
|
24
24
|
Violation(
|
|
25
25
|
filename,
|
|
26
26
|
line_num,
|
|
27
|
-
"TODO/FIXME
|
|
27
|
+
"TODO/FIXME marker found - remove it from source code and track the work outside the file",
|
|
28
28
|
)
|
|
29
29
|
)
|
|
30
30
|
|
package/package.json
CHANGED
package/rules/git-workflow.md
CHANGED
|
@@ -25,13 +25,6 @@ User-level rule: applies to **every** git repo that uses GitHub with `gh` (no ex
|
|
|
25
25
|
- Before push: `gh pr ready --undo`
|
|
26
26
|
- After review approved: `gh pr ready`
|
|
27
27
|
|
|
28
|
-
2. **ONE COMMIT PER REVIEW STAGE**: Each review round gets exactly ONE commit
|
|
29
|
-
- Initial feature: 1 commit
|
|
30
|
-
- After review #1: 2 commits (initial + review #1 fixes)
|
|
31
|
-
- After review #2: 3 commits (initial + review #1 fixes + review #2 fixes)
|
|
32
|
-
- NEVER squash multiple review stages into one commit
|
|
33
|
-
- NEVER have multiple commits for the same review stage
|
|
34
|
-
|
|
35
28
|
## Never Commit Working Documents or Images
|
|
36
29
|
|
|
37
30
|
**NEVER commit these files to the repo:**
|
|
@@ -53,7 +46,6 @@ An image a PR needs as visual evidence is not an exception to that row. Upload i
|
|
|
53
46
|
2. Create a TodoWrite checklist with one item per comment.
|
|
54
47
|
3. Fix systematically, marking each todo complete.
|
|
55
48
|
4. Reply to each comment inline.
|
|
56
|
-
5. Create one review-fix commit. Do not squash it with the original.
|
|
57
49
|
|
|
58
50
|
Repair only reported findings, then re-verify after every repair.
|
|
59
51
|
|
|
@@ -18,4 +18,4 @@ When a script file's literal body needs `$(...)`, author it with the Write tool,
|
|
|
18
18
|
|
|
19
19
|
## Enforcement
|
|
20
20
|
|
|
21
|
-
`shell_substitution_blocker.py` (PreToolUse on Bash, hosted by `bash_pre_tool_use_dispatcher`) denies a command carrying a live substitution and returns the split-into-two-calls rewrite. Single-quoted runs are stripped before the scan, and a backtick preceded by an odd number of backslashes is escaped, so an inert mention passes.
|
|
21
|
+
`shell_substitution_blocker.py` (PreToolUse on Bash, hosted by `bash_pre_tool_use_dispatcher`) denies a command carrying a live substitution and returns the split-into-two-calls rewrite. Single-quoted runs are stripped before the scan, and a backtick preceded by an odd number of backslashes is escaped, so an inert mention passes. A quoted heredoc body is dropped for the same reason: the opener forms that quote or escape the delimiter tell bash to expand nothing down to the terminator, so a backtick there is text the file receives. A bare delimiter expands its body and keeps its scan.
|
package/rules/testing.md
CHANGED
|
@@ -26,3 +26,7 @@ Tests exercise real behavior, real data, and production code paths. A test that
|
|
|
26
26
|
## The File-Level TDD Gate Reads Content
|
|
27
27
|
|
|
28
28
|
`tdd_enforcer.py` records each candidate test's content hash at every sighting and compares that hash on the next write. A first sighting requires content that differs from HEAD.
|
|
29
|
+
|
|
30
|
+
## The Gate Reads a Recorded Failing Run First
|
|
31
|
+
|
|
32
|
+
`hooks/observability/test_failure_recorder.py` runs on every Bash call and records a single unchained pytest run that names a real test file path and reports a failing exit status. It stores the command, that exit status, and the path in the same content-hash store the gate reads. The gate consults that record before its freshness fallback, and honours it while the candidate's content still matches what failed. `content_hash_store.py`'s module docstring holds the full contract.
|
package/scripts/check.ps1
CHANGED
|
@@ -25,6 +25,9 @@
|
|
|
25
25
|
.PARAMETER SkipRuff
|
|
26
26
|
Skip the ruff run.
|
|
27
27
|
|
|
28
|
+
.PARAMETER CommentPolicyBase
|
|
29
|
+
Optional git reference used as the comment-policy baseline.
|
|
30
|
+
|
|
28
31
|
.OUTPUTS
|
|
29
32
|
Per-tool status lines on stdout. Final summary line:
|
|
30
33
|
CHECK: OK
|
|
@@ -34,12 +37,14 @@
|
|
|
34
37
|
param(
|
|
35
38
|
[switch]$SkipTests,
|
|
36
39
|
[switch]$SkipMypy,
|
|
37
|
-
[switch]$SkipRuff
|
|
40
|
+
[switch]$SkipRuff,
|
|
41
|
+
[string]$CommentPolicyBase
|
|
38
42
|
)
|
|
39
43
|
|
|
40
44
|
$ErrorActionPreference = 'Stop'
|
|
41
45
|
|
|
42
46
|
$hooksRoot = Resolve-Path (Join-Path $PSScriptRoot '..' 'hooks')
|
|
47
|
+
$repositoryRoot = Resolve-Path (Join-Path $PSScriptRoot '..' '..' '..')
|
|
43
48
|
$blockingRoot = Join-Path $hooksRoot 'blocking'
|
|
44
49
|
$prLoopScriptsRoot = Resolve-Path (Join-Path $PSScriptRoot '..' '_shared' 'pr-loop' 'scripts')
|
|
45
50
|
$processTreeScriptsRoot = Resolve-Path (Join-Path $PSScriptRoot '..' '_shared' 'process-tree' 'scripts')
|
|
@@ -47,6 +52,26 @@ $processTreeScriptsRoot = Resolve-Path (Join-Path $PSScriptRoot '..' '_shared' '
|
|
|
47
52
|
$failedTools = @()
|
|
48
53
|
$firstNonZeroExitCode = 0
|
|
49
54
|
|
|
55
|
+
function Resolve-CommentPolicyBase {
|
|
56
|
+
param([string]$ExplicitBase)
|
|
57
|
+
if ($ExplicitBase) {
|
|
58
|
+
$resolvedExplicitBase = & git -C $repositoryRoot rev-parse --verify --quiet "${ExplicitBase}^{commit}" 2>$null
|
|
59
|
+
if ($LASTEXITCODE -eq 0 -and $resolvedExplicitBase) {
|
|
60
|
+
return $ExplicitBase
|
|
61
|
+
}
|
|
62
|
+
throw "Comment-policy baseline '$ExplicitBase' does not resolve in the repository."
|
|
63
|
+
}
|
|
64
|
+
foreach ($candidateBase in @('origin/main', 'HEAD^1')) {
|
|
65
|
+
$resolvedCandidateBase = & git -C $repositoryRoot rev-parse --verify --quiet "${candidateBase}^{commit}" 2>$null
|
|
66
|
+
if ($LASTEXITCODE -eq 0 -and $resolvedCandidateBase) {
|
|
67
|
+
return $candidateBase
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
throw 'Comment-policy baseline does not resolve to origin/main or HEAD^1.'
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
$commentPolicyBase = Resolve-CommentPolicyBase -ExplicitBase $CommentPolicyBase
|
|
74
|
+
|
|
50
75
|
function Invoke-Tool {
|
|
51
76
|
param(
|
|
52
77
|
[string]$Label,
|
|
@@ -78,6 +103,15 @@ if (-not $SkipRuff) {
|
|
|
78
103
|
}
|
|
79
104
|
}
|
|
80
105
|
|
|
106
|
+
Invoke-Tool -Label 'comment-policy' -Action {
|
|
107
|
+
Push-Location $prLoopScriptsRoot
|
|
108
|
+
try {
|
|
109
|
+
python code_rules_gate.py --base $commentPolicyBase --comment-policy --repo-root $repositoryRoot
|
|
110
|
+
} finally {
|
|
111
|
+
Pop-Location
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
81
115
|
if (-not $SkipMypy) {
|
|
82
116
|
Invoke-Tool -Label 'mypy' -Action {
|
|
83
117
|
Push-Location $hooksRoot
|
|
@@ -62,6 +62,7 @@ codex_hook_dependency_manifest = (
|
|
|
62
62
|
"hooks/blocking/code_rules_boolean_mustcheck.py",
|
|
63
63
|
"hooks/blocking/code_rules_command_dispatch.py",
|
|
64
64
|
"hooks/blocking/code_rules_comments.py",
|
|
65
|
+
"hooks/blocking/javascript_comment_scanner.py",
|
|
65
66
|
"hooks/blocking/code_rules_constants_config.py",
|
|
66
67
|
"hooks/blocking/codex_apply_patch.py",
|
|
67
68
|
"hooks/blocking/config/__init__.py",
|
|
@@ -8,6 +8,15 @@ here rather than inline in the modules.
|
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
+
# The installed tree's own shape is shared with every other reader of it.
|
|
12
|
+
from dev_env_scripts_constants.shared_tree_constants import ( # noqa: F401
|
|
13
|
+
CLAUDE_CONFIG_DIR_ENV_VAR,
|
|
14
|
+
PROCESS_TREE_DIRECTORY_NAME,
|
|
15
|
+
PROCESS_TREE_KILL_MODULE_FILENAME,
|
|
16
|
+
SCRIPTS_DIRECTORY_NAME,
|
|
17
|
+
SHARED_PACKAGE_DIRECTORY_NAME,
|
|
18
|
+
)
|
|
19
|
+
|
|
11
20
|
GROK_BINARY_NAME: str = "grok"
|
|
12
21
|
"""Executable name resolved on PATH for the headless grok tier."""
|
|
13
22
|
|
|
@@ -56,19 +65,14 @@ DEFAULT_PING_TIMEOUT_SECONDS: int = 120
|
|
|
56
65
|
CLAUDE_HOME_SUBDIRECTORY: str = ".claude"
|
|
57
66
|
"""Per-user directory under the home directory that holds Claude config."""
|
|
58
67
|
|
|
59
|
-
CLAUDE_CONFIG_DIR_ENV_VAR: str = "CLAUDE_CONFIG_DIR"
|
|
60
68
|
"""Environment variable naming the managed Claude config root."""
|
|
61
69
|
|
|
62
|
-
PROCESS_TREE_KILL_MODULE_FILENAME: str = "process_tree_kill.py"
|
|
63
70
|
"""Filename of the shared process-tree kill helper."""
|
|
64
71
|
|
|
65
|
-
SHARED_PACKAGE_DIRECTORY_NAME: str = "_shared"
|
|
66
72
|
"""Directory name of the shared install tree under the managed root."""
|
|
67
73
|
|
|
68
|
-
PROCESS_TREE_DIRECTORY_NAME: str = "process-tree"
|
|
69
74
|
"""Directory name of the process-tree helper package under ``_shared``."""
|
|
70
75
|
|
|
71
|
-
SCRIPTS_DIRECTORY_NAME: str = "scripts"
|
|
72
76
|
"""Directory name holding Python helpers under a process-tree package."""
|
|
73
77
|
|
|
74
78
|
MANIFEST_FILENAME: str = ".claude-dev-env-manifest.json"
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Constants for resolving directories inside the installed shared tree.
|
|
2
|
+
|
|
3
|
+
These name the shape of the installed tree itself, not any one worker or
|
|
4
|
+
skill that reads it, so every caller of shared_tree_paths shares them.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
CLAUDE_CONFIG_DIR_ENV_VAR: str = "CLAUDE_CONFIG_DIR"
|
|
10
|
+
|
|
11
|
+
SHARED_PACKAGE_DIRECTORY_NAME: str = "_shared"
|
|
12
|
+
|
|
13
|
+
SCRIPTS_DIRECTORY_NAME: str = "scripts"
|
|
14
|
+
|
|
15
|
+
PROCESS_TREE_DIRECTORY_NAME: str = "process-tree"
|
|
16
|
+
|
|
17
|
+
PROCESS_TREE_KILL_MODULE_FILENAME: str = "process_tree_kill.py"
|
|
@@ -1,11 +1,16 @@
|
|
|
1
|
-
"""Resolve
|
|
1
|
+
"""Resolve a scripts directory inside the installed shared tree.
|
|
2
|
+
|
|
3
|
+
An install can place a managed directory behind a directory junction. Path
|
|
4
|
+
resolution walks that link, so a parent computed from the resolved path
|
|
5
|
+
names the link target's parent, where the shared tree does not sit.
|
|
6
|
+
"""
|
|
2
7
|
|
|
3
8
|
from __future__ import annotations
|
|
4
9
|
|
|
5
10
|
from collections.abc import Mapping
|
|
6
11
|
from pathlib import Path
|
|
7
12
|
|
|
8
|
-
from dev_env_scripts_constants.
|
|
13
|
+
from dev_env_scripts_constants.shared_tree_constants import (
|
|
9
14
|
CLAUDE_CONFIG_DIR_ENV_VAR,
|
|
10
15
|
PROCESS_TREE_DIRECTORY_NAME,
|
|
11
16
|
PROCESS_TREE_KILL_MODULE_FILENAME,
|
|
@@ -14,57 +19,86 @@ from dev_env_scripts_constants.grok_worker_constants import (
|
|
|
14
19
|
)
|
|
15
20
|
|
|
16
21
|
|
|
17
|
-
def
|
|
22
|
+
def _shared_scripts_directory(from_root: Path, shared_subpackage_name: str) -> Path:
|
|
23
|
+
"""Return the scripts directory one named sub-package holds under a root."""
|
|
18
24
|
return (
|
|
19
25
|
from_root
|
|
20
26
|
/ SHARED_PACKAGE_DIRECTORY_NAME
|
|
21
|
-
/
|
|
27
|
+
/ shared_subpackage_name
|
|
22
28
|
/ SCRIPTS_DIRECTORY_NAME
|
|
23
29
|
)
|
|
24
30
|
|
|
25
31
|
|
|
26
|
-
def
|
|
27
|
-
return (from_directory / PROCESS_TREE_KILL_MODULE_FILENAME).is_file()
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def resolve_shared_process_tree_scripts_directory(
|
|
32
|
+
def resolve_shared_scripts_directory(
|
|
31
33
|
module_file: str | Path,
|
|
32
34
|
all_environment: Mapping[str, str],
|
|
35
|
+
shared_subpackage_name: str,
|
|
36
|
+
marker_filename: str,
|
|
37
|
+
anchor_depth: int,
|
|
33
38
|
) -> Path:
|
|
34
|
-
"""Return the first
|
|
39
|
+
"""Return the first shared scripts directory that holds the marker file.
|
|
35
40
|
|
|
36
41
|
::
|
|
37
42
|
|
|
38
|
-
scripts
|
|
39
|
-
ok:
|
|
40
|
-
flag:
|
|
43
|
+
the managed scripts directory is a junction
|
|
44
|
+
ok: an un-resolved parent keeps the link side, where _shared sits
|
|
45
|
+
flag: a resolved parent walks the link and misses _shared entirely
|
|
41
46
|
|
|
42
|
-
Tries the un-resolved parent first, then the configured managed root,
|
|
43
|
-
the resolved parent. When none
|
|
44
|
-
candidate so import failure names a readable path.
|
|
47
|
+
Tries the un-resolved parent first, then the configured managed root,
|
|
48
|
+
then the resolved parent. When none holds the marker, returns the
|
|
49
|
+
un-resolved candidate, so an import failure names a readable path.
|
|
45
50
|
|
|
46
51
|
Args:
|
|
47
52
|
module_file: Path of the importing module (``__file__``).
|
|
48
53
|
all_environment: Mapping that may hold ``CLAUDE_CONFIG_DIR``.
|
|
54
|
+
shared_subpackage_name: Directory under ``_shared`` to resolve.
|
|
55
|
+
marker_filename: File whose presence marks the right directory.
|
|
56
|
+
anchor_depth: Parent levels above the module to treat as the root.
|
|
49
57
|
|
|
50
58
|
Returns:
|
|
51
|
-
Directory that should hold
|
|
59
|
+
Directory that should hold the marker file.
|
|
52
60
|
"""
|
|
53
|
-
|
|
54
|
-
|
|
61
|
+
module_path = Path(module_file)
|
|
62
|
+
un_resolved_candidate = _shared_scripts_directory(
|
|
63
|
+
module_path.absolute().parents[anchor_depth], shared_subpackage_name
|
|
55
64
|
)
|
|
56
65
|
all_candidates = [un_resolved_candidate]
|
|
57
|
-
configured_root_text = all_environment.get(
|
|
58
|
-
CLAUDE_CONFIG_DIR_ENV_VAR, ""
|
|
59
|
-
).strip()
|
|
66
|
+
configured_root_text = all_environment.get(CLAUDE_CONFIG_DIR_ENV_VAR, "").strip()
|
|
60
67
|
if configured_root_text:
|
|
61
68
|
all_candidates.append(
|
|
62
|
-
|
|
69
|
+
_shared_scripts_directory(Path(configured_root_text), shared_subpackage_name)
|
|
63
70
|
)
|
|
64
71
|
all_candidates.append(
|
|
65
|
-
|
|
72
|
+
_shared_scripts_directory(
|
|
73
|
+
module_path.resolve().parents[anchor_depth], shared_subpackage_name
|
|
74
|
+
)
|
|
66
75
|
)
|
|
67
76
|
for each_candidate in all_candidates:
|
|
68
|
-
if
|
|
77
|
+
if (each_candidate / marker_filename).is_file():
|
|
69
78
|
return each_candidate
|
|
70
79
|
return un_resolved_candidate
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def resolve_shared_process_tree_scripts_directory(
|
|
83
|
+
module_file: str | Path,
|
|
84
|
+
all_environment: Mapping[str, str],
|
|
85
|
+
) -> Path:
|
|
86
|
+
"""Return the process-tree scripts directory that holds the kill helper.
|
|
87
|
+
|
|
88
|
+
Names the process-tree sub-package for a caller sitting one level under
|
|
89
|
+
the installed root, such as a script in the managed ``scripts`` directory.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
module_file: Path of the importing module (``__file__``).
|
|
93
|
+
all_environment: Mapping that may hold ``CLAUDE_CONFIG_DIR``.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
Directory that should hold ``process_tree_kill.py``.
|
|
97
|
+
"""
|
|
98
|
+
return resolve_shared_scripts_directory(
|
|
99
|
+
module_file,
|
|
100
|
+
all_environment,
|
|
101
|
+
PROCESS_TREE_DIRECTORY_NAME,
|
|
102
|
+
PROCESS_TREE_KILL_MODULE_FILENAME,
|
|
103
|
+
1,
|
|
104
|
+
)
|