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
@@ -1,96 +0,0 @@
1
- """Build and emit the gate's allow and deny decisions.
2
-
3
- Writes the PreToolUse decision JSON to stdout and, on a deny, records the block
4
- and attaches the short user-facing notice.
5
- """
6
-
7
- import json
8
- import sys
9
- from pathlib import Path
10
-
11
- from tdd_enforcer_parts.config.tdd_enforcer_constants import (
12
- ENTRY_HOOK_FILE_NAME,
13
- FRESHNESS_WINDOW_SECONDS,
14
- NEWLINE_JOIN_SEPARATOR,
15
- )
16
-
17
- from hooks_constants.hook_block_logger import log_hook_block
18
- from hooks_constants.messages import USER_FACING_TDD_NOTICE
19
-
20
-
21
- def _entry_hook_path() -> Path:
22
- """Return the entry hook file that wires the gate's exemption rules together.
23
-
24
- The exemption logic the deny message points at lives in the entry hook
25
- beside this parts package, not in this emit-only module::
26
-
27
- tdd_enforcer_parts/decisions.py -> parent -> blocking/
28
- blocking/tdd_enforcer.py <- the named exemption home
29
-
30
- So the guidance names the entry hook, letting a reader land on the module
31
- that actually decides exemptions.
32
- """
33
- return Path(__file__).resolve().parent.parent / ENTRY_HOOK_FILE_NAME
34
-
35
-
36
- def build_deny_reason(production_path: Path, all_candidates: list[Path]) -> str:
37
- """Return the verbose deny message naming the candidate test files.
38
-
39
- Args:
40
- production_path: The production file the write targets.
41
- all_candidates: Candidate test paths the gate looked for.
42
-
43
- Returns:
44
- The multi-line reason shown when no fresh test satisfies the gate.
45
- """
46
- candidate_lines = NEWLINE_JOIN_SEPARATOR.join(f" - {each_path}" for each_path in all_candidates)
47
- hook_source_path = _entry_hook_path()
48
- return (
49
- f"[TDD] Blocking write to production file: {production_path}\n"
50
- f"No matching test file exists. Or none of them is fresh, carries real test "
51
- f"evidence, and holds content HEAD does not have yet. Or a previously "
52
- f"recorded test's content has not changed within the last "
53
- f"{FRESHNESS_WINDOW_SECONDS} seconds. Write a new test or change an "
54
- f"existing one; touching a file without changing its content does not "
55
- f"satisfy this gate.\n"
56
- f"Expected one of:\n{candidate_lines}\n"
57
- f"Write a failing test first (RED), then the minimum code to pass it (GREEN).\n\n"
58
- f"If this file legitimately does not need a test (for example, a module containing only "
59
- f"module-level constants with no behavior), that is a hook enhancement, not a bypass. "
60
- f"Propose an exemption rule in {hook_source_path} so every similar file benefits "
61
- f"automatically. Do not add escape-hatch markers to production files."
62
- )
63
-
64
-
65
- def emit_allow() -> None:
66
- """Write the allow decision to stdout."""
67
- allow_payload = {
68
- "hookSpecificOutput": {
69
- "hookEventName": "PreToolUse",
70
- "permissionDecision": "allow",
71
- }
72
- }
73
- sys.stdout.write(json.dumps(allow_payload))
74
-
75
-
76
- def emit_deny(reason: str) -> None:
77
- """Write the deny decision to stdout and record the block.
78
-
79
- Args:
80
- reason: The verbose deny reason shown to the agent.
81
- """
82
- deny_payload = {
83
- "hookSpecificOutput": {
84
- "hookEventName": "PreToolUse",
85
- "permissionDecision": "deny",
86
- "permissionDecisionReason": reason,
87
- },
88
- "suppressOutput": True,
89
- "systemMessage": USER_FACING_TDD_NOTICE,
90
- }
91
- log_hook_block(
92
- calling_hook_name="tdd_enforcer.py",
93
- hook_event="PreToolUse",
94
- block_reason=reason,
95
- )
96
- sys.stdout.write(json.dumps(deny_payload))
@@ -1,95 +0,0 @@
1
- """Freshness check for the candidate test files the gate resolves.
2
-
3
- A candidate satisfies the gate when it was modified inside the freshness window
4
- and holds a real test function, so a stale test or a same-named file with no
5
- test evidence does not open the gate.
6
- """
7
-
8
- import re
9
- import time
10
- from pathlib import Path
11
-
12
- from tdd_enforcer_parts.config.tdd_enforcer_constants import FRESHNESS_WINDOW_SECONDS
13
-
14
-
15
- def _freshness_seconds() -> int:
16
- return FRESHNESS_WINDOW_SECONDS
17
-
18
-
19
- def _test_file_encoding() -> str:
20
- return "utf-8"
21
-
22
-
23
- def _test_function_patterns() -> tuple[re.Pattern[str], ...]:
24
- return (
25
- re.compile(r"\bdef\s+test_"),
26
- re.compile(r"\b(?:it|test|describe)\s*\("),
27
- )
28
-
29
-
30
- def _safe_mtime(candidate_path: Path) -> float | None:
31
- try:
32
- return candidate_path.stat().st_mtime
33
- except (FileNotFoundError, OSError):
34
- return None
35
-
36
-
37
- def _read_candidate_text(candidate_path: Path) -> str | None:
38
- try:
39
- with candidate_path.open("r", encoding=_test_file_encoding(), errors="ignore") as each_file:
40
- return each_file.read()
41
- except (FileNotFoundError, OSError):
42
- return None
43
-
44
-
45
- def has_test_evidence_in(candidate_text: str) -> bool:
46
- """Return whether the given text holds a real test function.
47
-
48
- Takes the text rather than the path, so a caller that has already read the
49
- file does not read it a second time.
50
-
51
- Args:
52
- candidate_text: The candidate test file's full text.
53
-
54
- Returns:
55
- True when the text matches any test-function pattern.
56
- """
57
- return any(
58
- each_pattern.search(candidate_text) for each_pattern in _test_function_patterns()
59
- )
60
-
61
-
62
- def _contains_test_evidence(candidate_path: Path) -> bool:
63
- test_file_content = _read_candidate_text(candidate_path)
64
- if test_file_content is None:
65
- return False
66
- return has_test_evidence_in(test_file_content)
67
-
68
-
69
- def _candidate_is_fresh_test(
70
- candidate_path: Path, current_time: float, freshness_seconds: int
71
- ) -> bool:
72
- candidate_mtime = _safe_mtime(candidate_path)
73
- if candidate_mtime is None:
74
- return False
75
- if current_time - candidate_mtime > freshness_seconds:
76
- return False
77
- return _contains_test_evidence(candidate_path)
78
-
79
-
80
- def has_fresh_test(all_candidates: list[Path], freshness_seconds: int) -> bool:
81
- """Return whether any candidate is a recently modified real test file.
82
-
83
- Args:
84
- all_candidates: Candidate test paths for the production file.
85
- freshness_seconds: Maximum age, in seconds, a test may have.
86
-
87
- Returns:
88
- True when at least one candidate was modified within the window and
89
- holds a test function.
90
- """
91
- current_time = time.time()
92
- return any(
93
- _candidate_is_fresh_test(each_candidate, current_time, freshness_seconds)
94
- for each_candidate in all_candidates
95
- )
@@ -1,132 +0,0 @@
1
- """Ask git about a candidate path: is it a restore, and does it carry a real edit.
2
-
3
- ::
4
-
5
- remove orders.py (git-tracked) -> Write orders.py -> restore (allow)
6
- Write brand_new.py (untracked, absent) -> new code (gate)
7
-
8
- A full rewrite happens as remove-then-Write, so the file is absent on disk
9
- between the two steps. Restoring committed code is not new production code, so
10
- the gate exempts a Write whose target is absent yet tracked in git.
11
-
12
- ``has_uncommitted_change_from_head`` answers a second, related question the
13
- content-hash store's first-sight check needs: whether a candidate test file
14
- already carries content HEAD does not have, so a `touch` on an already-clean,
15
- committed test cannot pass as a first sighting.
16
- """
17
-
18
- import subprocess
19
- from pathlib import Path
20
-
21
- from tdd_enforcer_parts.config.tdd_enforcer_constants import (
22
- GIT_DIFF_QUIET_FLAG,
23
- GIT_DIFF_SUBCOMMAND,
24
- GIT_DIFF_TIMEOUT_SECONDS,
25
- GIT_EXECUTABLE_NAME,
26
- GIT_HEAD_REVISION,
27
- GIT_LS_FILES_SUBCOMMAND,
28
- GIT_LS_FILES_TIMEOUT_SECONDS,
29
- GIT_PATHSPEC_SEPARATOR,
30
- )
31
-
32
- from hooks_constants.subprocess_window import hidden_window_creation_flags
33
-
34
-
35
- def _git_ls_files_command(file_name: str) -> list[str]:
36
- return [GIT_EXECUTABLE_NAME, GIT_LS_FILES_SUBCOMMAND, GIT_PATHSPEC_SEPARATOR, file_name]
37
-
38
-
39
- def _git_diff_quiet_against_head_command(file_name: str) -> list[str]:
40
- return [
41
- GIT_EXECUTABLE_NAME,
42
- GIT_DIFF_SUBCOMMAND,
43
- GIT_DIFF_QUIET_FLAG,
44
- GIT_HEAD_REVISION,
45
- GIT_PATHSPEC_SEPARATOR,
46
- file_name,
47
- ]
48
-
49
-
50
- def _git_tracks_path(path: Path) -> bool:
51
- parent_directory = path.parent
52
- if not parent_directory.is_dir():
53
- return False
54
- try:
55
- completed = subprocess.run(
56
- _git_ls_files_command(path.name),
57
- cwd=str(parent_directory),
58
- capture_output=True,
59
- text=True,
60
- check=False,
61
- timeout=GIT_LS_FILES_TIMEOUT_SECONDS,
62
- creationflags=hidden_window_creation_flags(),
63
- )
64
- except (OSError, subprocess.SubprocessError):
65
- return False
66
- return completed.returncode == 0 and bool(completed.stdout.strip())
67
-
68
-
69
- def _working_tree_differs_from_head(path: Path) -> bool:
70
- """Compare the working tree to HEAD, not to the index.
71
-
72
- A plain ``git diff`` (no revision argument) compares the working tree to
73
- the index, so a file that was just ``git add``-ed reads as unchanged even
74
- when it holds content HEAD never had. Naming ``HEAD`` folds the index and
75
- the working tree into one comparison against the last commit, catching a
76
- staged-but-uncommitted new file and a staged edit alike.
77
- """
78
- try:
79
- completed = subprocess.run(
80
- _git_diff_quiet_against_head_command(path.name),
81
- cwd=str(path.parent),
82
- capture_output=True,
83
- text=True,
84
- check=False,
85
- timeout=GIT_DIFF_TIMEOUT_SECONDS,
86
- creationflags=hidden_window_creation_flags(),
87
- )
88
- except (OSError, subprocess.SubprocessError):
89
- return True
90
- return completed.returncode != 0
91
-
92
-
93
- def is_absent_but_tracked(path: Path) -> bool:
94
- """Return whether a write target is absent on disk yet tracked in git.
95
-
96
- ::
97
-
98
- path missing on disk + git ls-files finds it -> True (restore)
99
- path present, or git does not track it -> False
100
-
101
- Args:
102
- path: The write target.
103
-
104
- Returns:
105
- True only when the path does not exist but git tracks it, marking the
106
- write a restore of committed code rather than new production code.
107
- """
108
- if path.exists():
109
- return False
110
- return _git_tracks_path(path)
111
-
112
-
113
- def has_uncommitted_change_from_head(path: Path) -> bool:
114
- """Return whether *path* carries content its git HEAD does not have.
115
-
116
- ::
117
-
118
- untracked file -> True (no HEAD entry to match)
119
- tracked, working tree != HEAD -> True (a real edit this session)
120
- tracked, working tree == HEAD -> False (clean; a touch cannot pass)
121
- git unavailable or errors -> True (cannot prove it is clean)
122
-
123
- Args:
124
- path: The candidate test file to check.
125
-
126
- Returns:
127
- True unless git can positively confirm the file is tracked and its
128
- working-tree content matches HEAD exactly.
129
- """
130
- if not _git_tracks_path(path):
131
- return True
132
- return _working_tree_differs_from_head(path)
@@ -1,119 +0,0 @@
1
- """Path and payload classification for the TDD-enforcer gate.
2
-
3
- Decides which write targets the gate skips silently (docs, tests, files inside a
4
- ``.claude`` tree) and pulls the written text out of a Write, Edit, or MultiEdit
5
- payload so the gate can inspect it.
6
- """
7
-
8
- from tdd_enforcer_parts.config.tdd_enforcer_constants import (
9
- ALL_DIRECTORY_SKIP_COMPONENTS,
10
- ALL_DOTCLAUDE_PATH_SEGMENTS,
11
- ALL_PRODUCTION_EXTENSIONS,
12
- ALL_SKIP_EXTENSIONS,
13
- ALL_SKIP_NAME_PATTERNS,
14
- NEWLINE_JOIN_SEPARATOR,
15
- )
16
-
17
-
18
- def production_extensions() -> frozenset[str]:
19
- """Return the source-file extensions the gate treats as production code."""
20
- return ALL_PRODUCTION_EXTENSIONS
21
-
22
-
23
- def skip_extensions() -> frozenset[str]:
24
- """Return the config and documentation extensions the gate skips."""
25
- return ALL_SKIP_EXTENSIONS
26
-
27
-
28
- def _skip_patterns() -> frozenset[str]:
29
- return ALL_SKIP_NAME_PATTERNS
30
-
31
-
32
- def _dotclaude_path_segments() -> frozenset[str]:
33
- return ALL_DOTCLAUDE_PATH_SEGMENTS
34
-
35
-
36
- def _directory_skip_components() -> frozenset[str]:
37
- return ALL_DIRECTORY_SKIP_COMPONENTS
38
-
39
-
40
- def _is_inside_dotclaude_segment(file_path_string: str) -> bool:
41
- """Return whether any path segment is exactly a ``.claude`` directory.
42
-
43
- Args:
44
- file_path_string: The write target path, in either separator style.
45
-
46
- Returns:
47
- True when a ``.claude`` directory sits on the path, so the gate leaves
48
- agent-config edits alone.
49
- """
50
- normalized_path = file_path_string.replace("\\", "/")
51
- dotclaude_segments = _dotclaude_path_segments()
52
- for each_segment in normalized_path.split("/"):
53
- if each_segment and each_segment in dotclaude_segments:
54
- return True
55
- return False
56
-
57
-
58
- def _path_has_skip_directory_component(path_with_forward_slashes: str) -> bool:
59
- all_components = [each_part for each_part in path_with_forward_slashes.split("/") if each_part]
60
- directory_components = all_components[:-1]
61
- skip_directory_components = _directory_skip_components()
62
- return any(each in skip_directory_components for each in directory_components)
63
-
64
-
65
- def _skip_pattern_matches(pattern: str, name_lower: str, path_with_forward_slashes: str) -> bool:
66
- if pattern.endswith("/"):
67
- return pattern in path_with_forward_slashes
68
- return pattern in name_lower
69
-
70
-
71
- def _name_or_path_matches_skip_pattern(name_lower: str, path_with_forward_slashes: str) -> bool:
72
- for each_pattern in _skip_patterns():
73
- if _skip_pattern_matches(each_pattern, name_lower, path_with_forward_slashes):
74
- return True
75
- return False
76
-
77
-
78
- def _matches_any_skip_pattern(name_lower: str, path_with_forward_slashes: str) -> bool:
79
- """Return whether a path names a test, fixture, mock, or stub file.
80
-
81
- Args:
82
- name_lower: The lower-cased file name.
83
- path_with_forward_slashes: The lower-cased path with forward slashes.
84
-
85
- Returns:
86
- True when a skip directory component or a skip name pattern matches.
87
- """
88
- if _path_has_skip_directory_component(path_with_forward_slashes):
89
- return True
90
- return _name_or_path_matches_skip_pattern(name_lower, path_with_forward_slashes)
91
-
92
-
93
- def _multiedit_new_strings(all_edits: list) -> list[str]:
94
- all_new_strings: list[str] = []
95
- for each_edit in all_edits:
96
- if isinstance(each_edit, dict):
97
- all_new_strings.append(each_edit.get("new_string", "") or "")
98
- return all_new_strings
99
-
100
-
101
- def _extract_written_content(tool_name: str, tool_input: dict) -> str:
102
- """Return the text a Write, Edit, or MultiEdit payload would introduce.
103
-
104
- Args:
105
- tool_name: The intercepted tool name.
106
- tool_input: The intercepted tool's input payload.
107
-
108
- Returns:
109
- The written content for Write, the replacement for Edit, the joined
110
- replacements for MultiEdit, or an empty string for any other tool.
111
- """
112
- if tool_name == "Write":
113
- return tool_input.get("content", "") or ""
114
- if tool_name == "Edit":
115
- return tool_input.get("new_string", "") or ""
116
- if tool_name == "MultiEdit":
117
- all_edits = tool_input.get("edits", []) or []
118
- return NEWLINE_JOIN_SEPARATOR.join(_multiedit_new_strings(all_edits))
119
- return ""
@@ -1,31 +0,0 @@
1
- """Behavioral tests for the candidate_paths parts module."""
2
-
3
- from pathlib import Path
4
-
5
- from tdd_enforcer_parts import candidate_paths
6
-
7
-
8
- def test_candidate_paths_offers_flat_stem_candidates_first(tmp_path: Path) -> None:
9
- (tmp_path / ".git").mkdir()
10
- all_candidates = candidate_paths.candidate_test_paths_for(tmp_path / "orders.py")
11
- assert all_candidates[0] == tmp_path / "test_orders.py"
12
- assert all_candidates[1] == tmp_path / "orders_test.py"
13
-
14
-
15
- def test_candidate_paths_returns_empty_for_unknown_extension(tmp_path: Path) -> None:
16
- assert candidate_paths.candidate_test_paths_for(tmp_path / "data.rs") == []
17
-
18
-
19
- def test_candidate_paths_offers_javascript_test_siblings(tmp_path: Path) -> None:
20
- all_candidates = candidate_paths.candidate_test_paths_for(tmp_path / "Button.tsx")
21
- assert tmp_path / "Button.test.tsx" in all_candidates
22
-
23
-
24
- def test_ancestor_tests_directories_finds_sibling_tests(tmp_path: Path) -> None:
25
- (tmp_path / ".git").mkdir()
26
- package = tmp_path / "pkg"
27
- package.mkdir()
28
- (package / "tests").mkdir()
29
- all_pairs = candidate_paths._ancestor_tests_directories(package)
30
- all_tests_directories = [each_tests_directory for _, each_tests_directory in all_pairs]
31
- assert package / "tests" in all_tests_directories