claude-dev-env 1.77.0 → 1.78.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.
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib.util
4
+ from pathlib import Path
5
+
6
+ ENFORCER_PATH = Path(__file__).resolve().parent / "code_rules_enforcer.py"
7
+ specification = importlib.util.spec_from_file_location("code_rules_enforcer", ENFORCER_PATH)
8
+ assert specification is not None and specification.loader is not None
9
+ code_rules_enforcer = importlib.util.module_from_spec(specification)
10
+ specification.loader.exec_module(code_rules_enforcer)
11
+
12
+ PRODUCTION_PATH = "C:/project/pkg/menu_info_colors.py"
13
+
14
+
15
+ def _check(source: str) -> list[str]:
16
+ return code_rules_enforcer.check_whitespace_indentation_magic(source, PRODUCTION_PATH)
17
+
18
+
19
+ def test_flags_twelve_space_indent_constant() -> None:
20
+ source = 'def fallback_indent() -> str:\n return " "\n'
21
+ issues = _check(source)
22
+ assert len(issues) == 1
23
+ assert "Line 2" in issues[0]
24
+
25
+
26
+ def test_flags_indent_fragment_inside_fstring() -> None:
27
+ source = 'def build(value: str) -> str:\n return value + f"\\n {value}"\n'
28
+ issues = _check(source)
29
+ assert len(issues) == 1
30
+ assert "Line 2" in issues[0]
31
+
32
+
33
+ def test_flags_tab_indent_constant() -> None:
34
+ source = 'def tabbed() -> str:\n return "\\t\\t"\n'
35
+ assert len(_check(source)) == 1
36
+
37
+
38
+ def test_does_not_flag_single_tab_delimiter() -> None:
39
+ source = 'def split_columns(text: str) -> list[str]:\n return text.split("\\t")\n'
40
+ assert _check(source) == []
41
+
42
+
43
+ def test_does_not_flag_single_tab_join_delimiter() -> None:
44
+ source = 'def join_rows(rows: list[str]) -> str:\n return "\\t".join(rows)\n'
45
+ assert _check(source) == []
46
+
47
+
48
+ def test_does_not_flag_single_space() -> None:
49
+ source = 'def join_words(left: str, right: str) -> str:\n return left + " " + right\n'
50
+ assert _check(source) == []
51
+
52
+
53
+ def test_does_not_flag_newline_only_fragment() -> None:
54
+ source = 'def build(value: str) -> str:\n return f"\\n{value}"\n'
55
+ assert _check(source) == []
56
+
57
+
58
+ def test_does_not_flag_docstring_with_spaced_words() -> None:
59
+ source = (
60
+ "def documented() -> str:\n"
61
+ ' """A docstring with spaced words."""\n'
62
+ ' return documented.__doc__ or ""\n'
63
+ )
64
+ assert _check(source) == []
65
+
66
+
67
+ def test_does_not_flag_in_config_file() -> None:
68
+ source = 'def fallback_indent() -> str:\n return " "\n'
69
+ assert (
70
+ code_rules_enforcer.check_whitespace_indentation_magic(
71
+ source, "C:/project/pkg/config/indents.py"
72
+ )
73
+ == []
74
+ )
@@ -1,9 +1,14 @@
1
1
  """Unit tests for convergence-gate-blocker PreToolUse hook."""
2
2
 
3
3
  import importlib.util
4
+ import io
5
+ import json
4
6
  import pathlib
7
+ import subprocess
5
8
  import sys
6
9
 
10
+ import pytest
11
+
7
12
  _HOOK_DIR = pathlib.Path(__file__).parent
8
13
  if str(_HOOK_DIR) not in sys.path:
9
14
  sys.path.insert(0, str(_HOOK_DIR))
@@ -61,3 +66,69 @@ def test_returns_none_when_no_number_and_no_repo() -> None:
61
66
 
