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.
Files changed (32) hide show
  1. package/CLAUDE.md +4 -0
  2. package/audit-rubrics/category_rubrics/category-f-silent-failures.md +1 -1
  3. package/audit-rubrics/prompts/category-e-dead-code.md +17 -4
  4. package/audit-rubrics/prompts/category-f-silent-failures.md +1 -0
  5. package/docs/CODE_RULES.md +2 -2
  6. package/hooks/blocking/code_rules_annotations_length.py +189 -10
  7. package/hooks/blocking/code_rules_enforcer.py +8 -0
  8. package/hooks/blocking/code_rules_orphan_css_class.py +196 -0
  9. package/hooks/blocking/config/verified_commit_constants.py +14 -2
  10. package/hooks/blocking/destructive_command_blocker.py +483 -61
  11. package/hooks/blocking/test_code_rules_enforcer_annotations.py +240 -0
  12. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +1 -0
  13. package/hooks/blocking/test_code_rules_enforcer_dispatch_wiring.py +82 -0
  14. package/hooks/blocking/test_code_rules_enforcer_orphan_css_class.py +196 -0
  15. package/hooks/blocking/test_destructive_command_blocker.py +213 -0
  16. package/hooks/blocking/test_verification_verdict_store.py +212 -0
  17. package/hooks/blocking/test_verified_commit_gate.py +127 -0
  18. package/hooks/blocking/test_verifier_verdict_minter.py +74 -95
  19. package/hooks/blocking/verification_verdict_store.py +240 -0
  20. package/hooks/blocking/verified_commit_gate.py +20 -8
  21. package/hooks/blocking/verifier_verdict_minter.py +46 -124
  22. package/hooks/hooks_constants/code_rules_enforcer_constants.py +6 -0
  23. package/hooks/hooks_constants/destructive_command_segment_constants.py +15 -0
  24. package/hooks/hooks_constants/orphan_css_class_constants.py +40 -0
  25. package/hooks/validation/mypy_validator.py +59 -7
  26. package/hooks/validation/test_mypy_validator.py +94 -0
  27. package/package.json +1 -1
  28. package/rules/orphan-css-class.md +23 -0
  29. package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +0 -1
  30. package/skills/autoconverge/workflow/converge.contract.test.mjs +202 -13
  31. package/skills/autoconverge/workflow/converge.mjs +392 -51
  32. 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 by ``agent_id``. The hook
6
- recovers the spawning agent type from the parent transcript
7
- (``transcript_path``), where the agent's completion record carries its
8
- identity as sibling ``agentId`` and ``agentType`` keys. When that type is
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 the payload key ``agent_transcript_path``; the parent
11
- ``transcript_path`` supplies only the spawning type and never the verdict, so
12
- text printed by the main session can never mint — recomputes the live
13
- change-surface hash for the session
14
- repository, and writes the verdict bound to that hash. The companion
15
- ``verified_commit_gate.py`` (PreToolUse) then allows ``git commit`` /
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 _transcript_entries(transcript_path: str) -> list[dict]:
123
- """Parse every JSON object line of a transcript file.
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
- Args:
126
- transcript_path: Path to the parent session transcript.
127
-
128
- Returns:
129
- Each parseable object entry in transcript order; empty when the
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
- transcript_node: A JSON value drawn from a parsed transcript entry.
159
- agent_id: The stopping subagent's id from the payload.
127
+ agent_transcript_path: The stopping subagent's own transcript path from
128
+ the SubagentStop payload.
160
129
 
161
130
  Returns:
162
- The ``agentType`` of the matching mapping, or None when no nested
163
- value names this agent.
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 isinstance(transcript_node, dict):
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
- if isinstance(transcript_node, list):
175
- for each_item in transcript_node:
176
- nested_type = _agent_type_in_node(each_item, agent_id)
177
- if nested_type is not None:
178
- return nested_type
179
- return None
180
-
181
-
182
- def _agent_type_from_entries(all_entries: list[dict], agent_id: str) -> str | None:
183
- """Find the spawn record naming an agent across parent-transcript entries.
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 payload names the stopping subagent by ``agent_id``. Its spawn type
230
- lives on the agent's completion record in the parent transcript, attached
231
- as sibling ``agentId`` and ``agentType`` keys, so the type is read from
232
- that record. The read retries because the record may not be flushed at the
233
- instant the hook fires.
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 agent
240
- id is absent or no spawn record names its type.
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
- agent_id = subagent_stop_payload.get("agent_id", "")
243
- if not agent_id:
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 build_mypy_command(relative_file_path: str) -> list[str]:
73
- if IS_WINDOWS:
74
- base_command = [sys.executable, "-m", "mypy"]
75
- else:
76
- base_command = ["mypy"]
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
- return base_command + [
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
- mypy_command = build_mypy_command(relative_file_path)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "1.60.0",
3
+ "version": "1.61.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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.
@@ -62,7 +62,6 @@ SCENE_FIELD_CAPTION = "caption"
62
62
 
63
63
  MEDIUM_TERMINAL = "terminal"
64
64
  MEDIUM_CODE = "code"
65
- MEDIUM_TEXT = "text"
66
65
 
67
66
  CATEGORY_BUG = "bug"
68
67
  CATEGORY_LABEL_BY_VALUE = {