claude-dev-env 8.10.0 → 8.11.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 (77) hide show
  1. package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_regression.py +85 -3
  2. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_regression.py +34 -0
  3. package/_shared/pr-loop/scripts/fix_pr_test_proof.py +132 -0
  4. package/_shared/pr-loop/scripts/pr_loop_shared_constants/fix_pr_test_proof_constants.py +43 -0
  5. package/_shared/pr-loop/scripts/test_fix_pr_test_proof.py +126 -0
  6. package/bin/codex-skill-load-block.mjs +45 -0
  7. package/bin/codex-skill-load-block.test.mjs +72 -0
  8. package/bin/install.agents-home.test.mjs +76 -1
  9. package/bin/install.mjs +53 -31
  10. package/bin/install.test.mjs +37 -0
  11. package/bin/install.transaction.test.mjs +29 -7
  12. package/bin/seed-codex-pstack-models.mjs +27 -0
  13. package/bin/seed-codex-pstack-models.test.mjs +72 -0
  14. package/docs/CODE_RULES.md +5 -1
  15. package/hooks/blocking/code_rules_enforcer.py +1 -3
  16. package/hooks/blocking/code_rules_magic_values.py +1 -3
  17. package/hooks/blocking/code_rules_paired_test.py +5 -6
  18. package/hooks/blocking/tdd_enforcer_parts/__init__.py +1 -1
  19. package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +1 -46
  20. package/hooks/blocking/test_pre_tool_use_dispatcher.py +2 -40
  21. package/hooks/followup_ledger.py +8 -1
  22. package/hooks/git-hooks/test_installed_policy_lint.py +4 -2
  23. package/hooks/hooks.json +15 -0
  24. package/hooks/hooks_constants/followup_ledger_constants.py +4 -0
  25. package/hooks/hooks_constants/messages.py +0 -1
  26. package/hooks/hooks_constants/nested_project_hooks_constants.py +37 -0
  27. package/hooks/hooks_constants/test_messages.py +1 -7
  28. package/hooks/lifecycle/nested_project_hooks.py +395 -0
  29. package/hooks/lifecycle/test_nested_project_hooks.py +251 -0
  30. package/hooks/test_followup_ledger.py +18 -0
  31. package/package.json +2 -1
  32. package/pstack-codex-models.md +37 -0
  33. package/rules/bdd.md +1 -1
  34. package/rules/build-needs-a-user.md +53 -0
  35. package/rules/code-standards.md +1 -1
  36. package/rules/flag-non-breaking-findings.md +7 -0
  37. package/rules/paired-test-coverage.md +4 -2
  38. package/rules/testing.md +5 -1
  39. package/scripts/cde_lint.py +47 -1
  40. package/scripts/policy_lint/adapter_callers.py +124 -0
  41. package/scripts/policy_lint/adapter_pairing.py +4 -2
  42. package/scripts/policy_lint/adapter_support.py +4 -3
  43. package/scripts/policy_lint/adapters.py +15 -0
  44. package/scripts/policy_lint/check_catalog.py +24 -0
  45. package/scripts/policy_lint/config/approved_test_pairs.py +3 -2
  46. package/scripts/policy_lint/config/check_catalog_constants.py +13 -0
  47. package/scripts/policy_lint/config/constants.py +18 -0
  48. package/scripts/policy_lint/engine.py +9 -3
  49. package/scripts/policy_lint/model.py +6 -2
  50. package/scripts/policy_lint/registry.py +6 -0
  51. package/scripts/policy_lint/selection.py +2 -0
  52. package/scripts/tests/test_cde_lint.py +50 -0
  53. package/scripts/tests/test_policy_lint_rules_archive.py +16 -0
  54. package/scripts/tests/test_policy_lint_rules_callers.py +126 -0
  55. package/scripts/tests/test_policy_lint_rules_check_catalog.py +25 -1
  56. package/scripts/tests/test_policy_lint_rules_pairing.py +31 -2
  57. package/scripts/tests/test_policy_lint_selection.py +35 -0
  58. package/hooks/blocking/tdd_enforcer.py +0 -195
  59. package/hooks/blocking/tdd_enforcer_parts/candidate_paths.py +0 -142
  60. package/hooks/blocking/tdd_enforcer_parts/config/__init__.py +0 -1
  61. package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +0 -49
  62. package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +0 -298
  63. package/hooks/blocking/tdd_enforcer_parts/decisions.py +0 -96
  64. package/hooks/blocking/tdd_enforcer_parts/freshness.py +0 -95
  65. package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +0 -132
  66. package/hooks/blocking/tdd_enforcer_parts/path_classification.py +0 -119
  67. package/hooks/blocking/tdd_enforcer_parts/tests/test_candidate_paths.py +0 -31
  68. package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +0 -256
  69. package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +0 -46
  70. package/hooks/blocking/tdd_enforcer_parts/tests/test_freshness.py +0 -28
  71. package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +0 -111
  72. package/hooks/blocking/tdd_enforcer_parts/tests/test_path_classification.py +0 -36
  73. package/hooks/blocking/test_tdd_enforcer.py +0 -1506
  74. package/hooks/blocking/test_tdd_enforcer_restore.py +0 -108
  75. package/hooks/blocking/test_tdd_enforcer_scratchpad.py +0 -105
  76. package/scripts/codex_compat_watcher.py +0 -502
  77. package/scripts/tests/test_codex_compat_watcher.py +0 -599