62
67
  def test_matches_gh_pr_ready_in_compound_command() -> None:
63
68
  assert not _GH_PR_READY_PATTERN.search("gh pr ready --undo && gh pr create")
69
+
70
+
71
+ def test_run_convergence_check_forwards_cwd_to_subprocess(
72
+ monkeypatch: pytest.MonkeyPatch,
73
+ ) -> None:
74
+ captured_cwd: list[object] = []
75
+
76
+ def fake_run(*_run_args: object, **run_keywords: object) -> subprocess.CompletedProcess[str]:
77
+ captured_cwd.append(run_keywords.get("cwd"))
78
+ return subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="")
79
+
80
+ monkeypatch.setattr(hook_module.subprocess, "run", fake_run)
81
+ hook_module._run_convergence_check(
82
+ "check_convergence.py", "owner", "repo", 783, "C:/worktrees/pr-783"
83
+ )
84
+ assert captured_cwd == ["C:/worktrees/pr-783"]
85
+
86
+
87
+ def test_run_convergence_check_forwards_none_cwd_as_none(
88
+ monkeypatch: pytest.MonkeyPatch,
89
+ ) -> None:
90
+ captured_cwd: list[object] = []
91
+
92
+ def fake_run(*_run_args: object, **run_keywords: object) -> subprocess.CompletedProcess[str]:
93
+ captured_cwd.append(run_keywords.get("cwd"))
94
+ return subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="")
95
+
96
+ monkeypatch.setattr(hook_module.subprocess, "run", fake_run)
97
+ hook_module._run_convergence_check("check_convergence.py", "owner", "repo", 783, None)
98
+ assert captured_cwd == [None]
99
+
100
+
101
+ def test_main_reads_cwd_from_top_level_payload(
102
+ monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
103
+ ) -> None:
104
+ convergence_script = (
105
+ tmp_path / ".claude" / "skills" / "pr-converge" / "scripts" / "check_convergence.py"
106
+ )
107
+ convergence_script.parent.mkdir(parents=True)
108
+ convergence_script.write_text("")
109
+ monkeypatch.setattr(hook_module.Path, "home", classmethod(lambda _cls: tmp_path))
110
+
111
+ worktree_path = str(tmp_path / "worktrees" / "pr-783")
112
+ monkeypatch.setattr(hook_module, "_resolve_owner_repo", lambda _cwd: ("jl-cmd", "repo"))
113
+
114
+ captured_cwd: list[object] = []
115
+
116
+ def fake_check(
117
+ _script: str, _owner: str, _repo: str, _pr_number: int, cwd: str | None
118
+ ) -> subprocess.CompletedProcess[str]:
119
+ captured_cwd.append(cwd)
120
+ return subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="")
121
+
122
+ monkeypatch.setattr(hook_module, "_run_convergence_check", fake_check)
123
+
124
+ payload = {
125
+ "tool_name": "Bash",
126
+ "cwd": worktree_path,
127
+ "tool_input": {"command": "gh pr ready 783", "cwd": "C:/wrong/session/dir"},
128
+ }
129
+ monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(payload)))
130
+
131
+ with pytest.raises(SystemExit):
132
+ hook_module.main()
133
+
134
+ assert captured_cwd == [worktree_path]
@@ -39,6 +39,7 @@ Shared constant modules imported by hooks throughout the `hooks/` tree. Each fil
39
39
  | `open_questions_in_plans_blocker_constants.py` | Patterns for detecting unresolved open questions in plan documents |
40
40
  | `orphan_css_class_constants.py` | Scan radius and selector patterns for the orphan-CSS-class check |
41
41
  | `package_inventory_stale_blocker_constants.py` | Inventory document names, production code extensions, backtick token pattern, smallest inventory size, exempt names, scan budget, and block-message text for the package-inventory stale-entry blocker |
