claude-dev-env 1.60.0 → 1.61.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +4 -0
- package/audit-rubrics/category_rubrics/category-f-silent-failures.md +1 -1
- package/audit-rubrics/prompts/category-e-dead-code.md +17 -4
- package/audit-rubrics/prompts/category-f-silent-failures.md +1 -0
- package/docs/CODE_RULES.md +2 -2
- package/hooks/blocking/code_rules_annotations_length.py +189 -10
- package/hooks/blocking/code_rules_enforcer.py +8 -0
- package/hooks/blocking/code_rules_orphan_css_class.py +196 -0
- package/hooks/blocking/config/verified_commit_constants.py +14 -2
- package/hooks/blocking/destructive_command_blocker.py +483 -61
- package/hooks/blocking/test_code_rules_enforcer_annotations.py +240 -0
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +1 -0
- package/hooks/blocking/test_code_rules_enforcer_dispatch_wiring.py +82 -0
- package/hooks/blocking/test_code_rules_enforcer_orphan_css_class.py +196 -0
- package/hooks/blocking/test_destructive_command_blocker.py +213 -0
- package/hooks/blocking/test_verification_verdict_store.py +212 -0
- package/hooks/blocking/test_verified_commit_gate.py +127 -0
- package/hooks/blocking/test_verifier_verdict_minter.py +74 -95
- package/hooks/blocking/verification_verdict_store.py +240 -0
- package/hooks/blocking/verified_commit_gate.py +20 -8
- package/hooks/blocking/verifier_verdict_minter.py +46 -124
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +6 -0
- package/hooks/hooks_constants/destructive_command_segment_constants.py +15 -0
- package/hooks/hooks_constants/orphan_css_class_constants.py +40 -0
- package/hooks/validation/mypy_validator.py +59 -7
- package/hooks/validation/test_mypy_validator.py +94 -0
- package/package.json +1 -1
- package/rules/orphan-css-class.md +23 -0
- package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +0 -1
- package/skills/autoconverge/workflow/converge.contract.test.mjs +202 -13
- package/skills/autoconverge/workflow/converge.mjs +392 -51
- package/skills/autoconverge/workflow/test_render_report.py +30 -0
|
@@ -2,18 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
Only this hook writes verdict files — the main session is denied writes to
|
|
4
4
|
the verdict directory, so a session cannot fabricate a passing verdict. The
|
|
5
|
-
SubagentStop payload names the stopping subagent
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
SubagentStop payload names the stopping subagent's own transcript
|
|
6
|
+
(``agent_transcript_path``), which sits beside a harness-written
|
|
7
|
+
``agent-<id>.meta.json`` sidecar naming the spawning ``agentType``. The hook
|
|
8
|
+
reads that type from the sidecar, so it resolves identically in interactive,
|
|
9
|
+
background, and worktree-switched sessions. When that type is
|
|
9
10
|
``code-verifier``, the hook pulls the verdict block out of the agent's own
|
|
10
|
-
transcript
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
``git push`` only while the work tree still matches the verified state.
|
|
11
|
+
transcript (``agent_transcript_path``); the main session writes neither that
|
|
12
|
+
transcript nor the sidecar, so text it prints can never mint — recomputes the
|
|
13
|
+
live change-surface hash for the session repository, and writes the verdict
|
|
14
|
+
bound to that hash. The companion ``verified_commit_gate.py`` (PreToolUse)
|
|
15
|
+
then allows ``git commit`` / ``git push`` only while the work tree still
|
|
16
|
+
matches the verified state.
|
|
17
17
|
|
|
18
18
|
The verifier's final message must end with a fenced block::
|
|
19
19
|
|
|
@@ -29,18 +29,13 @@ from __future__ import annotations
|
|
|
29
29
|
import json
|
|
30
30
|
import re
|
|
31
31
|
import sys
|
|
32
|
-
import time
|
|
33
32
|
from pathlib import Path
|
|
34
33
|
|
|
35
34
|
blocking_directory = str(Path(__file__).resolve().parent)
|
|
36
35
|
if blocking_directory not in sys.path:
|
|
37
36
|
sys.path.insert(0, blocking_directory)
|
|
38
37
|
|
|
39
|
-
from config.verified_commit_constants import
|
|
40
|
-
MINTING_AGENT_TYPE,
|
|
41
|
-
SPAWN_LOOKUP_ATTEMPT_COUNT,
|
|
42
|
-
SPAWN_LOOKUP_RETRY_DELAY_SECONDS,
|
|
43
|
-
)
|
|
38
|
+
from config.verified_commit_constants import MINTING_AGENT_TYPE
|
|
44
39
|
from verification_verdict_store import (
|
|
45
40
|
branch_surface_manifest,
|
|
46
41
|
manifest_sha256,
|
|
@@ -119,131 +114,58 @@ def last_verdict_in_blocks(all_text_blocks: list[str]) -> dict | None:
|
|
|
119
114
|
return None
|
|
120
115
|
|
|
121
116
|
|
|
122
|
-
def
|
|
123
|
-
"""
|
|
117
|
+
def _agent_type_from_meta_sidecar(agent_transcript_path: str) -> str | None:
|
|
118
|
+
"""Read the spawning agentType from a subagent transcript's sidecar.
|
|
124
119
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
file is missing or holds no object lines.
|
|
131
|
-
"""
|
|
132
|
-
parsed_entries: list[dict] = []
|
|
133
|
-
try:
|
|
134
|
-
transcript_lines = (
|
|
135
|
-
Path(transcript_path).read_text(encoding="utf-8", errors="replace").splitlines()
|
|
136
|
-
)
|
|
137
|
-
except OSError:
|
|
138
|
-
return parsed_entries
|
|
139
|
-
for each_line in transcript_lines:
|
|
140
|
-
try:
|
|
141
|
-
transcript_entry = json.loads(each_line)
|
|
142
|
-
except json.JSONDecodeError:
|
|
143
|
-
continue
|
|
144
|
-
if isinstance(transcript_entry, dict):
|
|
145
|
-
parsed_entries.append(transcript_entry)
|
|
146
|
-
return parsed_entries
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
def _agent_type_in_node(transcript_node: object, agent_id: str) -> str | None:
|
|
150
|
-
"""Search one parsed transcript value for a spawn record naming an agent.
|
|
151
|
-
|
|
152
|
-
Walks a transcript value and its nested mappings and sequences for a
|
|
153
|
-
mapping whose ``agentId`` equals the stopping agent and whose
|
|
154
|
-
``agentType`` is a string. Only a structured ``agentType`` key counts, so
|
|
155
|
-
a main-session text block that merely quotes the words cannot match.
|
|
120
|
+
Each subagent transcript ``agent-<id>.jsonl`` sits beside a harness-written
|
|
121
|
+
``agent-<id>.meta.json`` naming the spawning ``agentType``. Reading the type
|
|
122
|
+
from this sidecar binds it to the stopping subagent itself, so it resolves
|
|
123
|
+
identically in interactive, background, and worktree-switched sessions and
|
|
124
|
+
needs no parent-transcript scan or flush retry.
|
|
156
125
|
|
|
157
126
|
Args:
|
|
158
|
-
|
|
159
|
-
|
|
127
|
+
agent_transcript_path: The stopping subagent's own transcript path from
|
|
128
|
+
the SubagentStop payload.
|
|
160
129
|
|
|
161
130
|
Returns:
|
|
162
|
-
The ``agentType
|
|
163
|
-
|
|
131
|
+
The recorded ``agentType``, or None when the path is empty, the sidecar
|
|
132
|
+
is absent or cannot be read or parsed, it does not hold a JSON object,
|
|
133
|
+
or it names no string ``agentType``.
|
|
164
134
|
"""
|
|
165
|
-
if
|
|
166
|
-
recorded_type = transcript_node.get("agentType")
|
|
167
|
-
if transcript_node.get("agentId") == agent_id and isinstance(recorded_type, str):
|
|
168
|
-
return recorded_type
|
|
169
|
-
for each_value in transcript_node.values():
|
|
170
|
-
nested_type = _agent_type_in_node(each_value, agent_id)
|
|
171
|
-
if nested_type is not None:
|
|
172
|
-
return nested_type
|
|
135
|
+
if not agent_transcript_path:
|
|
173
136
|
return None
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
Args:
|
|
186
|
-
all_entries: Parsed parent-transcript entries.
|
|
187
|
-
agent_id: The stopping subagent's id from the payload.
|
|
188
|
-
|
|
189
|
-
Returns:
|
|
190
|
-
The ``agentType`` recorded for the agent, or None when no entry's
|
|
191
|
-
spawn record names it.
|
|
192
|
-
"""
|
|
193
|
-
for each_entry in all_entries:
|
|
194
|
-
recorded_type = _agent_type_in_node(each_entry, agent_id)
|
|
195
|
-
if recorded_type is not None:
|
|
196
|
-
return recorded_type
|
|
197
|
-
return None
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
def _resolve_agent_type_with_retry(transcript_path: str, agent_id: str) -> str | None:
|
|
201
|
-
"""Read the parent transcript and resolve the agent's type, with retry.
|
|
202
|
-
|
|
203
|
-
The agent's completion record is not reliably flushed to the parent
|
|
204
|
-
transcript at the instant SubagentStop fires, so a single read can miss it
|
|
205
|
-
and silently mint nothing. Each attempt re-reads the transcript; a bounded
|
|
206
|
-
sleep separates attempts so a late-arriving record resolves on a later read.
|
|
207
|
-
|
|
208
|
-
Args:
|
|
209
|
-
transcript_path: Path to the parent session transcript.
|
|
210
|
-
agent_id: The stopping subagent's id from the payload.
|
|
211
|
-
|
|
212
|
-
Returns:
|
|
213
|
-
The recorded ``agentType``, or None when no attempt finds the spawn
|
|
214
|
-
record naming this agent.
|
|
215
|
-
"""
|
|
216
|
-
for each_attempt_index in range(SPAWN_LOOKUP_ATTEMPT_COUNT):
|
|
217
|
-
all_entries = _transcript_entries(transcript_path)
|
|
218
|
-
recorded_type = _agent_type_from_entries(all_entries, agent_id)
|
|
219
|
-
if recorded_type is not None:
|
|
220
|
-
return recorded_type
|
|
221
|
-
if each_attempt_index < SPAWN_LOOKUP_ATTEMPT_COUNT - 1:
|
|
222
|
-
time.sleep(SPAWN_LOOKUP_RETRY_DELAY_SECONDS)
|
|
223
|
-
return None
|
|
137
|
+
transcript_file = Path(agent_transcript_path)
|
|
138
|
+
sidecar_file = transcript_file.with_name(f"{transcript_file.stem}.meta.json")
|
|
139
|
+
try:
|
|
140
|
+
sidecar_record = json.loads(sidecar_file.read_text(encoding="utf-8"))
|
|
141
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
|
142
|
+
return None
|
|
143
|
+
if not isinstance(sidecar_record, dict):
|
|
144
|
+
return None
|
|
145
|
+
recorded_type = sidecar_record.get("agentType")
|
|
146
|
+
return recorded_type if isinstance(recorded_type, str) else None
|
|
224
147
|
|
|
225
148
|
|
|
226
149
|
def resolved_subagent_type(subagent_stop_payload: dict) -> str | None:
|
|
227
150
|
"""Recover the spawning agent type for a SubagentStop payload.
|
|
228
151
|
|
|
229
|
-
The
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
152
|
+
The stopping subagent's own transcript (``agent_transcript_path``) sits
|
|
153
|
+
beside a harness-written ``agent-<id>.meta.json`` sidecar naming its
|
|
154
|
+
``agentType``. Reading the type from that sidecar binds it to the subagent
|
|
155
|
+
itself, so it resolves the same across interactive, background, and
|
|
156
|
+
worktree-switched sessions.
|
|
234
157
|
|
|
235
158
|
Args:
|
|
236
159
|
subagent_stop_payload: The SubagentStop hook payload.
|
|
237
160
|
|
|
238
161
|
Returns:
|
|
239
|
-
The agent type this subagent was spawned with, or None when the
|
|
240
|
-
|
|
162
|
+
The agent type this subagent was spawned with, or None when the
|
|
163
|
+
``agent_transcript_path`` is empty, the sidecar is absent or cannot be
|
|
164
|
+
read or parsed, it does not hold a JSON object, or it names no string
|
|
165
|
+
``agentType``.
|
|
241
166
|
"""
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
return None
|
|
245
|
-
return _resolve_agent_type_with_retry(
|
|
246
|
-
subagent_stop_payload.get("transcript_path", ""), agent_id
|
|
167
|
+
return _agent_type_from_meta_sidecar(
|
|
168
|
+
subagent_stop_payload.get("agent_transcript_path", "")
|
|
247
169
|
)
|
|
248
170
|
|
|
249
171
|
|
|
@@ -124,6 +124,12 @@ KNOWN_PYTEST_FIXTURE_ANNOTATION_MESSAGE_SUFFIX: str = (
|
|
|
124
124
|
"(CODE_RULES §6; pytest builtin fixture reference "
|
|
125
125
|
"https://docs.pytest.org/en/stable/reference/fixtures.html)"
|
|
126
126
|
)
|
|
127
|
+
UNUSED_PYTEST_FIXTURE_PARAMETER_MESSAGE_SUFFIX: str = (
|
|
128
|
+
"known pytest fixture parameter is declared but never referenced in the "
|
|
129
|
+
"function body; pytest still materializes its setup, so drop the unused "
|
|
130
|
+
"parameter (pytest builtin fixture reference "
|
|
131
|
+
"https://docs.pytest.org/en/stable/reference/fixtures.html)"
|
|
132
|
+
)
|
|
127
133
|
ALL_LOOP_INDEX_LETTER_EXEMPTIONS: frozenset[str] = frozenset({"i", "j", "k", "_"})
|
|
128
134
|
EACH_PREFIX = "each_"
|
|
129
135
|
BARE_EACH_TOKEN = "each"
|
|
@@ -41,6 +41,7 @@ ALL_INTERPRETER_AND_WRAPPER_COMMANDS: frozenset[str] = frozenset(
|
|
|
41
41
|
"su",
|
|
42
42
|
"env",
|
|
43
43
|
"xargs",
|
|
44
|
+
"parallel",
|
|
44
45
|
"awk",
|
|
45
46
|
"gawk",
|
|
46
47
|
"mawk",
|
|
@@ -66,6 +67,12 @@ ALL_REMOTE_AND_PROGRAM_STRING_EXECUTORS: frozenset[str] = frozenset(
|
|
|
66
67
|
}
|
|
67
68
|
)
|
|
68
69
|
ALL_STRING_ARGUMENT_EXECUTION_FLAGS: frozenset[str] = frozenset({"-c", "-e"})
|
|
70
|
+
FIND_PROGRAM_NAME: str = "find"
|
|
71
|
+
ALL_FIND_EXEC_ACTION_FLAGS: frozenset[str] = frozenset({"-exec", "-execdir"})
|
|
72
|
+
ALL_FIND_EXEC_ACTION_TERMINATORS: frozenset[str] = frozenset({";", "+"})
|
|
73
|
+
ALL_FIND_GLOBAL_OPTION_FLAGS_WITHOUT_VALUE: frozenset[str] = frozenset({"-H", "-L", "-P"})
|
|
74
|
+
ALL_FIND_GLOBAL_OPTION_FLAGS_TAKING_A_VALUE: frozenset[str] = frozenset({"-D"})
|
|
75
|
+
FIND_OPTIMIZATION_LEVEL_OPTION_PREFIX: str = "-O"
|
|
69
76
|
ALL_BENIGN_COMPOUND_SEGMENT_COMMANDS: frozenset[str] = frozenset(
|
|
70
77
|
{
|
|
71
78
|
"echo",
|
|
@@ -176,3 +183,11 @@ ALL_LAUNCHER_OPTIONS_TAKING_SEPARATE_VALUE: frozenset[str] = frozenset(
|
|
|
176
183
|
}
|
|
177
184
|
)
|
|
178
185
|
ALL_SUBSHELL_GROUPING_CHARACTERS: str = "({"
|
|
186
|
+
ALL_KNOWN_TEMPORARY_ENVIRONMENT_VARIABLE_NAMES: frozenset[str] = frozenset(
|
|
187
|
+
{
|
|
188
|
+
"TEMP",
|
|
189
|
+
"TMP",
|
|
190
|
+
"TMPDIR",
|
|
191
|
+
"CLAUDE_JOB_DIR",
|
|
192
|
+
}
|
|
193
|
+
)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Constants for the orphan-CSS-class check in code_rules_enforcer.
|
|
2
|
+
|
|
3
|
+
A Python module that builds HTML emits ``class="..."`` attributes in string
|
|
4
|
+
literals and pairs them with a ``<style>`` block whose selectors style those
|
|
5
|
+
classes. When a class appears in the markup but no selector defines it, the
|
|
6
|
+
markup carries a dead attribute or the style block is missing a rule. This
|
|
7
|
+
module holds the patterns that pair the two halves and the package-scan
|
|
8
|
+
budget that bounds the sibling read.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"CLASS_ATTRIBUTE_PATTERN",
|
|
15
|
+
"STYLE_BLOCK_PATTERN",
|
|
16
|
+
"CSS_CLASS_SELECTOR_PATTERN",
|
|
17
|
+
"PYTHON_MODULE_GLOB",
|
|
18
|
+
"MAX_ORPHAN_CSS_CLASS_ISSUES",
|
|
19
|
+
"MAX_SIBLING_MODULES_SCANNED",
|
|
20
|
+
"ORPHAN_CSS_CLASS_MESSAGE_SUFFIX",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
CLASS_ATTRIBUTE_PATTERN: re.Pattern[str] = re.compile(r"""class\s*=\s*["']([^"']+)["']""")
|
|
24
|
+
|
|
25
|
+
STYLE_BLOCK_PATTERN: re.Pattern[str] = re.compile(
|
|
26
|
+
r"<style[^>]*>(.*?)</style>", re.DOTALL | re.IGNORECASE
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
CSS_CLASS_SELECTOR_PATTERN: re.Pattern[str] = re.compile(r"\.(-?[_a-zA-Z][\w-]*)")
|
|
30
|
+
|
|
31
|
+
PYTHON_MODULE_GLOB: str = "*.py"
|
|
32
|
+
|
|
33
|
+
MAX_ORPHAN_CSS_CLASS_ISSUES: int = 10
|
|
34
|
+
|
|
35
|
+
MAX_SIBLING_MODULES_SCANNED: int = 60
|
|
36
|
+
|
|
37
|
+
ORPHAN_CSS_CLASS_MESSAGE_SUFFIX: str = (
|
|
38
|
+
"add a matching '.<class>' selector to the <style> block, "
|
|
39
|
+
"or drop the unused class attribute (CODE_RULES self-documenting markup)"
|
|
40
|
+
)
|
|
@@ -69,13 +69,55 @@ def is_file_within_project(target_file: str, project_root: Path) -> bool:
|
|
|
69
69
|
return False
|
|
70
70
|
|
|
71
71
|
|
|
72
|
-
def
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
72
|
+
def discover_mypy_config(target_file: Path) -> Path | None:
|
|
73
|
+
"""Return the nearest ancestor ``pyproject.toml`` that configures mypy.
|
|
74
|
+
|
|
75
|
+
Mypy applies a project's ``[tool.mypy]`` settings only when the config file
|
|
76
|
+
is on its invocation path; handing the discovered config to mypy lets a
|
|
77
|
+
check run from the repository root still honor the project's own import
|
|
78
|
+
resolution settings (such as ``ignore_missing_imports``) for a module that
|
|
79
|
+
imports its siblings by name. Reuses the validators-package walk-up so the
|
|
80
|
+
discovery logic lives in one place.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
target_file: The Python file mypy will check.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
The nearest ancestor ``pyproject.toml`` declaring a ``[tool.mypy]``
|
|
87
|
+
table, or None when none exists above the file or the walk-up helper
|
|
88
|
+
cannot be imported.
|
|
89
|
+
"""
|
|
90
|
+
validators_directory = os.path.join(
|
|
91
|
+
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "validators"
|
|
92
|
+
)
|
|
93
|
+
if validators_directory not in sys.path:
|
|
94
|
+
sys.path.insert(0, validators_directory)
|
|
95
|
+
try:
|
|
96
|
+
integration_module = importlib.import_module("mypy_integration")
|
|
97
|
+
except ImportError:
|
|
98
|
+
return None
|
|
99
|
+
discovered_config = integration_module.find_pyproject_with_mypy_config(target_file)
|
|
100
|
+
return discovered_config if isinstance(discovered_config, Path) else None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def build_mypy_command(relative_file_path: str, mypy_config_file: Path | None) -> list[str]:
|
|
104
|
+
"""Build the mypy command line for one file.
|
|
77
105
|
|
|
78
|
-
|
|
106
|
+
Args:
|
|
107
|
+
relative_file_path: The target file path relative to the project root.
|
|
108
|
+
mypy_config_file: The ``pyproject.toml`` to pass via ``--config-file``,
|
|
109
|
+
or None to let mypy fall back to its own config discovery.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
The full mypy argument vector, including the interpreter prefix on
|
|
113
|
+
Windows and the config file when one was discovered.
|
|
114
|
+
"""
|
|
115
|
+
base_command = [sys.executable, "-m", "mypy"] if IS_WINDOWS else ["mypy"]
|
|
116
|
+
|
|
117
|
+
config_arguments = (
|
|
118
|
+
["--config-file", str(mypy_config_file)] if mypy_config_file is not None else []
|
|
119
|
+
)
|
|
120
|
+
return base_command + config_arguments + [
|
|
79
121
|
"--no-error-summary",
|
|
80
122
|
"--show-error-codes",
|
|
81
123
|
"--no-color",
|
|
@@ -84,8 +126,18 @@ def build_mypy_command(relative_file_path: str) -> list[str]:
|
|
|
84
126
|
|
|
85
127
|
|
|
86
128
|
def run_mypy(target_file: str, project_root: str) -> tuple[int, str]:
|
|
129
|
+
"""Run mypy on one file from the project root and return its result.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
target_file: The absolute path of the file to type-check.
|
|
133
|
+
project_root: The directory mypy runs from.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
The mypy exit code paired with its combined stdout and stderr text.
|
|
137
|
+
"""
|
|
87
138
|
relative_file_path = os.path.relpath(target_file, project_root)
|
|
88
|
-
|
|
139
|
+
mypy_config_file = discover_mypy_config(Path(target_file))
|
|
140
|
+
mypy_command = build_mypy_command(relative_file_path, mypy_config_file)
|
|
89
141
|
|
|
90
142
|
completed_process = subprocess.run(
|
|
91
143
|
mypy_command,
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Behavior tests for the mypy_validator config-discovery fix.
|
|
2
|
+
|
|
3
|
+
The hook runs mypy from the project root, so without handing mypy the project's
|
|
4
|
+
own ``[tool.mypy]`` config a module that imports its siblings by name draws a
|
|
5
|
+
spurious ``import-not-found`` error. These tests drive the real production
|
|
6
|
+
functions: ``discover_mypy_config`` walks up to the nearest configuring
|
|
7
|
+
``pyproject.toml`` and ``run_mypy`` passes it through so the project's
|
|
8
|
+
``ignore_missing_imports`` setting applies.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import importlib.util
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from types import ModuleType
|
|
14
|
+
|
|
15
|
+
HOOK_PATH = Path(__file__).resolve().parent / "mypy_validator.py"
|
|
16
|
+
|
|
17
|
+
MODULE_WITH_SIBLING_IMPORT = (
|
|
18
|
+
"from sibling_only_resolvable_at_runtime import value\n\nx: int = value\n"
|
|
19
|
+
)
|
|
20
|
+
TOOL_MYPY_PYPROJECT = "[tool.mypy]\nignore_missing_imports = true\n"
|
|
21
|
+
NON_MYPY_PYPROJECT = "[tool.ruff]\nline-length = 100\n"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _load_validator() -> ModuleType:
|
|
25
|
+
spec = importlib.util.spec_from_file_location("mypy_validator_under_test", HOOK_PATH)
|
|
26
|
+
assert spec is not None and spec.loader is not None
|
|
27
|
+
module = importlib.util.module_from_spec(spec)
|
|
28
|
+
spec.loader.exec_module(module)
|
|
29
|
+
return module
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_discover_mypy_config_finds_nearest_tool_mypy_pyproject(tmp_path: Path) -> None:
|
|
33
|
+
validator = _load_validator()
|
|
34
|
+
(tmp_path / "pyproject.toml").write_text(TOOL_MYPY_PYPROJECT, encoding="utf-8")
|
|
35
|
+
nested_module = tmp_path / "package" / "module.py"
|
|
36
|
+
nested_module.parent.mkdir(parents=True)
|
|
37
|
+
nested_module.write_text("value: int = 1\n", encoding="utf-8")
|
|
38
|
+
|
|
39
|
+
discovered = validator.discover_mypy_config(nested_module)
|
|
40
|
+
|
|
41
|
+
assert discovered is not None
|
|
42
|
+
assert discovered.resolve() == (tmp_path / "pyproject.toml").resolve()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_discover_mypy_config_returns_none_without_tool_mypy(tmp_path: Path) -> None:
|
|
46
|
+
validator = _load_validator()
|
|
47
|
+
(tmp_path / "pyproject.toml").write_text(NON_MYPY_PYPROJECT, encoding="utf-8")
|
|
48
|
+
standalone_module = tmp_path / "module.py"
|
|
49
|
+
standalone_module.write_text("value: int = 1\n", encoding="utf-8")
|
|
50
|
+
|
|
51
|
+
assert validator.discover_mypy_config(standalone_module) is None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_build_mypy_command_includes_config_file_when_present(tmp_path: Path) -> None:
|
|
55
|
+
validator = _load_validator()
|
|
56
|
+
config_file = tmp_path / "pyproject.toml"
|
|
57
|
+
|
|
58
|
+
command = validator.build_mypy_command("package/module.py", config_file)
|
|
59
|
+
|
|
60
|
+
assert "--config-file" in command
|
|
61
|
+
assert command[command.index("--config-file") + 1] == str(config_file)
|
|
62
|
+
assert command[-1] == "package/module.py"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_build_mypy_command_omits_config_file_when_absent(tmp_path: Path) -> None:
|
|
66
|
+
validator = _load_validator()
|
|
67
|
+
|
|
68
|
+
command = validator.build_mypy_command("package/module.py", None)
|
|
69
|
+
|
|
70
|
+
assert "--config-file" not in command
|
|
71
|
+
assert command[-1] == "package/module.py"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_run_mypy_suppresses_sibling_import_error_with_tool_mypy_config(tmp_path: Path) -> None:
|
|
75
|
+
validator = _load_validator()
|
|
76
|
+
(tmp_path / "pyproject.toml").write_text(TOOL_MYPY_PYPROJECT, encoding="utf-8")
|
|
77
|
+
importer_module = tmp_path / "importer.py"
|
|
78
|
+
importer_module.write_text(MODULE_WITH_SIBLING_IMPORT, encoding="utf-8")
|
|
79
|
+
|
|
80
|
+
exit_code, output = validator.run_mypy(str(importer_module), str(tmp_path))
|
|
81
|
+
|
|
82
|
+
assert exit_code == 0, output
|
|
83
|
+
assert "import-not-found" not in output
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_run_mypy_reports_import_error_without_tool_mypy_config(tmp_path: Path) -> None:
|
|
87
|
+
validator = _load_validator()
|
|
88
|
+
importer_module = tmp_path / "importer.py"
|
|
89
|
+
importer_module.write_text(MODULE_WITH_SIBLING_IMPORT, encoding="utf-8")
|
|
90
|
+
|
|
91
|
+
exit_code, output = validator.run_mypy(str(importer_module), str(tmp_path))
|
|
92
|
+
|
|
93
|
+
assert exit_code != 0
|
|
94
|
+
assert "import-not-found" in output
|
package/package.json
CHANGED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Orphan CSS Class in Generated Markup
|
|
2
|
+
|
|
3
|
+
**When this applies:** Any Write or Edit to a production `.py` file that builds HTML by emitting `class="..."` attributes inside string literals and pairs them with a `<style>` block — in the same file or in a companion module beside it.
|
|
4
|
+
|
|
5
|
+
## Rule
|
|
6
|
+
|
|
7
|
+
Every class name a markup string references has a matching `.<class>` selector in the `<style>` block. A class that appears in the markup but carries no selector anywhere is a dead attribute (or a missing rule): the markup names a style that the stylesheet never defines, so a reader who trusts the class to be styled is misled, and the attribute adds noise without effect.
|
|
8
|
+
|
|
9
|
+
When you add a `class="..."` attribute, add its `.<class>` selector to the `<style>` block in the same change. When you drop a selector, drop the class attribute it styled.
|
|
10
|
+
|
|
11
|
+
## What the gate checks
|
|
12
|
+
|
|
13
|
+
The `check_orphan_css_classes` check in `code_rules_orphan_css_class.py` (wired into `code_rules_enforcer.py`) runs on every production Python write. It:
|
|
14
|
+
|
|
15
|
+
1. Collects each class name referenced in a `class="..."` attribute across the file's string literals.
|
|
16
|
+
2. Collects each class selector defined in a `<style>` block — both in the file under edit and in every Python module beside it (its own directory and immediate child directories), since a markup module commonly imports its style constant from a companion package directory.
|
|
17
|
+
3. Flags each referenced class with no matching selector in that whole set.
|
|
18
|
+
|
|
19
|
+
The check stays quiet for a file that emits no `class="..."` markup, and for a file whose markup has no `<style>` source nearby (its stylesheet lives outside the scan, so the gate cannot judge it). Test files are exempt, since a fixture may carry intentional orphan markup.
|
|
20
|
+
|
|
21
|
+
## Why this is a hook, not a lint pass
|
|
22
|
+
|
|
23
|
+
A class attribute with no matching selector reads as styled but renders unstyled. Native elements such as `<details>` stay functional without CSS, so the gap survives review as a cosmetic defect rather than a crash — exactly the class of issue that slips past a manual pass and lands as a deferred code-standard finding. Catching it at Write time keeps the markup and the stylesheet in step as each line is written.
|