@@ -0,0 +1,251 @@
1
+ """Behavior tests for the nested project hooks forwarder.
2
+
3
+ Each case builds a session directory that holds child checkouts, runs the hook
4
+ script the way Claude Code does (payload on stdin, ``CLAUDE_PROJECT_DIR`` set
5
+ to the session directory), and reads the exit code and output it produces.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import pathlib
13
+ import subprocess
14
+ import sys
15
+
16
+ import pytest
17
+
18
+ HOOK_SCRIPT_PATH = pathlib.Path(__file__).parent / "nested_project_hooks.py"
19
+ BLOCKING_EXIT_CODE = 2
20
+
21
+
22
+ def _make_checkout(
23
+ session_directory: pathlib.Path,
24
+ checkout_name: str,
25
+ all_hook_groups_by_event: dict[str, list[dict[str, object]]],
26
+ ) -> pathlib.Path:
27
+ checkout_directory = session_directory / checkout_name
28
+ (checkout_directory / ".git").mkdir(parents=True)
29
+ settings_directory = checkout_directory / ".claude"
30
+ settings_directory.mkdir()
31
+ (settings_directory / "settings.json").write_text(
32
+ json.dumps({"hooks": all_hook_groups_by_event}), encoding="utf-8"
33
+ )
34
+ return checkout_directory
35
+
36
+
37
+ def _command_group(matcher: str, command: str) -> dict[str, object]:
38
+ return {"matcher": matcher, "hooks": [{"type": "command", "command": command}]}
39
+
40
+
41
+ def _run_hook(
42
+ session_directory: pathlib.Path, payload: dict[str, object]
43
+ ) -> subprocess.CompletedProcess[str]:
44
+ hook_environment = dict(os.environ)
45
+ hook_environment["CLAUDE_PROJECT_DIR"] = str(session_directory)
46
+ return subprocess.run(
47
+ [sys.executable, str(HOOK_SCRIPT_PATH)],
48
+ input=json.dumps(payload),
49
+ capture_output=True,
50
+ text=True,
51
+ env=hook_environment,
52
+ check=False,
53
+ timeout=60,
54
+ )
55
+
56
+
57
+ def _pre_tool_use_payload(tool_name: str) -> dict[str, object]:
58
+ return {"hook_event_name": "PreToolUse", "tool_name": tool_name, "tool_input": {}}
59
+
60
+
61
+ def _session_start_payload() -> dict[str, object]:
62
+ return {"hook_event_name": "SessionStart", "source": "startup"}
63
+
64
+
65
+ def test_should_run_a_child_session_start_hook_inside_that_child(
66
+ tmp_path: pathlib.Path,
67
+ ) -> None:
68
+ record_path = tmp_path / "record.txt"
69
+ checkout_directory = _make_checkout(
70
+ tmp_path / "session",
71
+ "alpha",
72
+ {
73
+ "SessionStart": [
74
+ _command_group(
75
+ "",
76
+ f'echo "$CLAUDE_PROJECT_DIR|$(pwd)" > "{record_path}"',
77
+ )
78
+ ]
79
+ },
80
+ )
81
+
82
+ completed_process = _run_hook(tmp_path / "session", _session_start_payload())
83
+
84
+ assert completed_process.returncode == 0
85
+ resolved_checkout = str(checkout_directory.resolve())
86
+ project_directory, working_directory = (
87
+ record_path.read_text(encoding="utf-8").strip().split("|")
88
+ )
89
+ assert str(pathlib.Path(project_directory).resolve()) == resolved_checkout
90
+ assert str(pathlib.Path(working_directory).resolve()) == resolved_checkout
91
+
92
+
93
+ def test_should_carry_child_session_start_output_as_additional_context(
94
+ tmp_path: pathlib.Path,
95
+ ) -> None:
96
+ _make_checkout(
97
+ tmp_path,
98
+ "alpha",
99
+ {"SessionStart": [_command_group("", "echo alpha is ready")]},
100
+ )
101
+ _make_checkout(
102
+ tmp_path,
103
+ "beta",
104
+ {"SessionStart": [_command_group("", "echo beta is ready")]},
105
+ )
106
+
107
+ completed_process = _run_hook(tmp_path, _session_start_payload())
108
+
109
+ assert completed_process.returncode == 0
110
+ hook_output = json.loads(completed_process.stdout)
111
+ additional_context = hook_output["hookSpecificOutput"]["additionalContext"]
112
+ assert hook_output["hookSpecificOutput"]["hookEventName"] == "SessionStart"
113
+ assert "alpha is ready" in additional_context
114
+ assert "beta is ready" in additional_context
115
+ assert additional_context.index("alpha") < additional_context.index("beta")
116
+
117
+
118
+ def test_should_block_a_tool_call_when_a_child_hook_exits_two(
119
+ tmp_path: pathlib.Path,
120
+ ) -> None:
121
+ _make_checkout(
122
+ tmp_path,
123
+ "alpha",
124
+ {"PreToolUse": [_command_group("Bash", "echo the gate refused this >&2; exit 2")]},
125
+ )
126
+
127
+ completed_process = _run_hook(tmp_path, _pre_tool_use_payload("Bash"))
128
+
129
+ assert completed_process.returncode == BLOCKING_EXIT_CODE
130
+ assert "the gate refused this" in completed_process.stderr
131
+
132
+
133
+ def test_should_pass_a_child_deny_decision_through(tmp_path: pathlib.Path) -> None:
134
+ deny_output = {
135
+ "hookSpecificOutput": {
136
+ "hookEventName": "PreToolUse",
137
+ "permissionDecision": "deny",
138
+ "permissionDecisionReason": "no receipt for this commit",
139
+ }
140
+ }
141
+ _make_checkout(
142
+ tmp_path,
143
+ "alpha",
144
+ {
145
+ "PreToolUse": [
146
+ _command_group(
147
+ "Bash|mcp__github__merge_pull_request",
148
+ f"echo '{json.dumps(deny_output)}'",
149
+ )
150
+ ]
151
+ },
152
+ )
153
+
154
+ completed_process = _run_hook(
155
+ tmp_path, _pre_tool_use_payload("mcp__github__merge_pull_request")
156
+ )
157
+
158
+ assert completed_process.returncode == 0
159
+ assert json.loads(completed_process.stdout) == deny_output
160
+
161
+
162
+ def test_should_not_forward_a_child_allow_decision(tmp_path: pathlib.Path) -> None:
163
+ allow_output = {
164
+ "hookSpecificOutput": {
165
+ "hookEventName": "PreToolUse",
166
+ "permissionDecision": "allow",
167
+ }
168
+ }
169
+ _make_checkout(
170
+ tmp_path,
171
+ "alpha",
172
+ {"PreToolUse": [_command_group("", f"echo '{json.dumps(allow_output)}'")]},
173
+ )
174
+
175
+ completed_process = _run_hook(tmp_path, _pre_tool_use_payload("Bash"))
176
+
177
+ assert completed_process.returncode == 0
178
+ assert completed_process.stdout.strip() == ""
179
+
180
+
181
+ def test_should_skip_a_child_hook_whose_matcher_names_another_tool(
182
+ tmp_path: pathlib.Path,
183
+ ) -> None:
184
+ _make_checkout(
185
+ tmp_path,
186
+ "alpha",
187
+ {"PreToolUse": [_command_group("Write|Edit", "exit 2")]},
188
+ )
189
+
190
+ completed_process = _run_hook(tmp_path, _pre_tool_use_payload("Bash"))
191
+
192
+ assert completed_process.returncode == 0
193
+
194
+
195
+ def test_should_do_nothing_when_the_session_directory_is_a_checkout(
196
+ tmp_path: pathlib.Path,
197
+ ) -> None:
198
+ (tmp_path / ".git").mkdir()
199
+ _make_checkout(
200
+ tmp_path,
201
+ "alpha",
202
+ {"PreToolUse": [_command_group("", "exit 2")]},
203
+ )
204
+
205
+ completed_process = _run_hook(tmp_path, _pre_tool_use_payload("Bash"))
206
+
207
+ assert completed_process.returncode == 0
208
+ assert completed_process.stdout.strip() == ""
209
+
210
+
211
+ def test_should_skip_a_child_directory_that_is_not_a_checkout(
212
+ tmp_path: pathlib.Path,
213
+ ) -> None:
214
+ checkout_directory = _make_checkout(
215
+ tmp_path,
216
+ "alpha",
217
+ {"PreToolUse": [_command_group("", "exit 2")]},
218
+ )
219
+ (checkout_directory / ".git").rmdir()
220
+
221
+ completed_process = _run_hook(tmp_path, _pre_tool_use_payload("Bash"))
222
+
223
+ assert completed_process.returncode == 0
224
+
225
+
226
+ def test_should_let_the_call_through_when_a_child_hook_fails_to_start(
227
+ tmp_path: pathlib.Path,
228
+ ) -> None:
229
+ _make_checkout(
230
+ tmp_path,
231
+ "alpha",
232
+ {"PreToolUse": [_command_group("", "a-command-that-does-not-exist")]},
233
+ )
234
+
235
+ completed_process = _run_hook(tmp_path, _pre_tool_use_payload("Bash"))
236
+
237
+ assert completed_process.returncode == 0
238
+
239
+
240
+ @pytest.mark.parametrize("unreadable_settings", ["{not json", "[]", '{"hooks": []}'])
241
+ def test_should_skip_a_child_whose_settings_cannot_be_read(
242
+ tmp_path: pathlib.Path, unreadable_settings: str
243
+ ) -> None:
244
+ checkout_directory = _make_checkout(tmp_path, "alpha", {})
245
+ (checkout_directory / ".claude" / "settings.json").write_text(
246
+ unreadable_settings, encoding="utf-8"
247
+ )
248
+
249
+ completed_process = _run_hook(tmp_path, _pre_tool_use_payload("Bash"))
250
+
251
+ assert completed_process.returncode == 0
@@ -1,6 +1,7 @@
1
1
  """Behavior tests for the non-breaking-finding follow-up ledger."""
2
2
 
3
3
  import json
4
+ import subprocess
4
5
  import sys
5
6
  from pathlib import Path
6
7
 
@@ -82,6 +83,23 @@ def test_record_followup_finding_writes_one_json_object_per_line(tmp_path: Path)
82
83
  assert json.loads(all_lines[0])["rule_id"] == "instruction-mode"
83
84
 
84
85
 
86
+ def test_a_recorded_ledger_stays_out_of_git_status(tmp_path: Path) -> None:
87
+ subprocess.run(["git", "init", "--quiet", str(tmp_path)], check=True)
88
+
89
+ record_followup_finding(
90
+ tmp_path, FollowupFinding("instruction-mode", "docs/AGENTS.md", "wrong mode")
91
+ )
92
+
93
+ status_result = subprocess.run(
94
+ ["git", "-C", str(tmp_path), "status", "--porcelain", "--untracked-files=all"],
95
+ check=True,
96
+ capture_output=True,
97
+ text=True,
98
+ )
99
+ assert followup_ledger_path(tmp_path).is_file()
100
+ assert status_result.stdout == ""
101
+
102
+
85
103
  def test_record_followup_finding_swallows_a_write_failure(tmp_path: Path) -> None:
86
104
  blocking_file = tmp_path / ".claude"
87
105
  blocking_file.write_text("not a directory", encoding="utf-8")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.10.0",
3
+ "version": "8.11.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,6 +27,7 @@
27
27
  "_shared/",
28
28
  "audit-rubrics/",
29
29
  "codex-rules/",
30
+ "pstack-codex-models.md",
30
31
  "AGENTS.md",
31
32
  ".claude/CLAUDE.md",
32
33
  "!**/__pycache__/**",
@@ -0,0 +1,37 @@
1
+ # pstack model configuration
2
+
3
+ feature, refactoring: gpt-6-sol
4
+ bug-fix: gpt-6-astra
5
+ perf-issue: gpt-6-sol
6
+ hillclimb: gpt-6-sol
7
+ judgment and prose: gpt-6-astra
8
+ strongest judgment: gpt-6-astra
9
+ how explorer: gpt-6-luna
10
+ how explainer: gpt-6-luna
11
+ why investigators: gpt-6-luna
12
+ why synthesizer: gpt-6-luna
13
+ reflect tooling: gpt-6-sol
14
+ reflect judgment, divergent, synthesizer: gpt-6-astra
15
+ arena runners: gpt-6-astra, gpt-6-sol, gpt-6-luna
16
+ arena cross-judge pool: gpt-6-astra, gpt-6-sol, gpt-6-luna
17
+ swarm workers: gpt-6-luna
18
+ architect runners: gpt-6-astra, gpt-6-sol, gpt-6-luna
19
+ interrogate reviewers: gpt-6-astra, gpt-6-sol, gpt-6-luna
20
+
21
+ ## Reasoning effort
22
+
23
+ Single-model Luna roles use max. Astra roles use low. Sol roles use medium.
24
+ Panel entries use low, max, xhigh in the listed order.
25
+ Keep duplicate panel entries. Each entry specifies one candidate.
26
+ The cross-judge pool retains the effort paired with each entry.
27
+
28
+ Apply these substitutions once to incoming model requests:
29
+ - Sol medium through Luna max requests use gpt-6-luna with max effort.
30
+ - Sol high or higher requests use gpt-6-astra with low effort.
31
+ - Terra requests use gpt-6-luna with xhigh effort.
32
+ - Existing Luna requests use gpt-6-luna with high effort.
33
+
34
+ Explicit role and panel settings above take precedence over substitutions.
35
+ Do not apply substitutions again to their output.
36
+
37
+ session hook: on
package/rules/bdd.md CHANGED
@@ -13,7 +13,7 @@
13
13
  1. **Deliberate Discovery** — Reduce uncertainty before code; surface what you do not know (Smart & Molak §5.4).
14
14
  2. **Illustrate** — Explore goals, constraints, and concrete examples in chat; "given … when … then …" style outcomes.
15
15
  3. **Formulate** — Express behavior as narrow **"should …"** specifications the user can approve.
16
- 4. **Automate** — Build each formulated behavior through the TDD inner loop: CODE_RULES §8, red-green-refactor, canonical as stated in [`code-standards.md`](code-standards.md).
16
+ 4. **Automate** — Build each formulated behavior with tests. Red-green-refactor is the default loop, and the TDD skill (`pstack:tdd`) carries it: CODE_RULES §8, as stated in [`code-standards.md`](code-standards.md). A prototype may run ahead of its tests and adds them before the pull request goes ready.
17
17
 
18
18
  Conversation is the essential practice: if discovery is skipped, structured formats do not rescue the workflow (Minimal BDD).
19
19
 
@@ -0,0 +1,53 @@
1
+ # Build Needs a User
2
+
3
+ **When this applies:** Before you build a new tool, script, hook, gate, skill,
4
+ command, or workflow step. That includes a hook or check built to answer a
5
+ correction.
6
+
7
+ ## Rule
8
+
9
+ Answer three questions before the first line of the build:
10
+
11
+ 1. **Who calls it?** Name the hook entry, workflow step, skill, command, or
12
+ module that will run it in this same change.
13
+ 2. **When does it first run?** Name the date or the event of first use.
14
+ 3. **When was this job last done by hand?** Name the last time, and how often it
15
+ comes up.
16
+
17
+ Build when all three have a concrete answer. When the only answer is "it might
18
+ help" or "we could use it later", stop and send the owner one line: what the
19
+ build is for and which question has no answer. Keep the rest of the task moving
20
+ while that line waits.
21
+
22
+ Search first, per [`prefer-existing-tools.md`](prefer-existing-tools.md). A
23
+ tool that already exists answers the need, and the build is the glue.
24
+
25
+ ## Size the first build to the first use
26
+
27
+ Build the smallest version that serves the first caller. A correction starts
28
+ as a row in a rule file. It becomes a hook or a lint only when the same
29
+ correction arrives a second time, per
30
+ [`correction-lens.md`](correction-lens.md).
31
+
32
+ ## Where it is enforced
33
+
34
+ The staged policy lint's `uncalled-new-file` rule reads each code file a change
35
+ adds under `scripts/`, `hooks/`, or `bin/`. It reports the file when its name
36
+ appears only in its own tests, `CHANGELOG.md`, `README.md`, and
37
+ `bin/ever-shipped-skills.mjs`. CI runs that lint against the merge base, so a
38
+ file with no caller turns the pull request red.
39
+
40
+ ## Why
41
+
42
+ The history of this package holds files that lived weeks and ran never. The
43
+ Codex compatibility watcher shipped 502 lines and 599 lines of tests in July,
44
+ and PR 1488 deleted it in September. Its own test was the only file that named
45
+ it.
46
+
47
+ ## Sibling rules
48
+
49
+ | Rule | Role |
50
+ |---|---|
51
+ | [`prefer-existing-tools.md`](prefer-existing-tools.md) | Search before you build |
52
+ | [`correction-lens.md`](correction-lens.md) | A repeated correction moves up a layer |
53
+ | [`archiving-agent-config.md`](archiving-agent-config.md) | How an unused capability leaves service |
@@ -18,7 +18,7 @@ Load `CODE_RULES.md` when reviewing a PR, resolving a policy conflict, or genera
18
18
 
19
19
  Two standards live in `CODE_RULES.md` in full:
20
20
 
21
- - **TDD** — CODE_RULES §8 / AGENTS Tests: red, green, refactor; no production code before a failing test.
21
+ - **TDD** — CODE_RULES §8: red, green, refactor is the default loop for a bug fix and for new behavior, and the TDD skill (`pstack:tdd`) carries the procedure. A prototype may run ahead of its tests and adds them before the pull request goes ready. A bug fix ships with a test that reproduces the bug.
22
22
  - **Right-sized engineering** — CODE_RULES §7 / AGENTS Design: functions over classes; concrete over abstract; add an abstraction at the commit that introduces its second concrete implementation. That count is a house call, one occurrence earlier than the rule of three Fowler credits to Don Roberts. The direction comes from the literature; the number does not, so read it as this package's setting rather than as a cited standard.
23
23
 
24
24
  BDD is the outer process and TDD is the inner loop: [`bdd.md`](bdd.md) discovers and formulates the behavior a feature needs, then each formulated behavior is built through the TDD cycle.
@@ -108,6 +108,13 @@ naming a file that does not exist, an env-var row naming a file that never
108
108
  reads the variable, a test outside the testpaths allowlist, and a tracked
109
109
  secret.
110
110
 
111
+ `scripts/cde_lint.py` splits the policy-lint findings the same way.
112
+ `SEVERITY_BY_CHECK_ID` in `scripts/policy_lint/config/check_catalog_constants.py`
113
+ carries one row per smell, and a check id with no row reads as breaking. Three
114
+ checks are smells there: `test-pairing` and the two paired-test coverage
115
+ checks. The lint prints each as a warning, records it in the ledger, and exits
116
+ zero when no error remains.
117
+
111
118
  ## Sibling rules
112
119
 
113
120
  | Rule | Role |
@@ -20,6 +20,8 @@ When you add a public function to a module whose test suite already exercises th
20
20
 
21
21
  Two complementary checks in `code_rules_paired_test.py` reach changed files through `code_rules_enforcer.py`, which the staged policy lint runs under its `code-rules` rule. No write-time hook runs them, so CI is where they report, against the merge base. The two checks cover the two write orders.
22
22
 
23
+ Both checks record smells, per [`flag-non-breaking-findings.md`](flag-non-breaking-findings.md). `SEVERITY_BY_CHECK_ID` in `scripts/policy_lint/config/check_catalog_constants.py` declares `code-rules/paired-test-missing-function` and `code-rules/paired-test-omitted-function` as smells. The lint prints each finding as a warning, records it in `.claude/followups/smells.jsonl`, and exits zero when every finding is a warning. A later pull request adds the missing tests.
24
+
23
25
  `check_public_function_missing_paired_test` runs on a production Python write or edit and flags a public function when all of these hold:
24
26
 
25
27
  1. The target is production code — not a test module, hook infrastructure, config module, migration, workflow registry, or `__init__.py`.
@@ -33,8 +35,8 @@ A public function counts as covered when its name appears — imported, called,
33
35
 
34
36
  ## Relationship to the file-level TDD order
35
37
 
36
- `tdd_enforcer.py` once required a fresh test file to exist before a production module was written, judging coverage one file at a time. Nothing runs it now, and the staged policy lint carries no replacement, so the test-first order is yours to hold. This check judges coverage one function at a time for a module that already carries such a test file. Write the test file first, and the lint then reports any public function that file leaves uncovered.
38
+ No hook or lint checks the order in which a test and its module are written. Red-green-refactor is the default loop, and the TDD skill (`pstack:tdd`) carries it; a prototype may run ahead of its tests and adds them before the pull request goes ready. This check judges coverage one function at a time for a module that already carries a stem-matched test file, and reports any public function that file leaves uncovered.
37
39
 
38
40
  ## Why this check is mechanical
39
41
 
40
- 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. Running the lint on every staged change keeps the module's public surface and its test suite in step.
42
+ 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. Running the lint on every staged change records each gap in the follow-up ledger, where `cde followup list` names it until a pull request closes it.
package/rules/testing.md CHANGED
@@ -25,4 +25,8 @@ Tests exercise production behavior, production data, and production code paths.
25
25
 
26
26
  ## No Gate Holds the Test-First Order
27
27
 
28
- `tdd_enforcer.py` once required a fresh, failing test before a production module was written. Nothing runs it now, and the staged policy lint carries no replacement. Hold the red-green-refactor order yourself, and let review check it on the diff.
28
+ Red, green, refactor is the default loop for a bug fix and for new behavior. The TDD skill (`pstack:tdd`) carries the procedure, and no hook or lint checks the order. A prototype may run ahead of its tests and adds them before the pull request goes ready. A bug fix ships with a test that reproduces the bug. Review reads the tests on the diff.
29
+
30
+ ## A Fix Carries Its Proof Test
31
+
32
+ The `Fix test proof` job in `.github/workflows/pr-check.yml` runs `_shared/pr-loop/scripts/fix_pr_test_proof.py` on every pull request whose title starts with `fix`. A fix that changes production code must change at least one Python test that fails on the base and passes on the head. A fix that changes only docs or CI config passes. Version one reads Python tests only, so a fix proven by a JavaScript or PowerShell test needs a Python test beside it.
@@ -11,11 +11,24 @@ from typing import Never, TextIO
11
11
 
12
12
  from policy_lint.config.constants import INVALID_INPUT_EXIT_CODE
13
13
  from policy_lint.engine import lint
14
- from policy_lint.model import LintReport, LintRequest, SelectionKind, TextDocument
14
+ from policy_lint.model import (
15
+ LintReport,
16
+ LintRequest,
17
+ SelectionKind,
18
+ Severity,
19
+ TextDocument,
20
+ )
15
21
  from policy_lint.render import ReportFormat, render
16
22
  from policy_lint.selection import SelectionRunFatal
17
23
  from policy_lint.selection_git import GitSelectionError, git_bytes_for
18
24
 
25
+ _hooks_directory = str(Path(__file__).resolve().parents[1] / "hooks")
26
+ if _hooks_directory not in sys.path:
27
+ sys.path.insert(0, _hooks_directory)
28
+
29
+ from followup_ledger import FollowupFinding, head_commit, record_followup_finding
30
+ from hooks_constants.followup_ledger_constants import SEVERITY_SMELL
31
+
19
32
 
20
33
  class _LintUsageError(ValueError):
21
34
  """Raised when command arguments cannot form a source selection."""
@@ -201,6 +214,38 @@ def _write_invalid_input(error: Exception, stderr: TextIO) -> int:
201
214
  return INVALID_INPUT_EXIT_CODE
202
215
 
203
216
 
217
+ def _record_warning_followups(report: LintReport, repository_root: Path) -> None:
218
+ """Append each warning in the report to the repository's follow-up ledger.
219
+
220
+ Args:
221
+ report: The findings this run produced.
222
+ repository_root: The repository whose ledger receives the warnings.
223
+ """
224
+ all_warnings = [
225
+ each_diagnostic
226
+ for each_diagnostic in report.diagnostics
227
+ if each_diagnostic.severity is Severity.WARNING
228
+ ]
229
+ if not all_warnings:
230
+ return
231
+ origin_commit = head_commit(repository_root)
232
+ for each_warning in all_warnings:
233
+ file_path = (
234
+ "" if each_warning.location is None else each_warning.location.path.as_posix()
235
+ )
236
+ record_followup_finding(
237
+ repository_root,
238
+ FollowupFinding(
239
+ each_warning.rule_id,
240
+ file_path,
241
+ each_warning.message,
242
+ each_warning.check_id or each_warning.rule_id,
243
+ SEVERITY_SMELL,
244
+ origin_commit,
245
+ ),
246
+ )
247
+
248
+
204
249
  def _render_lint(
205
250
  all_arguments: Sequence[str],
206
251
  repository_root: Path,
@@ -212,6 +257,7 @@ def _render_lint(
212
257
  all_arguments, stdin, repository_root, lint_runner
213
258
  )
214
259
  stdout.write(render(report, report_format))
260
+ _record_warning_followups(report, repository_root)
215
261
  return report.exit_code
216
262
 
217
263
 
@@ -0,0 +1,124 @@
1
+ """Report a new code file that nothing outside its own tests calls.
2
+
3
+ A script, hook or skill script earns its place when something runs it: a
4
+ ``hooks.json`` entry, a workflow step, a skill or command, or an import from
5
+ live code. A file whose only mention is its own test ships work nobody uses.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from pathlib import Path, PurePosixPath
12
+
13
+ from .config import constants
14
+ from .model import Diagnostic, Document, DocumentSet, Location, SelectionKind, Severity
15
+
16
+
17
+ def _is_test_path(path: PurePosixPath) -> bool:
18
+ return (
19
+ path.name.startswith("test_")
20
+ or path.stem.endswith("_test")
21
+ or ".test." in path.name
22
+ or "tests" in path.parts
23
+ or "test" in path.parts
24
+ )
25
+
26
+
27
+ def _is_new_code_file(document: Document) -> bool:
28
+ if document.prior_text is not None or document.prior_path is not None:
29
+ return False
30
+ path = document.path
31
+ if path.suffix.lower() not in constants.ALL_CALLED_CODE_SUFFIXES:
32
+ return False
33
+ if path.name in constants.ALL_UNCALLED_EXEMPT_FILE_NAMES or _is_test_path(path):
34
+ return False
35
+ normalized_path = f"/{path.as_posix()}"
36
+ return any(
37
+ each_segment in normalized_path
38
+ for each_segment in constants.ALL_CALLED_CODE_SEGMENTS
39
+ )
40
+
41
+
42
+ def _is_caller_source(
43
+ relative_path: PurePosixPath, all_candidate_paths: frozenset[PurePosixPath]
44
+ ) -> bool:
45
+ if constants.ALL_CALLER_SEARCH_SKIPPED_DIRECTORIES.intersection(
46
+ relative_path.parts
47
+ ):
48
+ return False
49
+ if relative_path.name in constants.ALL_INVENTORY_FILE_NAMES:
50
+ return False
51
+ return relative_path not in all_candidate_paths and not _is_test_path(
52
+ relative_path
53
+ )
54
+
55
+
56
+ def _read_caller_text(file_path: Path) -> str:
57
+ try:
58
+ return file_path.read_text("utf-8")
59
+ except (OSError, UnicodeDecodeError):
60
+ return ""
61
+
62
+
63
+ def _caller_texts(
64
+ repository_root: Path, all_candidate_paths: frozenset[PurePosixPath]
65
+ ) -> tuple[str, ...]:
66
+ return tuple(
67
+ _read_caller_text(each_path)
68
+ for each_path in repository_root.rglob("*")
69
+ if each_path.is_file()
70
+ and _is_caller_source(
71
+ PurePosixPath(each_path.relative_to(repository_root).as_posix()),
72
+ all_candidate_paths,
73
+ )
74
+ )
75
+
76
+
77
+ def _has_caller(document: Document, all_caller_texts: tuple[str, ...]) -> bool:
78
+ name_pattern = re.compile(
79
+ constants.CALLER_NAME_PATTERN_TEMPLATE.format(
80
+ name=re.escape(document.path.stem)
81
+ )
82
+ )
83
+ return any(name_pattern.search(each_text) for each_text in all_caller_texts)
84
+
85
+
86
+ def _uncalled_diagnostic(document: Document) -> Diagnostic:
87
+ return Diagnostic(
88
+ constants.UNCALLED_NEW_FILE_RULE_ID,
89
+ Severity.ERROR,
90
+ constants.UNCALLED_NEW_FILE_MESSAGE.format(file_name=document.path.name),
91
+ Location(document.path, 1, 1),
92
+ )
93
+
94
+
95
+ def uncalled_new_file_diagnostics(
96
+ document_set: DocumentSet,
97
+ ) -> tuple[Diagnostic, ...]:
98
+ """Report each new code file with no caller outside its tests.
99
+
100
+ Args:
101
+ document_set: Staged or base change selection.
102
+
103
+ Returns:
104
+ One diagnostic for each added script, hook or skill script whose name
105
+ appears only in tests and inventory files.
106
+ """
107
+ if document_set.selection not in {SelectionKind.STAGED, SelectionKind.BASE}:
108
+ return ()
109
+ all_new_documents = tuple(
110
+ each_document
111
+ for each_document in document_set.documents
112
+ if _is_new_code_file(each_document)
113
+ )
114
+ if not all_new_documents:
115
+ return ()
116
+ all_caller_texts = _caller_texts(
117
+ document_set.repository_root,
118
+ frozenset(each_document.path for each_document in all_new_documents),
119
+ )
120
+ return tuple(
121
+ _uncalled_diagnostic(each_document)
122
+ for each_document in all_new_documents
123
+ if not _has_caller(each_document, all_caller_texts)
124
+ )