42
+ | `paired_test_coverage_constants.py` | Test-directory name, stem-test filename affixes, test-file globs, exempt public-function names, scan budget, coverage threshold, and guidance text for the public-function paired-test coverage check |
42
43
  | `path_rewriter_constants.py` | Path rewriting patterns for the Everything-search path rewriter |
43
44
  | `plain_language_blocker_constants.py` | The list of heavy words and their everyday replacements |
44
45
  | `pr_converge_bugteam_enforcer_constants.py` | State keys and timing config for the bugteam-parallel enforcer |
@@ -57,12 +57,36 @@ MAX_MODULE_DOCSTRING_CHECK_ROSTER_ISSUES: int = 5
57
57
  MINIMUM_PUBLIC_CHECKS_FOR_MODULE_DOCSTRING_ROSTER: int = 2
58
58
  MAX_DOCSTRING_TUPLE_ENUMERATION_ISSUES: int = 5
59
59
  MINIMUM_TUPLE_MEMBERS_FOR_DOCSTRING_ENUMERATION: int = 2
60
+ MAX_DOCSTRING_MARK_GLYPH_ENUMERATION_ISSUES: int = 5
61
+ MINIMUM_NAMED_MARKS_FOR_PROSE_ENUMERATION: int = 2
62
+ MAX_COMPANION_MODULE_RESOLUTION_DEPTH: int = 6
63
+ PYTHON_MODULE_FILE_SUFFIX: str = ".py"
64
+ WORD_BOUNDARY_REGEX: str = r"\b"
65
+ ALL_PUNCTUATION_MARK_GLYPH_PROSE_NAMES: dict[str, tuple[str, ...]] = {
66
+ "—": ("em-dash", "em dash"),
67
+ "–": ("en-dash", "en dash"),
68
+ "--": ("double-hyphen", "double hyphen", "spaced double-hyphen"),
69
+ ";": ("semicolon",),
70
+ ":": ("colon",),
71
+ ",": ("comma",),
72
+ "/": ("slash", "forward slash"),
73
+ "|": ("pipe", "vertical bar"),
74
+ "&": ("ampersand",),
75
+ }
60
76
  MAX_DOCSTRING_STEP_DISPATCH_ISSUES: int = 5
61
77
  MINIMUM_NAMED_LINEAR_STEPS_FOR_DISPATCH_CHECK: int = 2
62
78
  MINIMUM_TOKENS_FOR_DISPATCH_CALLEE: int = 2
63
79
  MAX_DOCSTRING_UNDEFINED_CONSTANT_ISSUES: int = 3
64
80
  MAX_DOCSTRING_RETURNS_PLURAL_CARDINALITY_ISSUES: int = 5
65
81
  SINGLE_DICT_KEY_COUNT_FOR_PLURAL_CARDINALITY_DRIFT: int = 1
82
+ MAX_DOCSTRING_RAISES_LARGEZIPFILE_ISSUES: int = 5
83
+ DOCSTRING_LARGE_ZIP_FILE_EXCEPTION_NAME: str = "LargeZipFile"
84
+ ZIPFILE_WRITER_CLASS_NAME: str = "ZipFile"
85
+ ZIPFILE_MODE_KEYWORD: str = "mode"
86
+ ZIPFILE_MODE_POSITIONAL_INDEX: int = 1
87
+ ZIPFILE_ALLOW_ZIP64_KEYWORD: str = "allowZip64"
88
+ ZIPFILE_ALLOW_ZIP64_POSITIONAL_INDEX: int = 3
89
+ ALL_ZIPFILE_WRITE_MODE_VALUES: frozenset[str] = frozenset({"w", "a", "x"})
66
90
  MAX_DOCSTRING_ARGS_SPAN_SCOPE_ISSUES: int = 3
