claude-dev-env 7.4.1 → 7.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/everything-search/scripts/everything_search.py +11 -0
- package/.agents/skills/everything-search/scripts/test_everything_search.py +1 -0
- package/.agents/skills/fresh-branch/SKILL.md +1 -0
- package/.agents/skills/fresh-branch/scripts/fresh_branch_git_commands.py +3 -0
- package/.agents/skills/fresh-branch/scripts/subprocess_window_access.py +43 -0
- package/.agents/skills/syncing-submodules/SKILL.md +1 -0
- package/.agents/skills/syncing-submodules/scripts/submodule_sync.py +4 -0
- package/.agents/skills/syncing-submodules/scripts/subprocess_window_access.py +43 -0
- package/.agents/skills/test-runner/SKILL.md +1 -0
- package/.agents/skills/test-runner/scripts/preflight_checks.py +5 -0
- package/.agents/skills/test-runner/scripts/run_tests.py +11 -0
- package/.agents/skills/test-runner/scripts/subprocess_window_access.py +43 -0
- package/.agents/skills/test-runner/scripts/test_run_tests.py +9 -7
- package/_shared/pr-loop/scripts/check_bugbot_ci.py +3 -0
- package/_shared/pr-loop/scripts/check_convergence_gates.py +3 -0
- package/_shared/pr-loop/scripts/check_pending_reviews.py +3 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/added_line_maps.py +3 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/baseline_import_isolation.py +3 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/git_blob_readers.py +5 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/git_file_sets.py +4 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_regression.py +3 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_running.py +3 -0
- package/_shared/pr-loop/scripts/codex_usage_probe.py +3 -0
- package/_shared/pr-loop/scripts/copilot_quota.py +3 -0
- package/_shared/pr-loop/scripts/fetch_copilot_reviews.py +3 -0
- package/_shared/pr-loop/scripts/fix_hookspath.py +7 -0
- package/_shared/pr-loop/scripts/post_fix_reply.py +4 -0
- package/_shared/pr-loop/scripts/preflight.py +7 -0
- package/_shared/pr-loop/scripts/preflight_self_heal.py +5 -0
- package/_shared/pr-loop/scripts/run_codex_review.py +3 -0
- package/_shared/pr-loop/scripts/subprocess_window_access.py +41 -0
- package/_shared/pr-loop/scripts/terminology_sweep.py +5 -0
- package/_shared/process-tree/scripts/process_tree_kill.py +3 -0
- package/_shared/process-tree/scripts/subprocess_window_access.py +41 -0
- package/bin/install-constants.mjs +4 -2
- package/bin/install.cursor-rules.test.mjs +57 -15
- package/bin/install.mjs +10 -16
- package/bin/install.policy-lint.test.mjs +1 -2
- package/bin/install.test.mjs +0 -1
- package/bin/pstack-model-preferences.claude.json +23 -0
- package/hooks/advisory/msys_path_conversion_advisor.py +200 -0
- package/hooks/advisory/pr_done_reminder.py +14 -13
- package/hooks/advisory/refactor_guard.py +8 -0
- package/hooks/advisory/test_msys_path_conversion_advisor.py +223 -0
- package/hooks/advisory/test_pr_done_reminder.py +22 -0
- package/hooks/advisory/test_refactor_guard.py +40 -0
- package/hooks/blocking/bash_post_call_dispatcher.py +6 -11
- package/hooks/blocking/block_main_commit.py +4 -0
- package/hooks/blocking/code_rules_comments.py +39 -5
- package/hooks/blocking/msys_rev_path_rewriter.py +192 -0
- package/hooks/blocking/pii_prevention_blocker_parts/repository_exemption.py +3 -0
- package/hooks/blocking/precommit_code_rules_gate.py +3 -0
- package/hooks/blocking/tdd_enforcer_parts/content_analysis.py +19 -3
- package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +4 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_content_analysis.py +25 -0
- package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +16 -3
- package/hooks/blocking/test_bash_post_call_dispatcher.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_comment_string_awareness.py +27 -0
- package/hooks/blocking/test_code_rules_enforcer_exempt_marker_chained.py +10 -0
- package/hooks/blocking/test_msys_rev_path_rewriter.py +141 -0
- package/hooks/git-hooks/git_hooks_constants/verification_notice_constants.py +0 -2
- package/hooks/git-hooks/pull_request_handoff.py +3 -0
- package/hooks/git-hooks/subprocess_window_access.py +41 -0
- package/hooks/git-hooks/test_verification_start.py +4 -0
- package/hooks/git-hooks/verification_notice_context.py +3 -0
- package/hooks/git-hooks/verification_start.py +7 -8
- package/hooks/hooks.json +10 -0
- package/hooks/hooks_constants/bash_post_call_dispatcher_constants.py +9 -0
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +6 -1
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +2 -0
- package/hooks/hooks_constants/msys_path_conversion_advisor_constants.py +48 -0
- package/hooks/hooks_constants/msys_rev_path_rewriter_constants.py +37 -0
- package/hooks/hooks_constants/post_tool_use_context.py +34 -0
- package/hooks/hooks_constants/pr_done_reminder_constants.py +0 -2
- package/hooks/hooks_constants/pre_tool_use_allow_output.py +48 -0
- package/hooks/hooks_constants/subprocess_window.py +141 -0
- package/hooks/hooks_constants/subprocess_window_constants.py +9 -0
- package/hooks/hooks_constants/test_bash_post_call_dispatcher_constants.py +9 -2
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +9 -2
- package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +6 -0
- package/hooks/hooks_constants/test_failure_recorder_constants.py +1 -2
- package/hooks/hooks_constants/test_post_tool_use_context.py +22 -0
- package/hooks/hooks_constants/test_pre_tool_use_allow_output.py +27 -0
- package/hooks/hooks_constants/test_subprocess_window.py +128 -0
- package/hooks/lifecycle/enter_worktree_origin_prefetch.py +3 -0
- package/hooks/observability/test_failure_recorder.py +3 -1
- package/hooks/validation/mypy_validator.py +4 -0
- package/hooks/validators/README.md +1 -0
- package/hooks/validators/git_checks.py +4 -0
- package/hooks/validators/health_check.py +3 -0
- package/hooks/validators/hook_timing_harness.py +3 -0
- package/hooks/validators/mypy_integration.py +4 -0
- package/hooks/validators/ruff_integration.py +6 -0
- package/hooks/validators/run_all_validators.py +7 -0
- package/hooks/validators/subprocess_window_access.py +41 -0
- package/hooks/workflow/auto_formatter.py +8 -0
- package/hooks/workflow/test_auto_formatter.py +39 -0
- package/package.json +1 -1
- package/rules/ask-user-question-required.md +3 -3
- package/rules/code-standards.md +3 -2
- package/rules/confirm-implementation-forks.md +2 -0
- package/rules/destructive-commands.md +19 -19
- package/rules/doc-inventory-integrity.md +4 -4
- package/rules/docstring-prose-matches-implementation.md +2 -2
- package/rules/failure-blast-radius.md +4 -2
- package/rules/filesystem-search.md +1 -1
- package/rules/git-workflow.md +34 -0
- package/rules/long-horizon-autonomy.md +2 -0
- package/rules/nas-ssh-invocation.md +3 -3
- package/rules/no-cross-skill-duplicate-helpers.md +6 -6
- package/rules/orphan-css-class.md +5 -5
- package/rules/paired-test-coverage.md +6 -6
- package/rules/plain-illustrative-docstrings.md +3 -3
- package/rules/pstack-models.md +4 -3
- package/rules/re-stage-before-commit.md +7 -7
- package/rules/retired-hook-prose.md +16 -0
- package/rules/shell-invocation.md +17 -1
- package/rules/testing.md +3 -5
- package/rules/verify-runtime-state.md +1 -1
- package/rules/windows-filesystem-safe.md +2 -2
- package/rules/workflow-substitution-slots.md +1 -1
- package/scripts/automatic_advisory/checkout.py +8 -1
- package/scripts/automatic_advisory/cli.py +9 -3
- package/scripts/automatic_advisory/config/constants.py +0 -2
- package/scripts/automatic_advisory/execution.py +7 -1
- package/scripts/automatic_advisory/git.py +9 -1
- package/scripts/automatic_advisory/process_host.py +3 -0
- package/scripts/claude_chain_runner.py +3 -0
- package/scripts/durable_post_lint_config/config/constants.py +0 -3
- package/scripts/durable_post_lint_config/config/test_constants.py +5 -0
- package/scripts/gh_artifact_upload.py +3 -0
- package/scripts/grok_headless_runner.py +3 -0
- package/scripts/grok_patch_artifacts.py +4 -0
- package/scripts/grok_worker_preflight.py +3 -0
- package/scripts/invoke_code_review.py +3 -0
- package/scripts/local_verification/command_runner.py +3 -0
- package/scripts/local_verification/git_state.py +3 -0
- package/scripts/policy_lint/adapter_configuration.py +24 -0
- package/scripts/policy_lint/adapter_detectors.py +48 -2
- package/scripts/policy_lint/adapter_pairing.py +150 -2
- package/scripts/policy_lint/adapter_retired_hook_prose.py +201 -0
- package/scripts/policy_lint/adapters.py +82 -5
- package/scripts/policy_lint/config/approved_test_pairs.py +25 -9
- package/scripts/policy_lint/config/constants.py +77 -0
- package/scripts/policy_lint/registry.py +6 -1
- package/scripts/policy_lint/selection.py +37 -18
- package/scripts/policy_lint/selection_git.py +17 -2
- package/scripts/repository_checks/config/constants.py +5 -0
- package/scripts/repository_checks/config/policy_document.py +19 -0
- package/scripts/repository_checks/email_exemptions.py +14 -71
- package/scripts/repository_checks/path_exemptions.py +35 -0
- package/scripts/repository_checks/policy_document.py +120 -0
- package/scripts/repository_checks/tracked_secrets.py +27 -4
- package/scripts/setup_project_paths.py +3 -0
- package/scripts/spawn_grok_batch.py +3 -0
- package/scripts/subprocess_window_access.py +41 -0
- package/scripts/test_dispatcher_profile_import.py +4 -0
- package/scripts/test_durable_post_lint.py +42 -9
- package/scripts/tests/test_adapter_configuration.py +8 -0
- package/scripts/tests/test_automatic_advisory_window_flags.py +17 -11
- package/scripts/tests/test_email_exemptions.py +37 -0
- package/scripts/tests/test_path_exemptions.py +164 -0
- package/scripts/tests/test_policy_document.py +104 -0
- package/scripts/tests/test_policy_lint_rules_function_length_scope.py +101 -0
- package/scripts/tests/test_policy_lint_rules_pairing.py +375 -0
- package/scripts/tests/test_policy_lint_rules_registry.py +116 -0
- package/scripts/tests/test_policy_lint_rules_retired_hook_prose.py +197 -0
- package/scripts/tests/test_policy_lint_selection.py +141 -0
- package/scripts/tests/test_subprocess_window_access.py +118 -0
- package/scripts/tests/test_tracked_secrets.py +79 -0
- package/scripts/validate_instruction_pairs.py +3 -0
- package/scripts/verify_installable_package.py +5 -0
- package/hooks/hooks_constants/convergence_branch_constants.py +0 -8
- package/hooks/hooks_constants/destructive_command_environment_constants.py +0 -18
- package/hooks/hooks_constants/test_text_stripping.py +0 -39
- package/hooks/hooks_constants/text_stripping.py +0 -36
- package/scripts/automatic_advisory/window_flags.py +0 -41
- package/scripts/repository_checks/config/email_exemptions.py +0 -10
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""The shared policy-document reader and entry validator."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
from repository_checks.policy_document import (
|
|
11
|
+
entry_path_and_digest,
|
|
12
|
+
read_policy_entries,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
RELATIVE_PATH = "evidence/manifest.json"
|
|
16
|
+
DIGEST = hashlib.sha256(b"frozen bytes").hexdigest()
|
|
17
|
+
CONFIG_RELATIVE_PATH = "config/repository-policy.json"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _write_config(root: Path, document: object) -> None:
|
|
21
|
+
config_path = root / CONFIG_RELATIVE_PATH
|
|
22
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
config_path.write_text(json.dumps(document), encoding="utf-8")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _entry(**changes: object) -> dict[str, object]:
|
|
27
|
+
return {
|
|
28
|
+
"path": RELATIVE_PATH,
|
|
29
|
+
"sha256": DIGEST,
|
|
30
|
+
"reason": "Frozen evidence file pinned by its consumers",
|
|
31
|
+
**changes,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_reader_returns_the_recorded_entries_without_validating_them(
|
|
36
|
+
tmp_path: Path,
|
|
37
|
+
) -> None:
|
|
38
|
+
"""The reader validates the document, and the caller validates each entry."""
|
|
39
|
+
_write_config(tmp_path, {"version": 1, "path_exemptions": [{"anything": 1}]})
|
|
40
|
+
assert read_policy_entries(tmp_path, "path_exemptions") == [{"anything": 1}]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_reader_returns_no_entries_for_a_field_the_document_omits(
|
|
44
|
+
tmp_path: Path,
|
|
45
|
+
) -> None:
|
|
46
|
+
_write_config(tmp_path, {"version": 1, "path_exemptions": [_entry()]})
|
|
47
|
+
assert read_policy_entries(tmp_path, "email_exemptions") == []
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_reader_rejects_a_configuration_symlinked_outside_the_repository(
|
|
51
|
+
tmp_path: Path,
|
|
52
|
+
) -> None:
|
|
53
|
+
outside_path = tmp_path / "outside-policy.json"
|
|
54
|
+
outside_path.write_text(json.dumps({"version": 1}), encoding="utf-8")
|
|
55
|
+
repository_root = tmp_path / "repo"
|
|
56
|
+
(repository_root / "config").mkdir(parents=True)
|
|
57
|
+
(repository_root / CONFIG_RELATIVE_PATH).symlink_to(outside_path)
|
|
58
|
+
with pytest.raises(ValueError) as rejection:
|
|
59
|
+
read_policy_entries(repository_root, "path_exemptions")
|
|
60
|
+
assert (
|
|
61
|
+
str(rejection.value)
|
|
62
|
+
== "Repository policy configuration must remain inside the repository"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_entry_validator_returns_the_path_and_digest_without_the_reason() -> None:
|
|
67
|
+
assert entry_path_and_digest(_entry(), "Path exemption") == (
|
|
68
|
+
RELATIVE_PATH,
|
|
69
|
+
DIGEST,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@pytest.mark.parametrize(
|
|
74
|
+
("entry", "expected_message"),
|
|
75
|
+
[
|
|
76
|
+
({}, "Path exemptions require path, sha256, and reason"),
|
|
77
|
+
(_entry(path=1), "Path exemption path must name one repository file"),
|
|
78
|
+
(
|
|
79
|
+
_entry(path="../manifest.json"),
|
|
80
|
+
"Path exemption path must be a literal relative file path",
|
|
81
|
+
),
|
|
82
|
+
(
|
|
83
|
+
_entry(sha256="F" * 64),
|
|
84
|
+
"Path exemption sha256 must contain 64 lowercase hex digits",
|
|
85
|
+
),
|
|
86
|
+
(
|
|
87
|
+
_entry(reason=" "),
|
|
88
|
+
"Path exemption reason must explain why the exception is owned",
|
|
89
|
+
),
|
|
90
|
+
],
|
|
91
|
+
)
|
|
92
|
+
def test_entry_validator_names_the_subject_in_every_rejection(
|
|
93
|
+
entry: object, expected_message: str
|
|
94
|
+
) -> None:
|
|
95
|
+
"""One validator serves both families, so each message names its subject."""
|
|
96
|
+
with pytest.raises(ValueError) as rejection:
|
|
97
|
+
entry_path_and_digest(entry, "Path exemption")
|
|
98
|
+
assert str(rejection.value) == expected_message
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_entry_validator_uses_the_subject_it_is_given() -> None:
|
|
102
|
+
with pytest.raises(ValueError) as rejection:
|
|
103
|
+
entry_path_and_digest({}, "Email exemption")
|
|
104
|
+
assert str(rejection.value) == "Email exemptions require path, sha256, and reason"
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Function-length diagnostics report only functions the change is responsible for.
|
|
2
|
+
|
|
3
|
+
The code-rule adapter runs the enforcer with ``defer_scope_to_caller`` set, so
|
|
4
|
+
the enforcer hands back every over-long function in the document and the caller
|
|
5
|
+
owns the scope decision. The engine's own changed-line filter keys on a
|
|
6
|
+
diagnostic's start line, and a function-length message carries no ``Line N:``
|
|
7
|
+
prefix, so before this scope pass a pre-existing long function blocked any
|
|
8
|
+
change that touched its file.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from pathlib import Path, PurePosixPath
|
|
14
|
+
|
|
15
|
+
from policy_lint import adapters
|
|
16
|
+
from policy_lint.model import ContentOrigin, Diagnostic, Document
|
|
17
|
+
from policy_lint.selection import _changed_lines
|
|
18
|
+
|
|
19
|
+
FUNCTION_LENGTH_MESSAGE_FRAGMENT = "exceeds blocking threshold"
|
|
20
|
+
PRODUCTION_RELATIVE_PATH = "packages/claude-dev-env/scripts/sample_long_module.py"
|
|
21
|
+
OVER_THRESHOLD_BODY_LINE_COUNT = 70
|
|
22
|
+
UNDER_THRESHOLD_BODY_LINE_COUNT = 40
|
|
23
|
+
REPOSITORY_ROOT = Path(__file__).resolve().parents[4]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _counting_function_source(function_name: str, body_line_count: int) -> str:
|
|
27
|
+
all_body_lines = [
|
|
28
|
+
f" running_total = running_total + {each_index}"
|
|
29
|
+
for each_index in range(body_line_count)
|
|
30
|
+
]
|
|
31
|
+
signature_lines = [f"def {function_name}(running_total: int) -> int:"]
|
|
32
|
+
return (
|
|
33
|
+
"\n".join(signature_lines + all_body_lines + [" return running_total"])
|
|
34
|
+
+ "\n"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _messages_for_document(document: Document) -> tuple[str, ...]:
|
|
39
|
+
all_diagnostics: tuple[Diagnostic, ...] = adapters.code_rule_diagnostics(
|
|
40
|
+
document, REPOSITORY_ROOT
|
|
41
|
+
)
|
|
42
|
+
return tuple(
|
|
43
|
+
each_diagnostic.message
|
|
44
|
+
for each_diagnostic in all_diagnostics
|
|
45
|
+
if FUNCTION_LENGTH_MESSAGE_FRAGMENT in each_diagnostic.message
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _function_length_messages(
|
|
50
|
+
prior_text: str | None, current_text: str
|
|
51
|
+
) -> tuple[str, ...]:
|
|
52
|
+
return _messages_for_document(
|
|
53
|
+
Document(
|
|
54
|
+
PurePosixPath(PRODUCTION_RELATIVE_PATH),
|
|
55
|
+
current_text,
|
|
56
|
+
prior_text,
|
|
57
|
+
_changed_lines(prior_text, current_text),
|
|
58
|
+
ContentOrigin.REVISION_DIFF,
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_should_leave_an_untouched_long_function_alone() -> None:
|
|
64
|
+
prior_text = _counting_function_source("main", OVER_THRESHOLD_BODY_LINE_COUNT)
|
|
65
|
+
current_text = f"import os\nimport sys\n{prior_text}"
|
|
66
|
+
assert _function_length_messages(prior_text, current_text) == ()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_should_report_a_function_the_change_grew_past_the_threshold() -> None:
|
|
70
|
+
prior_text = _counting_function_source("main", UNDER_THRESHOLD_BODY_LINE_COUNT)
|
|
71
|
+
current_text = _counting_function_source("main", OVER_THRESHOLD_BODY_LINE_COUNT)
|
|
72
|
+
assert _function_length_messages(prior_text, current_text) != ()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_should_report_an_over_long_function_the_change_adds() -> None:
|
|
76
|
+
prior_text = _counting_function_source("existing", UNDER_THRESHOLD_BODY_LINE_COUNT)
|
|
77
|
+
added_text = _counting_function_source("added", OVER_THRESHOLD_BODY_LINE_COUNT)
|
|
78
|
+
assert _function_length_messages(prior_text, f"{prior_text}\n{added_text}") != ()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_should_report_every_over_long_function_in_a_wholly_new_document() -> None:
|
|
82
|
+
current_text = _counting_function_source("main", OVER_THRESHOLD_BODY_LINE_COUNT)
|
|
83
|
+
assert _function_length_messages(None, current_text) != ()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_should_leave_a_long_function_alone_when_the_change_only_deletes() -> None:
|
|
87
|
+
current_text = _counting_function_source("main", OVER_THRESHOLD_BODY_LINE_COUNT)
|
|
88
|
+
prior_text = f"REMOVED_CONSTANT = 1\n{current_text}"
|
|
89
|
+
assert _function_length_messages(prior_text, current_text) == ()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_should_report_a_long_function_when_the_selection_names_no_changed_lines() -> None:
|
|
93
|
+
current_text = _counting_function_source("main", OVER_THRESHOLD_BODY_LINE_COUNT)
|
|
94
|
+
document = Document(
|
|
95
|
+
PurePosixPath(PRODUCTION_RELATIVE_PATH),
|
|
96
|
+
current_text,
|
|
97
|
+
None,
|
|
98
|
+
None,
|
|
99
|
+
ContentOrigin.WORKTREE,
|
|
100
|
+
)
|
|
101
|
+
assert _messages_for_document(document) != ()
|
|
@@ -70,6 +70,37 @@ def test_approved_pairs_name_files_that_exist() -> None:
|
|
|
70
70
|
assert (repository_root / each_test_path).is_file(), each_test_path
|
|
71
71
|
|
|
72
72
|
|
|
73
|
+
def test_every_approved_pair_actually_waives_its_production_file(tmp_path: Path) -> None:
|
|
74
|
+
"""Each mapping entry must silence the rule when its production file changes.
|
|
75
|
+
|
|
76
|
+
File existence alone leaves an entry inert when its key carries a suffix the
|
|
77
|
+
rule skips, or when its value is not a name the rule reads as a test.
|
|
78
|
+
"""
|
|
79
|
+
for (
|
|
80
|
+
production_path,
|
|
81
|
+
all_test_paths,
|
|
82
|
+
) in APPROVED_TEST_PATHS_BY_PRODUCTION_PATH.items():
|
|
83
|
+
production_document = Document(
|
|
84
|
+
production_path,
|
|
85
|
+
_BODY_AFTER,
|
|
86
|
+
_BODY_BEFORE,
|
|
87
|
+
frozenset({1}),
|
|
88
|
+
ContentOrigin.REVISION_DIFF,
|
|
89
|
+
)
|
|
90
|
+
for each_test_path in all_test_paths:
|
|
91
|
+
test_document = Document(
|
|
92
|
+
each_test_path,
|
|
93
|
+
_BODY_AFTER,
|
|
94
|
+
_BODY_BEFORE,
|
|
95
|
+
frozenset({1}),
|
|
96
|
+
ContentOrigin.REVISION_DIFF,
|
|
97
|
+
)
|
|
98
|
+
document_set = DocumentSet(
|
|
99
|
+
(production_document, test_document), SelectionKind.BASE, tmp_path
|
|
100
|
+
)
|
|
101
|
+
assert adapters.test_pairing_diagnostics(document_set) == (), production_path
|
|
102
|
+
|
|
103
|
+
|
|
73
104
|
def test_pairing_ignores_python_docstring_only_changes(tmp_path: Path) -> None:
|
|
74
105
|
all_diagnostics = _pairing_diagnostics(
|
|
75
106
|
tmp_path,
|
|
@@ -161,3 +192,347 @@ def test_pairing_rejects_an_unrelated_module_beside_a_changed_approved_suite(
|
|
|
161
192
|
_body_change_at(_APPROVED_TEST_PATH),
|
|
162
193
|
)
|
|
163
194
|
assert all_paths == (_UNRELATED_PRODUCTION_PATH,)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
_MECHANICAL_BEFORE = '''"""Before."""
|
|
198
|
+
|
|
199
|
+
import subprocess
|
|
200
|
+
|
|
201
|
+
TIMEOUT_SECONDS = 5
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def work(command: list[str]) -> int:
|
|
205
|
+
"""Run one command."""
|
|
206
|
+
if TIMEOUT_SECONDS > 0:
|
|
207
|
+
completed = subprocess.run(command, timeout=TIMEOUT_SECONDS, check=False)
|
|
208
|
+
return completed.returncode
|
|
209
|
+
return 1
|
|
210
|
+
'''
|
|
211
|
+
_KEYWORD_ADDED = '''"""Before."""
|
|
212
|
+
|
|
213
|
+
import subprocess
|
|
214
|
+
|
|
215
|
+
TIMEOUT_SECONDS = 5
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def work(command: list[str]) -> int:
|
|
219
|
+
"""Run one command."""
|
|
220
|
+
if TIMEOUT_SECONDS > 0:
|
|
221
|
+
completed = subprocess.run(
|
|
222
|
+
command, timeout=TIMEOUT_SECONDS, check=False, creationflags=0
|
|
223
|
+
)
|
|
224
|
+
return completed.returncode
|
|
225
|
+
return 1
|
|
226
|
+
'''
|
|
227
|
+
_IMPORT_AND_KEYWORD_ADDED = '''"""Before."""
|
|
228
|
+
|
|
229
|
+
import os
|
|
230
|
+
import subprocess
|
|
231
|
+
|
|
232
|
+
TIMEOUT_SECONDS = 5
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def work(command: list[str]) -> int:
|
|
236
|
+
"""Run one command."""
|
|
237
|
+
if TIMEOUT_SECONDS > 0:
|
|
238
|
+
completed = subprocess.run(
|
|
239
|
+
command, timeout=TIMEOUT_SECONDS, check=False, creationflags=os.O_RDONLY
|
|
240
|
+
)
|
|
241
|
+
return completed.returncode
|
|
242
|
+
return 1
|
|
243
|
+
'''
|
|
244
|
+
_DOCSTRING_AND_KEYWORD_CHANGED = '''"""After."""
|
|
245
|
+
|
|
246
|
+
import subprocess
|
|
247
|
+
|
|
248
|
+
TIMEOUT_SECONDS = 5
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def work(command: list[str]) -> int:
|
|
252
|
+
"""Run one command and report its exit status."""
|
|
253
|
+
if TIMEOUT_SECONDS > 0:
|
|
254
|
+
completed = subprocess.run(
|
|
255
|
+
command, timeout=TIMEOUT_SECONDS, check=False, creationflags=0
|
|
256
|
+
)
|
|
257
|
+
return completed.returncode
|
|
258
|
+
return 1
|
|
259
|
+
'''
|
|
260
|
+
_KEYWORD_ADDED_AND_LITERAL_CHANGED = '''"""Before."""
|
|
261
|
+
|
|
262
|
+
import subprocess
|
|
263
|
+
|
|
264
|
+
TIMEOUT_SECONDS = 6
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def work(command: list[str]) -> int:
|
|
268
|
+
"""Run one command."""
|
|
269
|
+
if TIMEOUT_SECONDS > 0:
|
|
270
|
+
completed = subprocess.run(
|
|
271
|
+
command, timeout=TIMEOUT_SECONDS, check=False, creationflags=0
|
|
272
|
+
)
|
|
273
|
+
return completed.returncode
|
|
274
|
+
return 1
|
|
275
|
+
'''
|
|
276
|
+
_KEYWORD_VALUE_CHANGED = '''"""Before."""
|
|
277
|
+
|
|
278
|
+
import subprocess
|
|
279
|
+
|
|
280
|
+
TIMEOUT_SECONDS = 5
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def work(command: list[str]) -> int:
|
|
284
|
+
"""Run one command."""
|
|
285
|
+
if TIMEOUT_SECONDS > 0:
|
|
286
|
+
completed = subprocess.run(command, timeout=TIMEOUT_SECONDS, check=True)
|
|
287
|
+
return completed.returncode
|
|
288
|
+
return 1
|
|
289
|
+
'''
|
|
290
|
+
_KEYWORD_VALUE_TYPE_CHANGED = '''"""Before."""
|
|
291
|
+
|
|
292
|
+
import subprocess
|
|
293
|
+
|
|
294
|
+
TIMEOUT_SECONDS = 5
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def work(command: list[str]) -> int:
|
|
298
|
+
"""Run one command."""
|
|
299
|
+
if TIMEOUT_SECONDS > 0:
|
|
300
|
+
completed = subprocess.run(command, timeout=TIMEOUT_SECONDS, check=0)
|
|
301
|
+
return completed.returncode
|
|
302
|
+
return 1
|
|
303
|
+
'''
|
|
304
|
+
_KEYWORD_REMOVED = '''"""Before."""
|
|
305
|
+
|
|
306
|
+
import subprocess
|
|
307
|
+
|
|
308
|
+
TIMEOUT_SECONDS = 5
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def work(command: list[str]) -> int:
|
|
312
|
+
"""Run one command."""
|
|
313
|
+
if TIMEOUT_SECONDS > 0:
|
|
314
|
+
completed = subprocess.run(command, timeout=TIMEOUT_SECONDS)
|
|
315
|
+
return completed.returncode
|
|
316
|
+
return 1
|
|
317
|
+
'''
|
|
318
|
+
_POSITIONAL_CHANGED = '''"""Before."""
|
|
319
|
+
|
|
320
|
+
import subprocess
|
|
321
|
+
|
|
322
|
+
TIMEOUT_SECONDS = 5
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def work(command: list[str]) -> int:
|
|
326
|
+
"""Run one command."""
|
|
327
|
+
if TIMEOUT_SECONDS > 0:
|
|
328
|
+
completed = subprocess.run(
|
|
329
|
+
command[:1], timeout=TIMEOUT_SECONDS, check=False, creationflags=0
|
|
330
|
+
)
|
|
331
|
+
return completed.returncode
|
|
332
|
+
return 1
|
|
333
|
+
'''
|
|
334
|
+
_STATEMENT_ADDED_IN_BODY = '''"""Before."""
|
|
335
|
+
|
|
336
|
+
import subprocess
|
|
337
|
+
|
|
338
|
+
TIMEOUT_SECONDS = 5
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def work(command: list[str]) -> int:
|
|
342
|
+
"""Run one command."""
|
|
343
|
+
if TIMEOUT_SECONDS > 0:
|
|
344
|
+
completed = subprocess.run(
|
|
345
|
+
command, timeout=TIMEOUT_SECONDS, check=False, creationflags=0
|
|
346
|
+
)
|
|
347
|
+
completed.check_returncode()
|
|
348
|
+
return completed.returncode
|
|
349
|
+
return 1
|
|
350
|
+
'''
|
|
351
|
+
_CONDITION_CHANGED = '''"""Before."""
|
|
352
|
+
|
|
353
|
+
import subprocess
|
|
354
|
+
|
|
355
|
+
TIMEOUT_SECONDS = 5
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def work(command: list[str]) -> int:
|
|
359
|
+
"""Run one command."""
|
|
360
|
+
if TIMEOUT_SECONDS > 1:
|
|
361
|
+
completed = subprocess.run(
|
|
362
|
+
command, timeout=TIMEOUT_SECONDS, check=False, creationflags=0
|
|
363
|
+
)
|
|
364
|
+
return completed.returncode
|
|
365
|
+
return 1
|
|
366
|
+
'''
|
|
367
|
+
_IMPORT_REMOVED = '''"""Before."""
|
|
368
|
+
|
|
369
|
+
TIMEOUT_SECONDS = 5
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def work(command: list[str]) -> int:
|
|
373
|
+
"""Run one command."""
|
|
374
|
+
if TIMEOUT_SECONDS > 0:
|
|
375
|
+
completed = subprocess.run(
|
|
376
|
+
command, timeout=TIMEOUT_SECONDS, check=False, creationflags=0
|
|
377
|
+
)
|
|
378
|
+
return completed.returncode
|
|
379
|
+
return 1
|
|
380
|
+
'''
|
|
381
|
+
_UNPARSEABLE = "def work(:\n return None\n"
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _mechanical_diagnostics(
|
|
385
|
+
temporary_path: Path, current_source: str, suffix: str = ".py"
|
|
386
|
+
) -> tuple[Diagnostic, ...]:
|
|
387
|
+
return _pairing_diagnostics(
|
|
388
|
+
temporary_path,
|
|
389
|
+
_changed_document(current_source, _MECHANICAL_BEFORE, suffix),
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def test_pairing_waives_one_added_keyword_argument(tmp_path: Path) -> None:
|
|
394
|
+
assert _mechanical_diagnostics(tmp_path, _KEYWORD_ADDED) == ()
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def test_pairing_waives_an_added_import_beside_an_added_keyword(
|
|
398
|
+
tmp_path: Path,
|
|
399
|
+
) -> None:
|
|
400
|
+
assert _mechanical_diagnostics(tmp_path, _IMPORT_AND_KEYWORD_ADDED) == ()
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def test_pairing_waives_a_docstring_edit_beside_an_added_keyword(
|
|
404
|
+
tmp_path: Path,
|
|
405
|
+
) -> None:
|
|
406
|
+
assert _mechanical_diagnostics(tmp_path, _DOCSTRING_AND_KEYWORD_CHANGED) == ()
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def test_pairing_reports_an_added_keyword_beside_a_changed_literal(
|
|
410
|
+
tmp_path: Path,
|
|
411
|
+
) -> None:
|
|
412
|
+
assert (
|
|
413
|
+
len(_mechanical_diagnostics(tmp_path, _KEYWORD_ADDED_AND_LITERAL_CHANGED)) == 1
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def test_pairing_reports_a_changed_keyword_value(tmp_path: Path) -> None:
|
|
418
|
+
assert len(_mechanical_diagnostics(tmp_path, _KEYWORD_VALUE_CHANGED)) == 1
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def test_pairing_reports_a_keyword_value_that_changed_literal_type(
|
|
422
|
+
tmp_path: Path,
|
|
423
|
+
) -> None:
|
|
424
|
+
assert len(_mechanical_diagnostics(tmp_path, _KEYWORD_VALUE_TYPE_CHANGED)) == 1
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def test_pairing_reports_a_removed_keyword(tmp_path: Path) -> None:
|
|
428
|
+
assert len(_mechanical_diagnostics(tmp_path, _KEYWORD_REMOVED)) == 1
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def test_pairing_reports_a_changed_positional_argument(tmp_path: Path) -> None:
|
|
432
|
+
assert len(_mechanical_diagnostics(tmp_path, _POSITIONAL_CHANGED)) == 1
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def test_pairing_reports_a_statement_added_inside_a_function(tmp_path: Path) -> None:
|
|
436
|
+
assert len(_mechanical_diagnostics(tmp_path, _STATEMENT_ADDED_IN_BODY)) == 1
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def test_pairing_reports_a_changed_if_condition(tmp_path: Path) -> None:
|
|
440
|
+
assert len(_mechanical_diagnostics(tmp_path, _CONDITION_CHANGED)) == 1
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def test_pairing_reports_a_removed_import(tmp_path: Path) -> None:
|
|
444
|
+
assert len(_mechanical_diagnostics(tmp_path, _IMPORT_REMOVED)) == 1
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def test_pairing_reports_a_non_python_document_with_the_same_shape(
|
|
448
|
+
tmp_path: Path,
|
|
449
|
+
) -> None:
|
|
450
|
+
assert len(_mechanical_diagnostics(tmp_path, _KEYWORD_ADDED, ".ts")) == 1
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def test_pairing_reports_unparseable_current_source(tmp_path: Path) -> None:
|
|
454
|
+
assert len(_mechanical_diagnostics(tmp_path, _UNPARSEABLE)) == 1
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def test_pairing_reports_unparseable_prior_source(tmp_path: Path) -> None:
|
|
458
|
+
all_diagnostics = _pairing_diagnostics(
|
|
459
|
+
tmp_path, _changed_document(_KEYWORD_ADDED, _UNPARSEABLE)
|
|
460
|
+
)
|
|
461
|
+
assert len(all_diagnostics) == 1
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def test_pairing_reports_an_added_document_without_prior_text(tmp_path: Path) -> None:
|
|
465
|
+
added_document = Document(
|
|
466
|
+
PurePosixPath("src/feature.py"),
|
|
467
|
+
_KEYWORD_ADDED,
|
|
468
|
+
None,
|
|
469
|
+
frozenset({1}),
|
|
470
|
+
ContentOrigin.REVISION_DIFF,
|
|
471
|
+
)
|
|
472
|
+
assert len(_pairing_diagnostics(tmp_path, added_document)) == 1
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
_MAPPING_UNPACKING_ADDED = '''"""Before."""
|
|
476
|
+
|
|
477
|
+
import subprocess
|
|
478
|
+
|
|
479
|
+
TIMEOUT_SECONDS = 5
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def work(command: list[str]) -> int:
|
|
483
|
+
"""Run one command."""
|
|
484
|
+
if TIMEOUT_SECONDS > 0:
|
|
485
|
+
completed = subprocess.run(
|
|
486
|
+
command, timeout=TIMEOUT_SECONDS, check=False, **{"creationflags": 0}
|
|
487
|
+
)
|
|
488
|
+
return completed.returncode
|
|
489
|
+
return 1
|
|
490
|
+
'''
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def test_pairing_reports_added_mapping_unpacking_on_a_call(tmp_path: Path) -> None:
|
|
494
|
+
assert len(_mechanical_diagnostics(tmp_path, _MAPPING_UNPACKING_ADDED)) == 1
|
|
495
|
+
_INSTALLER_PRODUCTION_PATHS = (
|
|
496
|
+
PurePosixPath("packages/claude-dev-env/bin/install-constants.mjs"),
|
|
497
|
+
PurePosixPath("packages/claude-dev-env/bin/install.mjs"),
|
|
498
|
+
)
|
|
499
|
+
_INSTALLER_SUITE_PATH = PurePosixPath(
|
|
500
|
+
"packages/claude-dev-env/bin/install.cursor-rules.test.mjs"
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def test_pairing_accepts_installer_modules_with_the_cursor_rules_suite(
|
|
505
|
+
tmp_path: Path,
|
|
506
|
+
) -> None:
|
|
507
|
+
all_paths = _diagnostic_paths(
|
|
508
|
+
tmp_path,
|
|
509
|
+
*(_body_change_at(each_path) for each_path in _INSTALLER_PRODUCTION_PATHS),
|
|
510
|
+
_body_change_at(_INSTALLER_SUITE_PATH),
|
|
511
|
+
)
|
|
512
|
+
assert all_paths == ()
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
_COMMENT_RULES_PRODUCTION_PATH = PurePosixPath(
|
|
516
|
+
"packages/claude-dev-env/hooks/blocking/code_rules_comments.py"
|
|
517
|
+
)
|
|
518
|
+
_COMMENT_RULES_SUITE_PATH = PurePosixPath(
|
|
519
|
+
"packages/claude-dev-env/hooks/blocking/test_code_rules_enforcer_comment_string_awareness.py"
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def test_pairing_accepts_comment_rules_module_with_one_approved_suite(
|
|
524
|
+
tmp_path: Path,
|
|
525
|
+
) -> None:
|
|
526
|
+
all_paths = _diagnostic_paths(
|
|
527
|
+
tmp_path,
|
|
528
|
+
_body_change_at(_COMMENT_RULES_PRODUCTION_PATH),
|
|
529
|
+
_body_change_at(_COMMENT_RULES_SUITE_PATH),
|
|
530
|
+
)
|
|
531
|
+
assert all_paths == ()
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def test_pairing_rejects_comment_rules_module_without_any_approved_suite(
|
|
535
|
+
tmp_path: Path,
|
|
536
|
+
) -> None:
|
|
537
|
+
all_paths = _diagnostic_paths(tmp_path, _body_change_at(_COMMENT_RULES_PRODUCTION_PATH))
|
|
538
|
+
assert all_paths == (_COMMENT_RULES_PRODUCTION_PATH,)
|