claude-dev-env 1.61.0 → 1.62.1
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 +8 -0
- package/bin/install.mjs +1 -1
- package/hooks/blocking/code_rules_dead_config_field.py +321 -0
- package/hooks/blocking/code_rules_enforcer.py +6 -0
- package/hooks/blocking/config/verified_commit_constants.py +1 -0
- package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +432 -0
- package/hooks/blocking/test_verified_commit_gate.py +32 -0
- package/hooks/blocking/verified_commit_gate.py +11 -1
- package/hooks/hooks_constants/dead_config_field_constants.py +39 -0
- package/package.json +1 -1
- package/skills/autoconverge/reference/gotchas.md +11 -0
- package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +5 -0
- package/skills/autoconverge/workflow/test_render_report.py +25 -0
- package/skills/doc-gist/SKILL.md +3 -2
- package/skills/doc-gist/references/examples/21-decision-signoff.html +546 -0
- package/skills/doc-gist/references/examples/README.md +2 -2
- package/skills/pr-converge/SKILL.md +1 -0
- package/skills/task-build/SKILL.md +31 -0
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import stat
|
|
7
|
+
import tempfile
|
|
8
|
+
from collections.abc import Callable, Iterator
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
ENFORCER_PATH = Path(__file__).resolve().parent / "code_rules_enforcer.py"
|
|
14
|
+
specification = importlib.util.spec_from_file_location("code_rules_enforcer", ENFORCER_PATH)
|
|
15
|
+
assert specification is not None and specification.loader is not None
|
|
16
|
+
code_rules_enforcer = importlib.util.module_from_spec(specification)
|
|
17
|
+
specification.loader.exec_module(code_rules_enforcer)
|
|
18
|
+
|
|
19
|
+
PRODUCTION_FILE_PATH = "packages/app/services/report.py"
|
|
20
|
+
TEST_FILE_PATH = "packages/app/services/test_report.py"
|
|
21
|
+
MIGRATION_FILE_PATH = "packages/app/migrations/0001_initial.py"
|
|
22
|
+
|
|
23
|
+
THEME_UPDATE_CONFIG_BODY = (
|
|
24
|
+
"from dataclasses import dataclass\n"
|
|
25
|
+
"\n"
|
|
26
|
+
"@dataclass\n"
|
|
27
|
+
"class ThemeUpdateConfig:\n"
|
|
28
|
+
" portal_url: str\n"
|
|
29
|
+
" debug_port: int\n"
|
|
30
|
+
" timeout_seconds: int\n"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _strip_read_only_and_retry(
|
|
35
|
+
removal_function: Callable[[str], object],
|
|
36
|
+
target_path: str,
|
|
37
|
+
_exc_info: BaseException,
|
|
38
|
+
) -> None:
|
|
39
|
+
try:
|
|
40
|
+
os.chmod(target_path, stat.S_IWRITE)
|
|
41
|
+
removal_function(target_path)
|
|
42
|
+
except OSError:
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@pytest.fixture
|
|
47
|
+
def neutral_root() -> Iterator[Path]:
|
|
48
|
+
"""Yield a temp directory whose path carries no ``test_`` segment.
|
|
49
|
+
|
|
50
|
+
The enforcer's ``is_test_file`` keys on the full path string, and pytest's
|
|
51
|
+
own ``tmp_path`` directory name embeds the test name, which would make every
|
|
52
|
+
synthetic config path look like a test file. A neutral ``mkdtemp`` root
|
|
53
|
+
mirrors how a production config module path looks.
|
|
54
|
+
"""
|
|
55
|
+
neutral_directory = Path(tempfile.mkdtemp(prefix="deadcfg-")).resolve()
|
|
56
|
+
try:
|
|
57
|
+
yield neutral_directory
|
|
58
|
+
finally:
|
|
59
|
+
shutil.rmtree(neutral_directory, onexc=_strip_read_only_and_retry)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _check(source: str, file_path: str) -> list[str]:
|
|
63
|
+
return code_rules_enforcer.check_dead_config_dataclass_fields(source, file_path)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _build_config_package(
|
|
67
|
+
workflow_directory: Path,
|
|
68
|
+
config_body: str,
|
|
69
|
+
consumer_body: str,
|
|
70
|
+
) -> Path:
|
|
71
|
+
config_package = workflow_directory / "os_update_workflow"
|
|
72
|
+
config_package.mkdir(parents=True)
|
|
73
|
+
(config_package / "__init__.py").write_text("", encoding="utf-8")
|
|
74
|
+
config_path = config_package / "config.py"
|
|
75
|
+
config_path.write_text(config_body, encoding="utf-8")
|
|
76
|
+
(workflow_directory / "runner.py").write_text(consumer_body, encoding="utf-8")
|
|
77
|
+
return config_path
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_flags_config_field_read_by_no_production_module(neutral_root: Path) -> None:
|
|
81
|
+
consumer_body = (
|
|
82
|
+
"from os_update_workflow.config import ThemeUpdateConfig\n"
|
|
83
|
+
"\n"
|
|
84
|
+
"def run(configuration: ThemeUpdateConfig) -> None:\n"
|
|
85
|
+
" print(configuration.portal_url)\n"
|
|
86
|
+
" print(configuration.timeout_seconds)\n"
|
|
87
|
+
)
|
|
88
|
+
config_path = _build_config_package(
|
|
89
|
+
neutral_root / "workflow", THEME_UPDATE_CONFIG_BODY, consumer_body
|
|
90
|
+
)
|
|
91
|
+
issues = _check(THEME_UPDATE_CONFIG_BODY, str(config_path))
|
|
92
|
+
assert any("'debug_port'" in each_issue for each_issue in issues), (
|
|
93
|
+
f"Expected dead 'debug_port' flagged, got: {issues}"
|
|
94
|
+
)
|
|
95
|
+
assert not any(
|
|
96
|
+
"'portal_url'" in each_issue or "'timeout_seconds'" in each_issue for each_issue in issues
|
|
97
|
+
), f"Fields read in consumer must not be flagged, got: {issues}"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_does_not_flag_field_read_as_attribute_in_sibling_module(neutral_root: Path) -> None:
|
|
101
|
+
consumer_body = (
|
|
102
|
+
"from os_update_workflow.config import ThemeUpdateConfig\n"
|
|
103
|
+
"\n"
|
|
104
|
+
"def run(configuration: ThemeUpdateConfig) -> None:\n"
|
|
105
|
+
" print(configuration.portal_url)\n"
|
|
106
|
+
" print(configuration.debug_port)\n"
|
|
107
|
+
" print(configuration.timeout_seconds)\n"
|
|
108
|
+
)
|
|
109
|
+
config_path = _build_config_package(
|
|
110
|
+
neutral_root / "workflow", THEME_UPDATE_CONFIG_BODY, consumer_body
|
|
111
|
+
)
|
|
112
|
+
issues = _check(THEME_UPDATE_CONFIG_BODY, str(config_path))
|
|
113
|
+
assert issues == [], f"All fields are read in consumer, none must be flagged, got: {issues}"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_flags_field_read_only_by_test_module(neutral_root: Path) -> None:
|
|
117
|
+
workflow_directory = neutral_root / "workflow"
|
|
118
|
+
config_package = workflow_directory / "os_update_workflow"
|
|
119
|
+
config_package.mkdir(parents=True)
|
|
120
|
+
(config_package / "__init__.py").write_text("", encoding="utf-8")
|
|
121
|
+
config_path = config_package / "config.py"
|
|
122
|
+
config_path.write_text(THEME_UPDATE_CONFIG_BODY, encoding="utf-8")
|
|
123
|
+
test_body = (
|
|
124
|
+
"from os_update_workflow.config import ThemeUpdateConfig\n"
|
|
125
|
+
"\n"
|
|
126
|
+
"def test_debug_port_default() -> None:\n"
|
|
127
|
+
" cfg = ThemeUpdateConfig(portal_url='x', debug_port=9222, timeout_seconds=30)\n"
|
|
128
|
+
" assert cfg.debug_port == 9222\n"
|
|
129
|
+
" assert cfg.portal_url == 'x'\n"
|
|
130
|
+
" assert cfg.timeout_seconds == 30\n"
|
|
131
|
+
)
|
|
132
|
+
(workflow_directory / "test_config.py").write_text(test_body, encoding="utf-8")
|
|
133
|
+
issues = _check(THEME_UPDATE_CONFIG_BODY, str(config_path))
|
|
134
|
+
assert any("'debug_port'" in each_issue for each_issue in issues), (
|
|
135
|
+
f"Field read only by test code must still be flagged as dead-in-production, got: {issues}"
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_ignores_non_config_named_dataclass(neutral_root: Path) -> None:
|
|
140
|
+
source = (
|
|
141
|
+
"from dataclasses import dataclass\n"
|
|
142
|
+
"\n"
|
|
143
|
+
"@dataclass\n"
|
|
144
|
+
"class ThemeMetadata:\n"
|
|
145
|
+
" title: str\n"
|
|
146
|
+
" debug_port: int\n"
|
|
147
|
+
)
|
|
148
|
+
workflow_directory = neutral_root / "workflow"
|
|
149
|
+
workflow_directory.mkdir(parents=True)
|
|
150
|
+
module_path = workflow_directory / "metadata.py"
|
|
151
|
+
module_path.write_text(source, encoding="utf-8")
|
|
152
|
+
issues = _check(source, str(module_path))
|
|
153
|
+
assert issues == [], (
|
|
154
|
+
f"Non-Config-named dataclasses are outside scope of this check, got: {issues}"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_does_not_flag_field_read_via_string_literal(neutral_root: Path) -> None:
|
|
159
|
+
consumer_body = (
|
|
160
|
+
"from os_update_workflow.config import ThemeUpdateConfig\n"
|
|
161
|
+
"\n"
|
|
162
|
+
"def read_field(configuration: ThemeUpdateConfig, field_name: str) -> object:\n"
|
|
163
|
+
" return getattr(configuration, 'debug_port')\n"
|
|
164
|
+
)
|
|
165
|
+
config_path = _build_config_package(
|
|
166
|
+
neutral_root / "workflow", THEME_UPDATE_CONFIG_BODY, consumer_body
|
|
167
|
+
)
|
|
168
|
+
issues = _check(THEME_UPDATE_CONFIG_BODY, str(config_path))
|
|
169
|
+
assert not any("'debug_port'" in each_issue for each_issue in issues), (
|
|
170
|
+
f"String literal getattr read must count as a field read, got: {issues}"
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_does_not_flag_when_consumer_serializes_whole_instance_via_asdict(
|
|
175
|
+
neutral_root: Path,
|
|
176
|
+
) -> None:
|
|
177
|
+
consumer_body = (
|
|
178
|
+
"from dataclasses import asdict\n"
|
|
179
|
+
"from os_update_workflow.config import ThemeUpdateConfig\n"
|
|
180
|
+
"\n"
|
|
181
|
+
"def serialize(configuration: ThemeUpdateConfig) -> dict[str, object]:\n"
|
|
182
|
+
" return asdict(configuration)\n"
|
|
183
|
+
)
|
|
184
|
+
config_path = _build_config_package(
|
|
185
|
+
neutral_root / "workflow", THEME_UPDATE_CONFIG_BODY, consumer_body
|
|
186
|
+
)
|
|
187
|
+
issues = _check(THEME_UPDATE_CONFIG_BODY, str(config_path))
|
|
188
|
+
assert issues == [], (
|
|
189
|
+
f"asdict reads every field at once, so no field may be flagged, got: {issues}"
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def test_does_not_flag_when_consumer_reads_instance_dict(neutral_root: Path) -> None:
|
|
194
|
+
consumer_body = (
|
|
195
|
+
"from os_update_workflow.config import ThemeUpdateConfig\n"
|
|
196
|
+
"\n"
|
|
197
|
+
"def serialize(configuration: ThemeUpdateConfig) -> dict[str, object]:\n"
|
|
198
|
+
" return dict(configuration.__dict__)\n"
|
|
199
|
+
)
|
|
200
|
+
config_path = _build_config_package(
|
|
201
|
+
neutral_root / "workflow", THEME_UPDATE_CONFIG_BODY, consumer_body
|
|
202
|
+
)
|
|
203
|
+
issues = _check(THEME_UPDATE_CONFIG_BODY, str(config_path))
|
|
204
|
+
assert issues == [], (
|
|
205
|
+
f"__dict__ read consumes every field at once, so none may be flagged, got: {issues}"
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def test_does_not_flag_field_used_only_as_replace_keyword(neutral_root: Path) -> None:
|
|
210
|
+
consumer_body = (
|
|
211
|
+
"from dataclasses import replace\n"
|
|
212
|
+
"from os_update_workflow.config import ThemeUpdateConfig\n"
|
|
213
|
+
"\n"
|
|
214
|
+
"def repoint(configuration: ThemeUpdateConfig) -> ThemeUpdateConfig:\n"
|
|
215
|
+
" return replace(configuration, debug_port=9999)\n"
|
|
216
|
+
)
|
|
217
|
+
config_path = _build_config_package(
|
|
218
|
+
neutral_root / "workflow", THEME_UPDATE_CONFIG_BODY, consumer_body
|
|
219
|
+
)
|
|
220
|
+
issues = _check(THEME_UPDATE_CONFIG_BODY, str(config_path))
|
|
221
|
+
assert not any("'debug_port'" in each_issue for each_issue in issues), (
|
|
222
|
+
f"replace keyword usage of debug_port must count as a read, got: {issues}"
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def test_does_not_flag_field_used_only_as_constructor_keyword(neutral_root: Path) -> None:
|
|
227
|
+
consumer_body = (
|
|
228
|
+
"from os_update_workflow.config import ThemeUpdateConfig\n"
|
|
229
|
+
"\n"
|
|
230
|
+
"def build() -> ThemeUpdateConfig:\n"
|
|
231
|
+
" return ThemeUpdateConfig(portal_url='x', debug_port=1, timeout_seconds=99)\n"
|
|
232
|
+
)
|
|
233
|
+
config_path = _build_config_package(
|
|
234
|
+
neutral_root / "workflow", THEME_UPDATE_CONFIG_BODY, consumer_body
|
|
235
|
+
)
|
|
236
|
+
issues = _check(THEME_UPDATE_CONFIG_BODY, str(config_path))
|
|
237
|
+
assert issues == [], (
|
|
238
|
+
f"Constructor keyword arguments name every field, so none may be flagged, got: {issues}"
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def test_returns_empty_list_at_file_cap(
|
|
243
|
+
neutral_root: Path, monkeypatch: pytest.MonkeyPatch
|
|
244
|
+
) -> None:
|
|
245
|
+
monkeypatch.setattr("code_rules_dead_config_field.MAX_SCAN_ROOT_FILE_COUNT", 0)
|
|
246
|
+
config_path = _build_config_package(
|
|
247
|
+
neutral_root / "workflow",
|
|
248
|
+
THEME_UPDATE_CONFIG_BODY,
|
|
249
|
+
"def noop() -> None:\n pass\n",
|
|
250
|
+
)
|
|
251
|
+
issues = _check(THEME_UPDATE_CONFIG_BODY, str(config_path))
|
|
252
|
+
assert issues == [], f"File cap hit must return [] (cannot prove dead), got: {issues}"
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def test_returns_empty_list_on_syntax_error(neutral_root: Path) -> None:
|
|
256
|
+
workflow_directory = neutral_root / "workflow"
|
|
257
|
+
workflow_directory.mkdir(parents=True)
|
|
258
|
+
broken_path = workflow_directory / "config.py"
|
|
259
|
+
broken_source = "@dataclass\nclass BrokenConfig(\n"
|
|
260
|
+
broken_path.write_text(broken_source, encoding="utf-8")
|
|
261
|
+
issues = _check(broken_source, str(broken_path))
|
|
262
|
+
assert issues == [], f"SyntaxError must return [], got: {issues}"
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def test_is_skipped_on_test_file_destination() -> None:
|
|
266
|
+
issues = _check(THEME_UPDATE_CONFIG_BODY, TEST_FILE_PATH)
|
|
267
|
+
assert issues == [], f"Test file destinations are exempt, got: {issues}"
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def test_is_skipped_on_migration_file_destination() -> None:
|
|
271
|
+
issues = _check(THEME_UPDATE_CONFIG_BODY, MIGRATION_FILE_PATH)
|
|
272
|
+
assert issues == [], f"Migration file destinations are exempt, got: {issues}"
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def test_real_world_shape_theme_update_config_with_dead_debug_port(neutral_root: Path) -> None:
|
|
276
|
+
workflow_directory = neutral_root / "workflow"
|
|
277
|
+
config_package = workflow_directory / "os_update_workflow"
|
|
278
|
+
config_package.mkdir(parents=True)
|
|
279
|
+
(config_package / "__init__.py").write_text("", encoding="utf-8")
|
|
280
|
+
config_path = config_package / "config.py"
|
|
281
|
+
config_path.write_text(THEME_UPDATE_CONFIG_BODY, encoding="utf-8")
|
|
282
|
+
orchestrator_body = (
|
|
283
|
+
"from os_update_workflow.config import ThemeUpdateConfig\n"
|
|
284
|
+
"\n"
|
|
285
|
+
"def build_session(configuration: ThemeUpdateConfig) -> None:\n"
|
|
286
|
+
" url = configuration.portal_url\n"
|
|
287
|
+
" seconds = configuration.timeout_seconds\n"
|
|
288
|
+
" print(url, seconds)\n"
|
|
289
|
+
)
|
|
290
|
+
(workflow_directory / "orchestrator.py").write_text(orchestrator_body, encoding="utf-8")
|
|
291
|
+
issues = _check(THEME_UPDATE_CONFIG_BODY, str(config_path))
|
|
292
|
+
assert any("'debug_port'" in each_issue for each_issue in issues), (
|
|
293
|
+
f"debug_port field unused in production must be flagged, got: {issues}"
|
|
294
|
+
)
|
|
295
|
+
assert not any(
|
|
296
|
+
"'portal_url'" in each_issue or "'timeout_seconds'" in each_issue for each_issue in issues
|
|
297
|
+
), f"Fields read in orchestrator must not be flagged, got: {issues}"
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def test_does_not_flag_field_used_only_via_augmented_assignment(neutral_root: Path) -> None:
|
|
301
|
+
consumer_body = (
|
|
302
|
+
"from os_update_workflow.config import ThemeUpdateConfig\n"
|
|
303
|
+
"\n"
|
|
304
|
+
"def run(configuration: ThemeUpdateConfig) -> None:\n"
|
|
305
|
+
" print(configuration.portal_url)\n"
|
|
306
|
+
" print(configuration.timeout_seconds)\n"
|
|
307
|
+
" configuration.debug_port += 1\n"
|
|
308
|
+
)
|
|
309
|
+
config_path = _build_config_package(
|
|
310
|
+
neutral_root / "workflow", THEME_UPDATE_CONFIG_BODY, consumer_body
|
|
311
|
+
)
|
|
312
|
+
issues = _check(THEME_UPDATE_CONFIG_BODY, str(config_path))
|
|
313
|
+
assert not any("'debug_port'" in each_issue for each_issue in issues), (
|
|
314
|
+
f"Augmented assignment reads debug_port before writing, so it is not dead, got: {issues}"
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def test_flags_field_read_only_via_whole_instance_comparison(neutral_root: Path) -> None:
|
|
319
|
+
"""A field read ONLY via whole-instance comparison IS flagged (accepted limitation).
|
|
320
|
+
|
|
321
|
+
Unlike the per-file dead-dataclass-field check, this cross-module check does
|
|
322
|
+
not suppress on a dataclass-dunder whole-instance read: instance comparison
|
|
323
|
+
(``left == right``) is not bound to a config instance, and tree-wide one
|
|
324
|
+
incidental ``==`` anywhere would disable the check on any realistic package.
|
|
325
|
+
A ``*Config`` field whose only production read is this whole-instance
|
|
326
|
+
comparison, and that is never read directly, is therefore flagged — a
|
|
327
|
+
documented, rare limitation.
|
|
328
|
+
"""
|
|
329
|
+
consumer_body = (
|
|
330
|
+
"from os_update_workflow.config import ThemeUpdateConfig\n"
|
|
331
|
+
"\n"
|
|
332
|
+
"def is_same(left: ThemeUpdateConfig, right: ThemeUpdateConfig) -> bool:\n"
|
|
333
|
+
" return left == right\n"
|
|
334
|
+
)
|
|
335
|
+
config_path = _build_config_package(
|
|
336
|
+
neutral_root / "workflow", THEME_UPDATE_CONFIG_BODY, consumer_body
|
|
337
|
+
)
|
|
338
|
+
issues = _check(THEME_UPDATE_CONFIG_BODY, str(config_path))
|
|
339
|
+
assert any("'debug_port'" in each_issue for each_issue in issues), (
|
|
340
|
+
f"Field read only via whole-instance comparison is flagged, got: {issues}"
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def test_flags_field_read_only_via_whole_instance_stringification(neutral_root: Path) -> None:
|
|
345
|
+
"""A field read ONLY via whole-instance stringification IS flagged (accepted limitation).
|
|
346
|
+
|
|
347
|
+
The cross-module check does not suppress on a formatted-string conversion of
|
|
348
|
+
a whole instance (``f'{configuration}'``): the f-string is not bound to a
|
|
349
|
+
config instance, and tree-wide one incidental f-string anywhere would disable
|
|
350
|
+
the check on any realistic package. A ``*Config`` field whose only production
|
|
351
|
+
read is this whole-instance stringification, and that is never read directly,
|
|
352
|
+
is therefore flagged — a documented, rare limitation.
|
|
353
|
+
"""
|
|
354
|
+
consumer_body = (
|
|
355
|
+
"from os_update_workflow.config import ThemeUpdateConfig\n"
|
|
356
|
+
"\n"
|
|
357
|
+
"def describe(configuration: ThemeUpdateConfig) -> str:\n"
|
|
358
|
+
" return f'{configuration}'\n"
|
|
359
|
+
)
|
|
360
|
+
config_path = _build_config_package(
|
|
361
|
+
neutral_root / "workflow", THEME_UPDATE_CONFIG_BODY, consumer_body
|
|
362
|
+
)
|
|
363
|
+
issues = _check(THEME_UPDATE_CONFIG_BODY, str(config_path))
|
|
364
|
+
assert any("'debug_port'" in each_issue for each_issue in issues), (
|
|
365
|
+
f"Field read only via whole-instance stringification is flagged, got: {issues}"
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def test_unrelated_string_method_does_not_suppress_so_dead_field_flagged(
|
|
370
|
+
neutral_root: Path,
|
|
371
|
+
) -> None:
|
|
372
|
+
"""An unrelated ``.replace(...)`` on a string must not suppress the tree.
|
|
373
|
+
|
|
374
|
+
``"some text".replace("a", "b")`` is a string method, not a ``dataclasses``-
|
|
375
|
+
qualified reflective consumer, so it does not make the check treat the tree
|
|
376
|
+
as a whole-instance read. A genuinely dead ``debug_port`` is still flagged.
|
|
377
|
+
"""
|
|
378
|
+
consumer_body = (
|
|
379
|
+
"from os_update_workflow.config import ThemeUpdateConfig\n"
|
|
380
|
+
"\n"
|
|
381
|
+
"def run(configuration: ThemeUpdateConfig) -> str:\n"
|
|
382
|
+
" print(configuration.portal_url)\n"
|
|
383
|
+
" print(configuration.timeout_seconds)\n"
|
|
384
|
+
" return 'some text'.replace('a', 'b')\n"
|
|
385
|
+
)
|
|
386
|
+
config_path = _build_config_package(
|
|
387
|
+
neutral_root / "workflow", THEME_UPDATE_CONFIG_BODY, consumer_body
|
|
388
|
+
)
|
|
389
|
+
issues = _check(THEME_UPDATE_CONFIG_BODY, str(config_path))
|
|
390
|
+
assert any("'debug_port'" in each_issue for each_issue in issues), (
|
|
391
|
+
f"Unrelated string .replace must not suppress, so dead debug_port is flagged, got: {issues}"
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def test_dataclasses_qualified_replace_call_suppresses_so_no_field_flagged(
|
|
396
|
+
neutral_root: Path,
|
|
397
|
+
) -> None:
|
|
398
|
+
"""A ``dataclasses.replace(cfg, ...)`` call suppresses the whole tree.
|
|
399
|
+
|
|
400
|
+
A genuine ``dataclasses``-qualified reflective consumer reads every field at
|
|
401
|
+
once, so the check is suppressed for the whole tree and no field is flagged.
|
|
402
|
+
"""
|
|
403
|
+
consumer_body = (
|
|
404
|
+
"import dataclasses\n"
|
|
405
|
+
"from os_update_workflow.config import ThemeUpdateConfig\n"
|
|
406
|
+
"\n"
|
|
407
|
+
"def repoint(configuration: ThemeUpdateConfig) -> ThemeUpdateConfig:\n"
|
|
408
|
+
" return dataclasses.replace(configuration, debug_port=9999)\n"
|
|
409
|
+
)
|
|
410
|
+
config_path = _build_config_package(
|
|
411
|
+
neutral_root / "workflow", THEME_UPDATE_CONFIG_BODY, consumer_body
|
|
412
|
+
)
|
|
413
|
+
issues = _check(THEME_UPDATE_CONFIG_BODY, str(config_path))
|
|
414
|
+
assert issues == [], (
|
|
415
|
+
f"dataclasses.replace reads every field at once, so none may be flagged, got: {issues}"
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def test_validate_content_dispatch_runs_dead_config_field_check(neutral_root: Path) -> None:
|
|
420
|
+
workflow_directory = neutral_root / "workflow"
|
|
421
|
+
config_package = workflow_directory / "os_update_workflow"
|
|
422
|
+
config_package.mkdir(parents=True)
|
|
423
|
+
(config_package / "__init__.py").write_text("", encoding="utf-8")
|
|
424
|
+
config_path = config_package / "config.py"
|
|
425
|
+
config_path.write_text(THEME_UPDATE_CONFIG_BODY, encoding="utf-8")
|
|
426
|
+
(workflow_directory / "runner.py").write_text(
|
|
427
|
+
"def noop() -> None:\n pass\n", encoding="utf-8"
|
|
428
|
+
)
|
|
429
|
+
issues = code_rules_enforcer.validate_content(THEME_UPDATE_CONFIG_BODY, str(config_path))
|
|
430
|
+
assert any("'debug_port'" in each_issue for each_issue in issues), (
|
|
431
|
+
f"Expected the enforcer dispatch to surface the dead config field, got: {issues}"
|
|
432
|
+
)
|
|
@@ -6,6 +6,7 @@ decide what to gate.
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
import importlib.util
|
|
9
|
+
import io
|
|
9
10
|
import json
|
|
10
11
|
import os
|
|
11
12
|
import pathlib
|
|
@@ -28,6 +29,7 @@ gate_module = importlib.util.module_from_spec(gate_spec)
|
|
|
28
29
|
gate_spec.loader.exec_module(gate_module)
|
|
29
30
|
gated_repo_directories = gate_module.gated_repo_directories
|
|
30
31
|
deny_reason_for_directory = gate_module.deny_reason_for_directory
|
|
32
|
+
gate_main = gate_module.main
|
|
31
33
|
|
|
32
34
|
store_spec = importlib.util.spec_from_file_location(
|
|
33
35
|
"verification_verdict_store",
|
|
@@ -493,3 +495,33 @@ def test_no_verdict_of_either_kind_denies_the_commit(
|
|
|
493
495
|
deny_reason = deny_reason_for_directory(str(work_dir), str(transcript_path))
|
|
494
496
|
assert deny_reason is not None
|
|
495
497
|
assert "VERIFIED_COMMIT_GATE" in deny_reason
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _run_gate_main(
|
|
501
|
+
monkeypatch: pytest.MonkeyPatch, command_text: str, work_dir: pathlib.Path
|
|
502
|
+
) -> None:
|
|
503
|
+
payload_text = json.dumps(
|
|
504
|
+
{
|
|
505
|
+
"tool_name": "Bash",
|
|
506
|
+
"tool_input": {"command": command_text},
|
|
507
|
+
"cwd": str(work_dir),
|
|
508
|
+
"transcript_path": "",
|
|
509
|
+
}
|
|
510
|
+
)
|
|
511
|
+
monkeypatch.setattr(sys, "stdin", io.StringIO(payload_text))
|
|
512
|
+
gate_main()
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def test_verification_bypass_marker_allows_an_otherwise_gated_commit(
|
|
516
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
517
|
+
capsys: pytest.CaptureFixture[str],
|
|
518
|
+
tmp_path: pathlib.Path,
|
|
519
|
+
) -> None:
|
|
520
|
+
fake_home = tmp_path / "home"
|
|
521
|
+
fake_home.mkdir()
|
|
522
|
+
_isolate_home(monkeypatch, fake_home)
|
|
523
|
+
work_dir = _make_gated_repo(tmp_path)
|
|
524
|
+
_run_gate_main(monkeypatch, "git commit -m x", work_dir)
|
|
525
|
+
assert "VERIFIED_COMMIT_GATE" in capsys.readouterr().out
|
|
526
|
+
_run_gate_main(monkeypatch, "git commit -m x # verify-skip", work_dir)
|
|
527
|
+
assert capsys.readouterr().out == ""
|
|
@@ -5,6 +5,8 @@ Fires on Bash and PowerShell tool calls. When the command carries a
|
|
|
5
5
|
targets, computes the live change-surface manifest against the merge base,
|
|
6
6
|
and allows the command only when one of these holds:
|
|
7
7
|
|
|
8
|
+
- the command carries the verification bypass marker (``# verify-skip``),
|
|
9
|
+
a manual on-the-fly override that skips the gate for that one command,
|
|
8
10
|
- the repository has no resolvable upstream base — no ``origin/HEAD``, no
|
|
9
11
|
configured tracking ref, and neither ``origin/main`` nor ``origin/master``
|
|
10
12
|
(scratch repos with no remote branch are out of scope),
|
|
@@ -47,6 +49,7 @@ from config.verified_commit_constants import (
|
|
|
47
49
|
OPTION_WITH_VALUE_STEP,
|
|
48
50
|
REPO_DIRECTORY_OPTION,
|
|
49
51
|
VALUE_TAKING_GIT_OPTIONS,
|
|
52
|
+
VERIFICATION_BYPASS_MARKER,
|
|
50
53
|
WORK_TREE_OPTION,
|
|
51
54
|
)
|
|
52
55
|
from verification_verdict_store import (
|
|
@@ -504,7 +507,12 @@ def deny_reason_for_directory(target_directory: str, transcript_path: str) -> st
|
|
|
504
507
|
|
|
505
508
|
|
|
506
509
|
def main() -> None:
|
|
507
|
-
"""Read the PreToolUse payload and
|
|
510
|
+
"""Read the PreToolUse payload and decide whether to allow the command.
|
|
511
|
+
|
|
512
|
+
Allows the command without a verdict when it carries the verification
|
|
513
|
+
bypass marker (``VERIFICATION_BYPASS_MARKER``), a manual on-the-fly
|
|
514
|
+
override; otherwise denies an unverified commit or push.
|
|
515
|
+
"""
|
|
508
516
|
try:
|
|
509
517
|
pretooluse_payload = json.load(sys.stdin)
|
|
510
518
|
except json.JSONDecodeError:
|
|
@@ -514,6 +522,8 @@ def main() -> None:
|
|
|
514
522
|
command_text = pretooluse_payload.get("tool_input", {}).get("command", "")
|
|
515
523
|
if not command_text:
|
|
516
524
|
return
|
|
525
|
+
if VERIFICATION_BYPASS_MARKER in command_text:
|
|
526
|
+
return
|
|
517
527
|
session_directory = pretooluse_payload.get("cwd", ".")
|
|
518
528
|
transcript_path = pretooluse_payload.get("transcript_path", "")
|
|
519
529
|
for each_target_directory in gated_repo_directories(command_text, session_directory):
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Constants for the dead config-dataclass field detector in ``code_rules_enforcer``.
|
|
2
|
+
|
|
3
|
+
Lives under the hooks-tree ``hooks_constants`` package so module-level
|
|
4
|
+
UPPER_SNAKE constants satisfy the CODE_RULES "constants live in config"
|
|
5
|
+
requirement and share a home with the other hook-tree configuration.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from hooks_constants.dead_dataclass_field_constants import (
|
|
9
|
+
ALL_REFLECTIVE_FIELD_CONSUMER_NAMES,
|
|
10
|
+
WHOLE_INSTANCE_DICT_ATTRIBUTE_NAME,
|
|
11
|
+
)
|
|
12
|
+
from hooks_constants.dead_module_constant_constants import (
|
|
13
|
+
CONFIG_DIRECTORY_SEGMENT,
|
|
14
|
+
DUNDER_INIT_FILENAME,
|
|
15
|
+
MAX_SCAN_ROOT_FILE_COUNT,
|
|
16
|
+
PYTHON_SOURCE_SUFFIX,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
CONFIG_CLASS_NAME_SUFFIX: str = "Config"
|
|
20
|
+
DATACLASSES_MODULE_NAME: str = "dataclasses"
|
|
21
|
+
MAX_DEAD_CONFIG_FIELD_ISSUES: int = 25
|
|
22
|
+
DEAD_CONFIG_FIELD_GUIDANCE: str = (
|
|
23
|
+
"config dataclass field is defined but read by no production module in the"
|
|
24
|
+
" enclosing package tree - remove the dead field, or read it where the value"
|
|
25
|
+
" is needed (CODE_RULES §9.8)"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"ALL_REFLECTIVE_FIELD_CONSUMER_NAMES",
|
|
30
|
+
"CONFIG_CLASS_NAME_SUFFIX",
|
|
31
|
+
"CONFIG_DIRECTORY_SEGMENT",
|
|
32
|
+
"DATACLASSES_MODULE_NAME",
|
|
33
|
+
"DEAD_CONFIG_FIELD_GUIDANCE",
|
|
34
|
+
"DUNDER_INIT_FILENAME",
|
|
35
|
+
"MAX_DEAD_CONFIG_FIELD_ISSUES",
|
|
36
|
+
"MAX_SCAN_ROOT_FILE_COUNT",
|
|
37
|
+
"PYTHON_SOURCE_SUFFIX",
|
|
38
|
+
"WHOLE_INSTANCE_DICT_ATTRIBUTE_NAME",
|
|
39
|
+
]
|
package/package.json
CHANGED
|
@@ -45,3 +45,14 @@ fails in a new way.
|
|
|
45
45
|
- **`gh` token drift across accounts.** When a run touches more than one GitHub
|
|
46
46
|
account, pin the token with `--user <login>`; `gh auth token` alone can return
|
|
47
47
|
another account's token after a switch.
|
|
48
|
+
|
|
49
|
+
- **The verified-commit gate can block the fix from landing.** The fix lens
|
|
50
|
+
commits and pushes through the `verified_commit_gate` hook, which denies a
|
|
51
|
+
`git commit`/`git push` until a `code-verifier` verdict covers the branch
|
|
52
|
+
surface. A run can reach a clean fix yet fail to land it — the push stays
|
|
53
|
+
blocked when no verdict is minted for that surface. A manual override exists:
|
|
54
|
+
a trailing `# verify-skip` comment on the commit or push command skips the
|
|
55
|
+
gate for that one command. Autoconverge must never apply that override on its
|
|
56
|
+
own. When landing a fix needs it, stop and tell the user the verified-commit
|
|
57
|
+
gate is blocking the push and that going forward needs either a `# verify-skip`
|
|
58
|
+
bypass or a switch to `/pr-converge`, then let the user decide.
|
package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py
CHANGED
|
@@ -215,6 +215,11 @@ HTML_STYLE_BLOCK = """\
|
|
|
215
215
|
.cause { background:#fff; border:1px solid #e2e8f0; border-radius:10px; padding:14px 18px; margin-top:16px; font-size:14px; color:#475569; }
|
|
216
216
|
.cause b { color:#0f172a; }
|
|
217
217
|
|
|
218
|
+
.appendix { background:#fff; border:1px solid #e2e8f0; border-radius:10px; padding:14px 18px; margin-top:16px; font-size:13px; color:#475569; }
|
|
219
|
+
.appendix summary { font-weight:600; color:#0f172a; cursor:pointer; }
|
|
220
|
+
.appendix-body { margin-top:10px; }
|
|
221
|
+
.appendix-item { font-family:'JetBrains Mono',monospace; font-size:12px; color:#475569; padding:5px 0; border-top:1px solid #f1f5f9; }
|
|
222
|
+
|
|
218
223
|
footer { margin-top:40px; padding-top:16px; border-top:1px solid #e2e8f0; color:#94a3b8; font-size:12px; }
|
|
219
224
|
footer code { background:#e2e8f0; padding:1px 6px; border-radius:4px; font-family:'JetBrains Mono',monospace; }
|
|
220
225
|
@media (max-width:680px){ .pf-grid,.term-grid{grid-template-columns:1fr;} }
|
|
@@ -43,6 +43,31 @@ def _render_cli(journal_path: Path, out_path: Path) -> subprocess.CompletedProce
|
|
|
43
43
|
)
|
|
44
44
|
|
|
45
45
|
|
|
46
|
+
def test_rendered_report_defines_every_referenced_css_class(tmp_path: Path) -> None:
|
|
47
|
+
"""Every class the rendered report markup references resolves to a CSS selector.
|
|
48
|
+
|
|
49
|
+
Renders the report from the findings fixture so the raw-findings appendix is
|
|
50
|
+
present, then asserts no class attribute names a style the stylesheet omits,
|
|
51
|
+
keeping the report markup and HTML_STYLE_BLOCK from drifting apart.
|
|
52
|
+
"""
|
|
53
|
+
out_path = tmp_path / "report.html"
|
|
54
|
+
completed = _render_cli(FIXTURE_JOURNAL, out_path)
|
|
55
|
+
assert completed.returncode == 0, f"CLI failed:\n{completed.stderr}"
|
|
56
|
+
html_content = out_path.read_text(encoding="utf-8")
|
|
57
|
+
|
|
58
|
+
style_match = re.search(r"<style>(.*?)</style>", html_content, re.DOTALL)
|
|
59
|
+
assert style_match is not None
|
|
60
|
+
defined_classes = set(re.findall(r"\.([A-Za-z][\w-]*)", style_match.group(1)))
|
|
61
|
+
referenced_classes = {
|
|
62
|
+
each_name
|
|
63
|
+
for attribute_value in re.findall(r'class="([^"]*)"', html_content)
|
|
64
|
+
for each_name in attribute_value.split()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
orphan_classes = referenced_classes - defined_classes
|
|
68
|
+
assert not orphan_classes, f"classes referenced but undefined: {sorted(orphan_classes)}"
|
|
69
|
+
|
|
70
|
+
|
|
46
71
|
def _copy_run_tree_without_summary_entry(destination_root: Path) -> Path:
|
|
47
72
|
"""Copy the fixture run tree, dropping the convergence-summary workflowProgress entry.
|
|
48
73
|
|
package/skills/doc-gist/SKILL.md
CHANGED
|
@@ -58,7 +58,7 @@ Reads HTML from `--input <path>` or stdin (`--input -`), runs `gh gist create`,
|
|
|
58
58
|
|
|
59
59
|
## Designing fresh — the example gallery
|
|
60
60
|
|
|
61
|
-
The skill ships [`references/examples/`](references/examples/) with
|
|
61
|
+
The skill ships [`references/examples/`](references/examples/) with 21 html-effectiveness examples: Thariq's 20 prototypes verbatim from [thariqs.github.io/html-effectiveness](https://thariqs.github.io/html-effectiveness/) (`01`–`20`), plus one original addition (`21-decision-signoff.html`). They are *examples to learn from, not templates to fill.*
|
|
62
62
|
|
|
63
63
|
When the user requests an artifact, decide the shape that fits. Use the gallery for grounding:
|
|
64
64
|
|
|
@@ -84,6 +84,7 @@ When the user requests an artifact, decide the shape that fits. Use the gallery
|
|
|
84
84
|
| Triage / kanban board (drag-drop) | `18-editor-triage-board.html` |
|
|
85
85
|
| Feature flag toggles with deps | `19-editor-feature-flags.html` |
|
|
86
86
|
| Live-updating template editor | `20-editor-prompt-tuner.html` |
|
|
87
|
+
| Decision / sign-off doc (accept-or-change each call, export digest) | `21-decision-signoff.html` |
|
|
87
88
|
|
|
88
89
|
Read the matching example for the artifact you're designing. Crib palette, typography, spatial idioms, component patterns. **Adapt — do not copy.** A PR writeup for a hooks PR shouldn't look identical to one for a notification-queue PR. The gallery teaches what shapes work; the request decides which shape fits.
|
|
89
90
|
|
|
@@ -92,5 +93,5 @@ Read the matching example for the artifact you're designing. Crib palette, typog
|
|
|
92
93
|
- `SKILL.md` — this file.
|
|
93
94
|
- `skills/doc-gist/scripts/gist_upload.py` — transport: HTML in, gist + preview URLs out.
|
|
94
95
|
- `skills/doc-gist/scripts/doc_gist_scripts_constants/gist_upload_constants.py` — the URL prefixes and template strings.
|
|
95
|
-
- `references/examples/` — Thariq's 20 html-effectiveness prototypes.
|
|
96
|
+
- `references/examples/` — Thariq's 20 html-effectiveness prototypes (`01`–`20`) plus one original addition (`21-decision-signoff.html`).
|
|
96
97
|
- (PostToolUse hook lives in `packages/claude-dev-env/hooks/workflow/doc_gist_auto_publish.py` — wired into the plugin's `hooks.json`.)
|