67
91
  ALL_DOCSTRING_SINGLE_LINE_SCOPE_PHRASES: tuple[str, ...] = (
68
92
  "anchor line is among the changed lines",
@@ -137,6 +137,20 @@ MINIMUM_FSTRING_LITERAL_LENGTH = 2
137
137
  MAX_FSTRING_STRUCTURAL_LITERAL_ISSUES = 100
138
138
  ALL_ALLOWED_MAGIC_NUMBER_LITERALS: frozenset[str] = frozenset({"0", "1", "-1", "0.0", "1.0"})
139
139
  ALL_NON_MAGIC_FSTRING_STRIPPED_VALUES: frozenset[str] = frozenset({"", "True", "False"})
140
+ INDENTATION_MAGIC_MINIMUM_SPACE_RUN = 4
141
+ INDENTATION_MAGIC_MINIMUM_TAB_RUN = 2
142
+ MAX_WHITESPACE_INDENTATION_MAGIC_ISSUES = 100
143
+ WHITESPACE_INDENTATION_MAGIC_MESSAGE_SUFFIX: str = (
144
+ "whitespace indentation literal in a function body - extract to a named "
145
+ "indent constant in config/"
146
+ )
147
+ ALL_ALWAYS_NONEMPTY_SPLIT_METHOD_NAMES: frozenset[str] = frozenset({"split", "rsplit"})
148
+ MAX_DEAD_SPLIT_BRANCH_ISSUES = 100
149
+ DEAD_SPLIT_BRANCH_MESSAGE_SUFFIX: str = (
150
+ "is bound from a str.split() call with a separator, which never returns an "
151
+ "empty list, so this truthiness test's falsy branch is unreachable dead "
152
+ "code - remove the dead branch"
153
+ )
140
154
  DUPLICATED_FORMAT_MINIMUM_REPETITION_COUNT = 3
141
155
  DUPLICATED_FORMAT_MINIMUM_LITERAL_CHARACTER_COUNT = 5
142
156
  FILE_GLOBAL_UPPER_SNAKE_PATTERN = re.compile(r"^_?[A-Z][A-Z0-9_]*$")
@@ -0,0 +1,27 @@
1
+ """Constants for the public-function paired-test coverage check in ``code_rules_enforcer``.
2
+
3
+ Lives under the hooks-tree ``hooks_constants`` package so its 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 __future__ import annotations
9
+
10
+
11
+ PYTHON_SOURCE_SUFFIX: str = ".py"
12
+ TESTS_DIRECTORY_NAME: str = "tests"
13
+ INIT_MODULE_FILENAME: str = "__init__.py"
14
+ STEM_TEST_FILENAME_PREFIX: str = "test_"
15
+ STEM_TEST_FILENAME_SUFFIX: str = "_test.py"
16
+ ALL_TEST_FILENAME_GLOBS: tuple[str, ...] = ("test_*.py", "*_test.py")
17
+ EXEMPT_PUBLIC_FUNCTION_NAMES: frozenset[str] = frozenset({"main"})
18
+ ANCESTOR_DIRECTORY_WALK_LIMIT: int = 10
19
+ MAX_TEST_FILES_SCANNED: int = 200
20
+ MINIMUM_COVERED_PUBLIC_FUNCTIONS: int = 1
21
+ MAX_PAIRED_TEST_COVERAGE_ISSUES: int = 25
22
+ MISSING_PAIRED_TEST_GUIDANCE: str = (
23
+ "is exercised by no test in the module's paired test suite, though that"
24
+ " suite already covers another public function in this module - add a"
25
+ " behavioral test that calls this function and asserts on its return value"
26
+ " or side effect (CODE_RULES TDD paired-test rule)"
27
+ )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "1.77.0",
3
+ "version": "1.78.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
package/rules/CLAUDE.md CHANGED
@@ -29,6 +29,7 @@ Rule files installed into `~/.claude/rules/` by `bin/install.mjs`. Claude Code l
29
29
  | `no-inline-destructive-literals.md` | No destructive-command literals in Bash tool command strings, even as data |
30
30
  | `orphan-css-class.md` | Every `class="..."` attribute in Python-generated markup has a matching selector in the `<style>` block |
31
31
  | `package-inventory-stale-entry.md` | A new production code file added to a directory carries an entry in that directory's `README.md`/`CLAUDE.md` file inventory |
32
+ | `paired-test-coverage.md` | A public function omitted by a module's established paired test suite must get a behavioral test |
32
33
  | `parallel-tools.md` | Make all independent tool calls in a single response |
33
34
  | `plain-illustrative-docstrings.md` | Public docstring narrative reads plainly and paints a concrete scene a general developer follows on first read; a run-on backstop hook plus Category O9 audit enforce it |
34
35
  | `plain-language.md` | Everyday words, short active sentences, lead with the answer |
@@ -6,7 +6,7 @@
6
6
 
7
7
  When a docstring enumerates the behaviors a body applies, the enumeration covers every behavior the body applies. A reader trusts the list to be complete: an item the code applies but the prose omits is a silent gap that misleads every future reader and reviewer.
8
8
 
9
- The gate validator `check_docstring_args_match_signature` covers the `Args:` section parameter names. Six more gate validators each cover one deterministic slice of the free-form prose. `check_docstring_fallback_branch_coverage` covers a summary that scopes a fallback to a single condition (`only when`, `falls back to ... when`) while the body routes to that same fallback call from two or more distinct early-return guards. `check_class_docstring_names_public_methods` covers a class whose docstring is a single summary line while the class exposes two or more public methods whose names the summary never spells out — the drift where a one-line class summary keeps naming its first feature after the class grows a second public entry point. `check_docstring_no_consumer_claim` covers a producer docstring asserting that no consumer reads its output yet (`producer-only artifact`, `no submission-run consumer reads it yet`) — a transitional claim that drifts the moment a reader lands and contradicts any companion `SKILL.md` that documents the consumer; this is the deterministic slice of the O8 companion-doc producer/consumer drift below. `check_docstring_returns_plural_cardinality` covers a `Returns:` clause that names a dict-key prefix family with a plural noun (`the sheen stops`) while the returned dict literal holds exactly one key in that family (`sheen_mid`) — the drift where a single-key family carries a plural noun, so the prose claims a cardinality of two or more that the dict does not hold. `check_docstring_args_single_line_scope_vs_span` covers an `Args:` entry whose prose scopes a finding to a single named line (`only when its block-anchor line is among the changed lines`) while the body builds a `range(...)` span over the finding's source lines and scopes it through a span-intersection scoper — the drift where the Args entry claims a narrower single-line scope than the span-intersection body applies, so an edit touching any non-anchor line of the span still blocks. `check_docstring_cardinal_count_matches_constant_family` covers a docstring that states a cardinal count of an outcome family (`Covers the four outcome branches: ...`) and lists those members, while the module references more members of the same `UPPER_SNAKE` constant family than the count names (`OUTCOME_OFFENDER_UNREADABLE` is imported and exercised, yet the summary stops at four) — the drift where a summary keeps the old count after the code grows another branch; this gate runs on test modules as well as production modules. The remaining free-form prose — `"a field counts as read when ..."`, `"resolves to shared temp only"`, `"strip ceremony, then drop blockquotes"`, and module-level responsibility paragraphs — has no signature, method roster, or single structural shape to compare against, so the gate cannot catch its drift. This rule is the judgment standard for that prose; the audit lane below is the enforcement for everything outside the seven gated slices.
9
+ The gate validator `check_docstring_args_match_signature` covers the `Args:` section parameter names. Seven more gate validators each cover one deterministic slice of the free-form prose. `check_docstring_fallback_branch_coverage` covers a summary that scopes a fallback to a single condition (`only when`, `falls back to ... when`) while the body routes to that same fallback call from two or more distinct early-return guards. `check_class_docstring_names_public_methods` covers a class whose docstring is a single summary line while the class exposes two or more public methods whose names the summary never spells out — the drift where a one-line class summary keeps naming its first feature after the class grows a second public entry point. `check_docstring_no_consumer_claim` covers a producer docstring asserting that no consumer reads its output yet (`producer-only artifact`, `no submission-run consumer reads it yet`) — a transitional claim that drifts the moment a reader lands and contradicts any companion `SKILL.md` that documents the consumer; this is the deterministic slice of the O8 companion-doc producer/consumer drift below. `check_docstring_returns_plural_cardinality` covers a `Returns:` clause that names a dict-key prefix family with a plural noun (`the sheen stops`) while the returned dict literal holds exactly one key in that family (`sheen_mid`) — the drift where a single-key family carries a plural noun, so the prose claims a cardinality of two or more that the dict does not hold. `check_docstring_args_single_line_scope_vs_span` covers an `Args:` entry whose prose scopes a finding to a single named line (`only when its block-anchor line is among the changed lines`) while the body builds a `range(...)` span over the finding's source lines and scopes it through a span-intersection scoper — the drift where the Args entry claims a narrower single-line scope than the span-intersection body applies, so an edit touching any non-anchor line of the span still blocks. `check_docstring_cardinal_count_matches_constant_family` covers a docstring that states a cardinal count of an outcome family (`Covers the four outcome branches: ...`) and lists those members, while the module references more members of the same `UPPER_SNAKE` constant family than the count names (`OUTCOME_OFFENDER_UNREADABLE` is imported and exercised, yet the summary stops at four) — the drift where a summary keeps the old count after the code grows another branch; this gate runs on test modules as well as production modules. `check_docstring_raises_unraisable_largezipfile` covers a `Raises:` clause that names `zipfile.LargeZipFile` while the function opens its `zipfile.ZipFile` writer in a write mode (`w`/`a`/`x`) with `allowZip64` left at its default of True — `zipfile` raises `LargeZipFile` only when an entry needs ZIP64 and `allowZip64` is False, so a writer that allows ZIP64 documents an exception the body cannot produce. The remaining free-form prose — `"a field counts as read when ..."`, `"resolves to shared temp only"`, `"strip ceremony, then drop blockquotes"`, and module-level responsibility paragraphs — has no signature, method roster, or single structural shape to compare against, so the gate cannot catch its drift. This rule is the judgment standard for that prose; the audit lane below is the enforcement for everything outside the eight gated slices.
10
10
 
11
11
  ## What to check before you write the docstring
12
12
 
@@ -20,6 +20,7 @@ Read the body and the docstring side by side:
20
20
  - **Returns-clause cardinality.** A `Returns:` clause that names a dict-key prefix family with a plural noun (`the sheen stops`) matches the count of keys in that family in the returned dict literal. When the dict holds one key in the family (`sheen_mid`), the noun is singular (`the sheen stop`); a plural noun there claims two or more entries the dict does not hold. The `check_docstring_returns_plural_cardinality` gate blocks the single-key-with-plural-noun form of this drift at Write/Edit time.
21
21
  - **Args single-line scope vs span body.** An `Args:` entry that scopes a finding to one named line (`a finding blocks only when its block-anchor line is among the changed lines`) matches the line breadth the body scopes by. When the body builds a `range(...)` span over the finding's source lines and scopes it through a span-intersection scoper that blocks when any line of the span is among the changed lines, the single-line Args wording understates the scope: an edit touching a non-anchor line of the span still blocks. State the Args entry on the same span breadth the body uses (`a finding blocks when any line of its block span is among the changed lines`). The `check_docstring_args_single_line_scope_vs_span` gate blocks the single-line-Args-over-span-body form of this drift at Write/Edit time.
22
22
  - **Cardinal-count enumerations.** A docstring that states a count of an outcome family (`the four outcome branches`) and lists those members names every member of that family the module references. When the module imports and exercises a fifth `OUTCOME_*` constant the summary leaves out, the count and the list both under-describe the code. The `check_docstring_cardinal_count_matches_constant_family` gate blocks this drift — a cardinal-count docstring that names two or more members of a referenced `UPPER_SNAKE` constant family, leaves at least one referenced member out, and states a count below the family size — at Write/Edit time, on test modules as well as production modules.
23
+ - **Raises-clause reachability for `LargeZipFile`.** A `Raises:` clause that names `zipfile.LargeZipFile` matches a writer the body opens with ZIP64 forbidden. `zipfile` raises `LargeZipFile` only when an entry needs ZIP64 and `allowZip64` is False; a function that opens its `zipfile.ZipFile` writer in a write mode (`w`/`a`/`x`) with `allowZip64` at its default of True allows ZIP64 and never raises it, so the clause documents an unreachable exception. Drop the entry, or pass `allowZip64=False` when forbidding ZIP64 is the goal. The `check_docstring_raises_unraisable_largezipfile` gate blocks the default-ZIP64-writer form of this drift at Write/Edit time; a writer that forbids ZIP64 on any open, a read-only open, and a function that opens no writer are all left alone.
23
24
  - **Field meaning: run mode versus per record.** A dataclass or `TypedDict` field documented in the class `Attributes:` block states what the field means for one record. When the code sets that field the same way for every record (a run-mode flag such as `is_dry_run = not is_execute` at each write site), the description states the run-mode meaning, not a per-record outcome. A field named `is_dry_run` documented as `True when no STP was written` reads as a per-record write result, but the value tracks the run mode, so a record that writes no file during an execute run still stores `False`. State the run-mode meaning the assignment gives the field. The assignment that sets the field sits in another module, out of reach of the write-time gate, so this stays an O6 audit-lane judgment finding.
24
25
  - **Predicate breadth.** A boolean helper whose prose promises a narrow check accepts only the inputs the prose names — no broader input class the name and prose do not mention.
25
26
  - **Exclusion-clause distinguisher.** A docstring sentence that says a named category of input "are not" / "is not" the thing the function flags (`plain logging, screenshot, or method-on-local calls inside a branch are not dispatch steps`) keys the exclusion to the same axis the body's classification keys on. When the body decides on one axis (a call sits in an `If.test` guard versus a plain statement) but the prose excludes on a different axis (the call's receiver shape — a method on a local), the exclusion clause names a category the body still flags: a guarded method-on-local call is flagged even though the prose lists method-on-local calls as excluded. Read the body's actual branch condition, then state the exclusion on that same axis (`plain (unguarded) calls inside a branch body are not dispatch steps`), so every member the prose excludes is a member the body also excludes.
@@ -24,9 +24,9 @@ Constants placed in `config/` satisfy the constants-location rule; the use-count
24
24
 
25
25
  ## Dead constant in a dedicated constants module (cross-module)
26
26
 
27
- The use-count rule above governs a file-global constant in production code outside `config/` by counting same-file references. A dedicated constants module — a file whose name ends in `_constants.py`, or any module under a `config/` directory — exports its constants to importer modules elsewhere, so a same-file count proves nothing. A separate hook, `check_dead_module_constants` (dispatched from `code_rules_enforcer`), governs these modules: it flags an `UPPER_SNAKE` constant defined in the written module whose name appears in no `.py` module anywhere under the enclosing package tree — not imported, not read, not listed in an `__all__` literal, not named in a string annotation. That is the dead exported constant CODE_RULES §9.8 targets, caught at Write/Edit time.
27
+ The use-count rule above governs a file-global constant in production code outside `config/` by counting same-file references. A dedicated constants module — a file whose name ends in `_constants.py`, or any module under a `config/` directory — exports its constants to importer modules elsewhere, so a same-file count proves nothing. A separate hook, `check_dead_module_constants` (dispatched from `code_rules_enforcer`), governs these modules: it flags an `UPPER_SNAKE` constant defined in the written module whose name appears in no `.py` module anywhere under the enclosing package tree — not imported, not read, not listed in another module's `__all__` literal, not named in a string annotation. That is the dead exported constant CODE_RULES §9.8 targets, caught at Write/Edit time.
28
28
 
29
- The scan resolves the enclosing package tree from the written file: for a constants module inside a package subdirectory, the tree is the package's parent (so an importer one directory up is in scope); for a `config/` module, the tree is the parent of the `config` directory. A module that declares its own `__all__` is skipped — the author's explicit export surface is taken as the liveness contract. A reference from a test module under the tree keeps a constant live. Test modules and migration modules are themselves exempt from the check.
29
+ The scan resolves the enclosing package tree from the written file: for a constants module inside a package subdirectory, the tree is the package's parent (so an importer one directory up is in scope); for a `config/` module, the tree is the parent of the `config` directory. A module that declares its own `__all__` narrows the check to the constants its `__all__` list names — the explicit export surface and requires each to be imported or read by another module; the module's own `__all__` entry does not count as that consumer, so an exported constant no module consumes is flagged, while a constant `__all__` omits is the author's private value and is left alone. A reference from a test module under the tree keeps a constant live. Test modules and migration modules are themselves exempt from the check.
30
30
 
31
31
  ## Examples
32
32
 
@@ -0,0 +1,28 @@
1
+ # Public-Function Paired-Test Coverage
2
+
3
+ **When this applies:** Any Write or Edit to a production Python module that already has a dedicated stem-matched test file — `test_<stem>.py` beside the module or under an ancestor `tests/` directory — and whose paired test suite already exercises at least one of the module's public functions.
4
+
5
+ ## Rule
6
+
7
+ Every public function a production module defines is exercised by a test in the module's paired test suite. The suite proves the module is unit-tested function by function, so a public entry point the suite omits is a forgotten test: a reader who trusts the suite to cover the module's public surface misses that gap.
8
+
9
+ When you add a public function to a module whose test suite already covers a sibling public function, add a behavioral test that calls the new function and asserts on its return value or side effect — in the same change that adds the function. This is the function-level half of the project rule "Every new production code path gets a paired behavioral test ... call the path and assert on what it does."
10
+
11
+ ## What the gate checks
12
+
13
+ The `check_public_function_missing_paired_test` check in `code_rules_paired_test.py` (dispatched from `code_rules_enforcer.py`) runs on every production Python write or edit. It flags a public function when all of these hold:
14
+
15
+ 1. The target is production code — not a test module, hook infrastructure, config module, migration, workflow registry, or `__init__.py`.
16
+ 2. A stem-matched test file (`test_<stem>.py` or `<stem>_test.py`) exists for the module.
17
+ 3. That suite already references at least one public function the module defines — the signature of a maintained per-function suite rather than a placeholder or unrelated test file.
18
+ 4. The public function is referenced by no test file in the directory that holds the stem-matched test.
19
+
20
+ A public function counts as covered when its name appears — imported, called, or named — in any `test_*.py` or `*_test.py` file in that directory, so a function exercised by a differently-named sibling test still counts. `main` and underscore-prefixed functions are never required to carry a test.
21
+
22
+ ## Relationship to the file-level TDD gate
23
+
24
+ `tdd_enforcer.py` requires a fresh test file to exist before a production module is written; it judges coverage one file at a time. This check judges coverage one function at a time for a module that already carries such a test file. The two compose: the file-level gate ensures a test file exists, and this check ensures that file covers every public function the module exposes.
25
+
26
+ ## Why this is a hook, not a lint pass
27
+
28
+ A public function with no test reads as covered when the module's test file sits right beside it and exercises its siblings. The gap survives review because the suite looks complete. Catching it as the function is written keeps the module's public surface and its test suite in step.