claude-dev-env 8.10.0 → 8.11.1
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/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_regression.py +85 -3
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_regression.py +34 -0
- package/_shared/pr-loop/scripts/fix_pr_test_proof.py +233 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/fix_pr_test_proof_constants.py +47 -0
- package/_shared/pr-loop/scripts/test_fix_pr_test_proof.py +186 -0
- package/bin/codex-skill-load-block.mjs +93 -0
- package/bin/codex-skill-load-block.test.mjs +134 -0
- package/bin/install.agents-home.test.mjs +76 -1
- package/bin/install.mjs +55 -31
- package/bin/install.test.mjs +57 -0
- package/bin/install.transaction.test.mjs +29 -7
- package/bin/seed-codex-pstack-models.mjs +27 -0
- package/bin/seed-codex-pstack-models.test.mjs +72 -0
- package/docs/CODE_RULES.md +5 -1
- package/hooks/blocking/code_rules_enforcer.py +1 -3
- package/hooks/blocking/code_rules_magic_values.py +1 -3
- package/hooks/blocking/code_rules_paired_test.py +5 -6
- package/hooks/blocking/pii_prevention_blocker_parts/repository_exemption.py +3 -3
- package/hooks/blocking/tdd_enforcer_parts/__init__.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +1 -46
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +2 -40
- package/hooks/blocking/tests/test_repository_exemption.py +7 -0
- package/hooks/followup_ledger.py +8 -1
- package/hooks/git-hooks/test_installed_policy_lint.py +4 -2
- package/hooks/hooks.json +15 -0
- package/hooks/hooks_constants/followup_ledger_constants.py +4 -0
- package/hooks/hooks_constants/messages.py +0 -1
- package/hooks/hooks_constants/nested_project_hooks_constants.py +37 -0
- package/hooks/hooks_constants/test_messages.py +1 -7
- package/hooks/lifecycle/nested_project_hooks.py +395 -0
- package/hooks/lifecycle/test_nested_project_hooks.py +251 -0
- package/hooks/test_followup_ledger.py +18 -0
- package/package.json +2 -1
- package/pstack-codex-models.md +37 -0
- package/rules/bdd.md +1 -1
- package/rules/build-needs-a-user.md +53 -0
- package/rules/code-standards.md +1 -1
- package/rules/flag-non-breaking-findings.md +7 -0
- package/rules/paired-test-coverage.md +4 -2
- package/rules/testing.md +5 -1
- package/scripts/cde_lint.py +47 -1
- package/scripts/policy_lint/adapter_callers.py +124 -0
- package/scripts/policy_lint/adapter_pairing.py +4 -2
- package/scripts/policy_lint/adapter_support.py +4 -3
- package/scripts/policy_lint/adapters.py +15 -0
- package/scripts/policy_lint/check_catalog.py +24 -0
- package/scripts/policy_lint/config/approved_test_pairs.py +3 -2
- package/scripts/policy_lint/config/check_catalog_constants.py +13 -0
- package/scripts/policy_lint/config/constants.py +18 -0
- package/scripts/policy_lint/engine.py +9 -3
- package/scripts/policy_lint/model.py +6 -2
- package/scripts/policy_lint/registry.py +6 -0
- package/scripts/policy_lint/selection.py +2 -0
- package/scripts/repository_checks/tracked_private_terms.py +15 -1
- package/scripts/tests/test_cde_lint.py +50 -0
- package/scripts/tests/test_policy_lint_rules_archive.py +16 -0
- package/scripts/tests/test_policy_lint_rules_callers.py +140 -0
- package/scripts/tests/test_policy_lint_rules_check_catalog.py +25 -1
- package/scripts/tests/test_policy_lint_rules_pairing.py +31 -2
- package/scripts/tests/test_policy_lint_selection.py +35 -0
- package/scripts/tests/test_tracked_private_terms.py +35 -0
- package/hooks/blocking/tdd_enforcer.py +0 -195
- package/hooks/blocking/tdd_enforcer_parts/candidate_paths.py +0 -142
- package/hooks/blocking/tdd_enforcer_parts/config/__init__.py +0 -1
- package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +0 -49
- package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +0 -298
- package/hooks/blocking/tdd_enforcer_parts/decisions.py +0 -96
- package/hooks/blocking/tdd_enforcer_parts/freshness.py +0 -95
- package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +0 -132
- package/hooks/blocking/tdd_enforcer_parts/path_classification.py +0 -119
- package/hooks/blocking/tdd_enforcer_parts/tests/test_candidate_paths.py +0 -31
- package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +0 -256
- package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +0 -46
- package/hooks/blocking/tdd_enforcer_parts/tests/test_freshness.py +0 -28
- package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +0 -111
- package/hooks/blocking/tdd_enforcer_parts/tests/test_path_classification.py +0 -36
- package/hooks/blocking/test_tdd_enforcer.py +0 -1506
- package/hooks/blocking/test_tdd_enforcer_restore.py +0 -108
- package/hooks/blocking/test_tdd_enforcer_scratchpad.py +0 -105
- package/scripts/codex_compat_watcher.py +0 -502
- package/scripts/tests/test_codex_compat_watcher.py +0 -599
|
@@ -13,6 +13,7 @@ from policy_lint.config import constants as policy_constants
|
|
|
13
13
|
from private_terms import private_term_line_numbers
|
|
14
14
|
|
|
15
15
|
from repository_checks.config import constants as repository_constants
|
|
16
|
+
from repository_checks.hook_modules import load_hooks_module
|
|
16
17
|
from repository_checks.models import RepositoryFinding
|
|
17
18
|
|
|
18
19
|
|
|
@@ -25,6 +26,15 @@ def _read_text_or_none(absolute_path: Path) -> str | None:
|
|
|
25
26
|
return None
|
|
26
27
|
|
|
27
28
|
|
|
29
|
+
def _is_owned_by_private_organization(repository_root: Path) -> bool:
|
|
30
|
+
exemption = load_hooks_module(repository_constants.REPOSITORY_EXEMPTION_MODULE_NAME)
|
|
31
|
+
origin_slug = exemption.repository_origin_slug(repository_root)
|
|
32
|
+
if origin_slug is None:
|
|
33
|
+
return False
|
|
34
|
+
owner_name = origin_slug.split(policy_constants.PATH_SEPARATOR)[0]
|
|
35
|
+
return bool(private_term_line_numbers(owner_name, ALL_PRIVATE_TERM_DIGESTS))
|
|
36
|
+
|
|
37
|
+
|
|
28
38
|
def collect_tracked_private_term_findings(
|
|
29
39
|
repository_root: Path, all_tracked_paths: Sequence[str]
|
|
30
40
|
) -> list[RepositoryFinding]:
|
|
@@ -35,8 +45,12 @@ def collect_tracked_private_term_findings(
|
|
|
35
45
|
all_tracked_paths: Repository-relative tracked paths.
|
|
36
46
|
|
|
37
47
|
Returns:
|
|
38
|
-
Findings that carry the path and line number, never the name.
|
|
48
|
+
Findings that carry the path and line number, never the name. A
|
|
49
|
+
repository whose github.com origin owner is itself a private
|
|
50
|
+
organization returns none, since that organization may name itself.
|
|
39
51
|
"""
|
|
52
|
+
if _is_owned_by_private_organization(repository_root):
|
|
53
|
+
return []
|
|
40
54
|
all_findings: list[RepositoryFinding] = []
|
|
41
55
|
for each_relative_path in all_tracked_paths:
|
|
42
56
|
if (content := _read_text_or_none(repository_root / each_relative_path)) is None:
|
|
@@ -396,3 +396,53 @@ def test_should_emit_only_located_diagnostics_in_editor_format(
|
|
|
396
396
|
assert exit_code == 1
|
|
397
397
|
assert "src/app.py:4:2:" in stdout_text
|
|
398
398
|
assert "unlocated finding" not in stdout_text
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def test_should_exit_zero_and_record_a_followup_when_only_warnings_exist(
|
|
402
|
+
tmp_path: Path,
|
|
403
|
+
) -> None:
|
|
404
|
+
warning_report = LintReport(
|
|
405
|
+
1,
|
|
406
|
+
(
|
|
407
|
+
Diagnostic(
|
|
408
|
+
"code-rules",
|
|
409
|
+
Severity.WARNING,
|
|
410
|
+
"Line 4: paired test gap",
|
|
411
|
+
Location(PurePosixPath("src/app.py"), 4, 1),
|
|
412
|
+
"code-rules/paired-test-missing-function",
|
|
413
|
+
),
|
|
414
|
+
),
|
|
415
|
+
(PurePosixPath("src/app.py"),),
|
|
416
|
+
("code-rules",),
|
|
417
|
+
(),
|
|
418
|
+
(),
|
|
419
|
+
)
|
|
420
|
+
exit_code, stdout_text, _stderr_text = _run_cli(
|
|
421
|
+
["--files", "src/app.py"],
|
|
422
|
+
lint_runner=lambda _request: warning_report,
|
|
423
|
+
repository_root=tmp_path,
|
|
424
|
+
)
|
|
425
|
+
ledger_path = tmp_path / ".claude" / "followups" / "smells.jsonl"
|
|
426
|
+
all_ledger_records = [
|
|
427
|
+
json.loads(each_line)
|
|
428
|
+
for each_line in ledger_path.read_text(encoding="utf-8").splitlines()
|
|
429
|
+
]
|
|
430
|
+
assert exit_code == 0
|
|
431
|
+
assert "paired test gap" in stdout_text
|
|
432
|
+
assert [each_record["check_id"] for each_record in all_ledger_records] == [
|
|
433
|
+
"code-rules/paired-test-missing-function"
|
|
434
|
+
]
|
|
435
|
+
assert all_ledger_records[0]["file_path"] == "src/app.py"
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def test_should_leave_the_ledger_absent_when_every_finding_is_an_error(
|
|
439
|
+
tmp_path: Path,
|
|
440
|
+
) -> None:
|
|
441
|
+
_run_cli(
|
|
442
|
+
["--files", "src/app.py"],
|
|
443
|
+
lint_runner=lambda _request: LintReport(
|
|
444
|
+
1, (_located_diagnostic(),), (), ("rule-a",), (), ()
|
|
445
|
+
),
|
|
446
|
+
repository_root=tmp_path,
|
|
447
|
+
)
|
|
448
|
+
assert not (tmp_path / ".claude" / "followups" / "smells.jsonl").exists()
|
|
@@ -118,3 +118,19 @@ def test_retirement_and_restoration_preserve_live_change_metadata(tmp_path: Path
|
|
|
118
118
|
assert tuple(document.path for document in runtime_set.documents) == (restored_path,)
|
|
119
119
|
assert runtime_set.deleted_paths == (deleted_path, retired_path)
|
|
120
120
|
assert runtime_set.renamed_paths == ((restored_source, restored_path),)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_archived_hook_moves_leave_the_automatic_checks(tmp_path: Path) -> None:
|
|
124
|
+
live_hook_path = PurePosixPath("packages/tool/hooks/blocking/retired_gate.py")
|
|
125
|
+
archived_hook_path = PurePosixPath("packages/tool/hooks-archived/blocking/retired_gate.py")
|
|
126
|
+
document_set = DocumentSet(
|
|
127
|
+
(Document.from_text(archived_hook_path, "archived"),),
|
|
128
|
+
SelectionKind.BASE,
|
|
129
|
+
tmp_path,
|
|
130
|
+
(),
|
|
131
|
+
((live_hook_path, archived_hook_path),),
|
|
132
|
+
)
|
|
133
|
+
runtime_set = _runtime_document_set(document_set)
|
|
134
|
+
assert runtime_set.documents == ()
|
|
135
|
+
assert runtime_set.deleted_paths == (live_hook_path,)
|
|
136
|
+
assert runtime_set.renamed_paths == ()
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Tests for the uncalled-new-file rule over a change set."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path, PurePosixPath
|
|
6
|
+
|
|
7
|
+
from policy_lint import adapter_callers, registry
|
|
8
|
+
from policy_lint.model import (
|
|
9
|
+
ChangeSetRule,
|
|
10
|
+
ContentOrigin,
|
|
11
|
+
Document,
|
|
12
|
+
DocumentSet,
|
|
13
|
+
SelectionKind,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
_PACKAGE = "packages/claude-dev-env"
|
|
17
|
+
_NEW_SCRIPT = f"{_PACKAGE}/scripts/lonely_watcher.py"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _write(repository_root: Path, relative_path: str, text: str) -> None:
|
|
21
|
+
target = repository_root / relative_path
|
|
22
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
target.write_text(text, "utf-8")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _added(relative_path: str, prior_text: str | None = None) -> Document:
|
|
27
|
+
return Document(
|
|
28
|
+
PurePosixPath(relative_path),
|
|
29
|
+
"print('hi')\n",
|
|
30
|
+
prior_text,
|
|
31
|
+
None,
|
|
32
|
+
ContentOrigin.REVISION_DIFF,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _messages(
|
|
37
|
+
repository_root: Path,
|
|
38
|
+
all_documents: tuple[Document, ...],
|
|
39
|
+
selection: SelectionKind = SelectionKind.BASE,
|
|
40
|
+
) -> tuple[str, ...]:
|
|
41
|
+
document_set = DocumentSet(all_documents, selection, repository_root)
|
|
42
|
+
return tuple(
|
|
43
|
+
each_diagnostic.message
|
|
44
|
+
for each_diagnostic in adapter_callers.uncalled_new_file_diagnostics(
|
|
45
|
+
document_set
|
|
46
|
+
)
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_should_report_a_new_script_only_its_own_test_names(tmp_path: Path) -> None:
|
|
51
|
+
_write(tmp_path, _NEW_SCRIPT, "print('hi')\n")
|
|
52
|
+
_write(
|
|
53
|
+
tmp_path,
|
|
54
|
+
f"{_PACKAGE}/scripts/tests/test_lonely_watcher.py",
|
|
55
|
+
"import lonely_watcher\n",
|
|
56
|
+
)
|
|
57
|
+
_write(tmp_path, f"{_PACKAGE}/CHANGELOG.md", "added lonely_watcher\n")
|
|
58
|
+
_write(tmp_path, f"{_PACKAGE}/README.md", "| lonely_watcher | x |\n")
|
|
59
|
+
_write(tmp_path, f"{_PACKAGE}/bin/ever-shipped-skills.mjs", "'lonely_watcher'\n")
|
|
60
|
+
|
|
61
|
+
all_messages = _messages(tmp_path, (_added(_NEW_SCRIPT),))
|
|
62
|
+
|
|
63
|
+
assert len(all_messages) == 1
|
|
64
|
+
assert "lonely_watcher" in all_messages[0]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_should_pass_a_new_script_a_workflow_runs(tmp_path: Path) -> None:
|
|
68
|
+
_write(tmp_path, _NEW_SCRIPT, "print('hi')\n")
|
|
69
|
+
_write(
|
|
70
|
+
tmp_path,
|
|
71
|
+
".github/workflows/ci.yml",
|
|
72
|
+
f"run: python {_NEW_SCRIPT}\n",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
assert _messages(tmp_path, (_added(_NEW_SCRIPT),)) == ()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_should_pass_a_new_hook_that_hooks_json_registers(tmp_path: Path) -> None:
|
|
79
|
+
hook_path = f"{_PACKAGE}/hooks/advisory/quiet_guard.py"
|
|
80
|
+
_write(tmp_path, hook_path, "")
|
|
81
|
+
_write(
|
|
82
|
+
tmp_path,
|
|
83
|
+
f"{_PACKAGE}/hooks/hooks.json",
|
|
84
|
+
'{"command": "python hooks/advisory/quiet_guard.py"}',
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
assert _messages(tmp_path, (_added(hook_path),)) == ()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_should_ignore_a_file_that_existed_before_the_change(tmp_path: Path) -> None:
|
|
91
|
+
_write(tmp_path, _NEW_SCRIPT, "print('hi')\n")
|
|
92
|
+
|
|
93
|
+
assert _messages(tmp_path, (_added(_NEW_SCRIPT, "old\n"),)) == ()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_should_ignore_new_tests_and_files_outside_code_folders(
|
|
97
|
+
tmp_path: Path,
|
|
98
|
+
) -> None:
|
|
99
|
+
test_path = f"{_PACKAGE}/scripts/tests/test_orphan.py"
|
|
100
|
+
rule_path = f"{_PACKAGE}/rules/orphan.md"
|
|
101
|
+
_write(tmp_path, test_path, "")
|
|
102
|
+
_write(tmp_path, rule_path, "")
|
|
103
|
+
|
|
104
|
+
assert _messages(tmp_path, (_added(test_path), _added(rule_path))) == ()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_should_not_count_a_longer_name_that_contains_the_stem(
|
|
108
|
+
tmp_path: Path,
|
|
109
|
+
) -> None:
|
|
110
|
+
_write(tmp_path, _NEW_SCRIPT, "print('hi')\n")
|
|
111
|
+
_write(tmp_path, f"{_PACKAGE}/scripts/runner.py", "import lonely_watcher_v2\n")
|
|
112
|
+
|
|
113
|
+
assert len(_messages(tmp_path, (_added(_NEW_SCRIPT),))) == 1
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_should_run_on_staged_and_base_selections() -> None:
|
|
117
|
+
uncalled_rule = next(
|
|
118
|
+
each_rule
|
|
119
|
+
for each_rule in registry.default_registry()
|
|
120
|
+
if each_rule.rule_id == "uncalled-new-file"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
assert isinstance(uncalled_rule, ChangeSetRule)
|
|
124
|
+
assert uncalled_rule.selections == frozenset(
|
|
125
|
+
{SelectionKind.STAGED, SelectionKind.BASE}
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_should_report_new_ci_and_tools_helpers_only_tests_name(
|
|
130
|
+
tmp_path: Path,
|
|
131
|
+
) -> None:
|
|
132
|
+
ci_helper = ".github/ci/lonely_router.py"
|
|
133
|
+
tools_helper = "tools/lonely_archiver.py"
|
|
134
|
+
_write(tmp_path, ci_helper, "print('hi')\n")
|
|
135
|
+
_write(tmp_path, tools_helper, "print('hi')\n")
|
|
136
|
+
_write(tmp_path, ".github/ci/test_lonely_router.py", "import lonely_router\n")
|
|
137
|
+
|
|
138
|
+
all_messages = _messages(tmp_path, (_added(ci_helper), _added(tools_helper)))
|
|
139
|
+
|
|
140
|
+
assert len(all_messages) == 2
|
|
@@ -10,7 +10,7 @@ from __future__ import annotations
|
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
|
|
12
12
|
import pytest
|
|
13
|
-
from policy_lint.check_catalog import check_id_for_message
|
|
13
|
+
from policy_lint.check_catalog import check_id_for_message, severity_for_check_id
|
|
14
14
|
from policy_lint.config.check_catalog_constants import (
|
|
15
15
|
ALL_CHECK_CATALOG_ENTRIES,
|
|
16
16
|
UNCLASSIFIED_CHECK_NAME,
|
|
@@ -71,6 +71,17 @@ ALL_MESSAGE_SAMPLES: tuple[tuple[str, str], ...] = (
|
|
|
71
71
|
"missing-return-annotation",
|
|
72
72
|
),
|
|
73
73
|
("src/app.py:3: Single-letter variable 'p'", "single-letter-variable"),
|
|
74
|
+
(
|
|
75
|
+
"Line 45: public function 'run' is exercised by no test in the module's"
|
|
76
|
+
" paired test suite, though that suite already exercises this module",
|
|
77
|
+
"paired-test-missing-function",
|
|
78
|
+
),
|
|
79
|
+
(
|
|
80
|
+
"app.py public function 'run' is a public function this stem-matched test"
|
|
81
|
+
" suite defines its module for but exercises nowhere, though the suite"
|
|
82
|
+
" already covers another public function in that module",
|
|
83
|
+
"paired-test-omitted-function",
|
|
84
|
+
),
|
|
74
85
|
)
|
|
75
86
|
|
|
76
87
|
|
|
@@ -149,3 +160,16 @@ def test_a_diagnostic_reports_the_check_identifier_it_carries() -> None:
|
|
|
149
160
|
)
|
|
150
161
|
|
|
151
162
|
assert diagnostic.as_dict()["check_id"] == "code-rules/constant-outside-config"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@pytest.mark.parametrize(
|
|
166
|
+
"check_name", ("paired-test-missing-function", "paired-test-omitted-function")
|
|
167
|
+
)
|
|
168
|
+
def test_a_paired_test_check_reports_as_a_warning(check_name: str) -> None:
|
|
169
|
+
assert severity_for_check_id(f"{_BUNDLING_RULE_ID}/{check_name}") is Severity.WARNING
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_an_unclassified_check_reports_as_an_error() -> None:
|
|
173
|
+
unclassified_check_id = f"{_BUNDLING_RULE_ID}/{UNCLASSIFIED_CHECK_NAME}"
|
|
174
|
+
|
|
175
|
+
assert severity_for_check_id(unclassified_check_id) is Severity.ERROR
|
|
@@ -14,6 +14,7 @@ from policy_lint.model import (
|
|
|
14
14
|
Document,
|
|
15
15
|
DocumentSet,
|
|
16
16
|
SelectionKind,
|
|
17
|
+
Severity,
|
|
17
18
|
)
|
|
18
19
|
|
|
19
20
|
_MODULE_DOCSTRING_BEFORE = '''"""Before."""
|
|
@@ -117,6 +118,17 @@ def test_pairing_requires_a_test_for_python_body_changes(tmp_path: Path) -> None
|
|
|
117
118
|
assert len(all_diagnostics) == 1
|
|
118
119
|
|
|
119
120
|
|
|
121
|
+
def test_pairing_reports_an_unpaired_change_as_a_recorded_warning(
|
|
122
|
+
tmp_path: Path,
|
|
123
|
+
) -> None:
|
|
124
|
+
all_diagnostics = _pairing_diagnostics(
|
|
125
|
+
tmp_path,
|
|
126
|
+
_changed_document(_BODY_AFTER, _BODY_BEFORE),
|
|
127
|
+
)
|
|
128
|
+
assert [each.severity for each in all_diagnostics] == [Severity.WARNING]
|
|
129
|
+
assert all_diagnostics[0].check_id == "test-pairing"
|
|
130
|
+
|
|
131
|
+
|
|
120
132
|
def test_pairing_rejects_python_syntax_errors_without_a_test(tmp_path: Path) -> None:
|
|
121
133
|
all_diagnostics = _pairing_diagnostics(
|
|
122
134
|
tmp_path,
|
|
@@ -186,10 +198,10 @@ def test_cursor_verifier_constants_require_the_changed_driver_suite(
|
|
|
186
198
|
tmp_path: Path,
|
|
187
199
|
) -> None:
|
|
188
200
|
constants_path = PurePosixPath(
|
|
189
|
-
".
|
|
201
|
+
".agents/skills/verify/scripts/verify_installer_constants/constants.mjs"
|
|
190
202
|
)
|
|
191
203
|
suite_path = PurePosixPath(
|
|
192
|
-
".
|
|
204
|
+
".agents/skills/verify/scripts/verify-installer.test.mjs"
|
|
193
205
|
)
|
|
194
206
|
constants_change = _body_change_at(constants_path)
|
|
195
207
|
assert _diagnostic_paths(tmp_path, constants_change) == (constants_path,)
|
|
@@ -528,6 +540,23 @@ def test_pairing_accepts_installer_modules_with_the_cursor_rules_suite(
|
|
|
528
540
|
assert all_paths == ()
|
|
529
541
|
|
|
530
542
|
|
|
543
|
+
_INSTALLER_MODULE_PATH = PurePosixPath("packages/claude-dev-env/bin/install.mjs")
|
|
544
|
+
_AGENTS_HOME_SUITE_PATH = PurePosixPath(
|
|
545
|
+
"packages/claude-dev-env/bin/install.agents-home.test.mjs"
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def test_pairing_accepts_installer_module_with_the_agents_home_suite(
|
|
550
|
+
tmp_path: Path,
|
|
551
|
+
) -> None:
|
|
552
|
+
all_paths = _diagnostic_paths(
|
|
553
|
+
tmp_path,
|
|
554
|
+
_body_change_at(_INSTALLER_MODULE_PATH),
|
|
555
|
+
_body_change_at(_AGENTS_HOME_SUITE_PATH),
|
|
556
|
+
)
|
|
557
|
+
assert all_paths == ()
|
|
558
|
+
|
|
559
|
+
|
|
531
560
|
_COMMENT_RULES_PRODUCTION_PATH = PurePosixPath(
|
|
532
561
|
"packages/claude-dev-env/hooks/blocking/code_rules_comments.py"
|
|
533
562
|
)
|
|
@@ -285,6 +285,41 @@ def test_base_source_should_compare_merge_base_and_read_worktree_bytes(
|
|
|
285
285
|
assert selected_document.origin == ContentOrigin.REVISION_DIFF
|
|
286
286
|
|
|
287
287
|
|
|
288
|
+
def test_base_file_that_was_a_directory_at_base_should_have_no_prior_text(
|
|
289
|
+
tmp_path: Path,
|
|
290
|
+
) -> None:
|
|
291
|
+
repository_root = _initialize_repository(tmp_path)
|
|
292
|
+
_commit_text(repository_root, "skills/verify/notes.md", "base-text\n")
|
|
293
|
+
_run_git(repository_root, "checkout", "--quiet", "-b", "feature")
|
|
294
|
+
_run_git(repository_root, "rm", "--quiet", "-r", "--", "skills")
|
|
295
|
+
_commit_text(repository_root, "skills", "../shared/skills\n")
|
|
296
|
+
|
|
297
|
+
document_set = select_documents(LintRequest.base(repository_root, "main"))
|
|
298
|
+
selected_document = _document_for(document_set, "skills")
|
|
299
|
+
|
|
300
|
+
assert selected_document.text == "../shared/skills\n"
|
|
301
|
+
assert selected_document.prior_text is None
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def test_base_directory_pointer_should_be_left_out_of_the_selection(
|
|
305
|
+
tmp_path: Path,
|
|
306
|
+
) -> None:
|
|
307
|
+
repository_root = _initialize_repository(tmp_path)
|
|
308
|
+
_commit_text(repository_root, "shared/skills/verify/notes.md", "base-text\n")
|
|
309
|
+
_run_git(repository_root, "checkout", "--quiet", "-b", "feature")
|
|
310
|
+
try:
|
|
311
|
+
os.symlink("shared/skills", repository_root / "skills", target_is_directory=True)
|
|
312
|
+
except OSError as error:
|
|
313
|
+
pytest.skip(f"this host cannot create a directory pointer: {error}")
|
|
314
|
+
_run_git(repository_root, "add", "--", "skills")
|
|
315
|
+
_run_git(repository_root, "commit", "--quiet", "-m", "selection pointer fixture")
|
|
316
|
+
|
|
317
|
+
document_set = select_documents(LintRequest.base(repository_root, "main"))
|
|
318
|
+
|
|
319
|
+
assert "skills" not in _all_selected_paths(document_set)
|
|
320
|
+
assert "shared/skills" not in _all_selected_paths(document_set)
|
|
321
|
+
|
|
322
|
+
|
|
288
323
|
def test_base_selection_records_merge_base_revision_and_staged_does_not(
|
|
289
324
|
tmp_path: Path,
|
|
290
325
|
) -> None:
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import hashlib
|
|
6
|
+
import subprocess
|
|
6
7
|
import sys
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
|
|
@@ -69,3 +70,37 @@ def test_should_pass_a_tree_that_names_no_private_organization(
|
|
|
69
70
|
exit_code, stdout_text, _stderr_text = run_policy(repository_root)
|
|
70
71
|
assert exit_code == SUCCESS_EXIT_CODE
|
|
71
72
|
assert stdout_text == ""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_should_pass_a_repository_the_named_organization_owns(
|
|
76
|
+
tmp_path: Path,
|
|
77
|
+
) -> None:
|
|
78
|
+
repository_root = tmp_path / "repo"
|
|
79
|
+
initialize_repository(repository_root)
|
|
80
|
+
subprocess.run(
|
|
81
|
+
["git", "remote", "add", "origin", "https://github.com/AcmeWidget/app.git"],
|
|
82
|
+
cwd=repository_root,
|
|
83
|
+
check=True,
|
|
84
|
+
)
|
|
85
|
+
write_text(repository_root / "docs" / "notes.md", "Built by Acme Widgets.\n")
|
|
86
|
+
commit_tracked_files(repository_root)
|
|
87
|
+
exit_code, stdout_text, _stderr_text = run_policy(repository_root)
|
|
88
|
+
assert exit_code == SUCCESS_EXIT_CODE
|
|
89
|
+
assert stdout_text == ""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_should_flag_a_repository_another_owner_holds(
|
|
93
|
+
tmp_path: Path,
|
|
94
|
+
) -> None:
|
|
95
|
+
repository_root = tmp_path / "repo"
|
|
96
|
+
initialize_repository(repository_root)
|
|
97
|
+
subprocess.run(
|
|
98
|
+
["git", "remote", "add", "origin", "https://github.com/someone/app.git"],
|
|
99
|
+
cwd=repository_root,
|
|
100
|
+
check=True,
|
|
101
|
+
)
|
|
102
|
+
write_text(repository_root / "docs" / "notes.md", "Built by Acme Widgets.\n")
|
|
103
|
+
commit_tracked_files(repository_root)
|
|
104
|
+
exit_code, stdout_text, _stderr_text = run_policy(repository_root)
|
|
105
|
+
assert exit_code == FINDINGS_EXIT_CODE
|
|
106
|
+
assert f"{CHECK_ID_TRACKED_PRIVATE_TERMS}: docs/notes.md:" in stdout_text
|
|
@@ -1,195 +0,0 @@
|
|
|
1
|
-
"""BDD Automate-phase gate (production code touch).
|
|
2
|
-
|
|
3
|
-
Blocks a write to a production source file when no matching test was modified
|
|
4
|
-
within the freshness window, enforcing "TDD IS NON-NEGOTIABLE" from CLAUDE.md.
|
|
5
|
-
The gate records each candidate test's content hash at every sighting and
|
|
6
|
-
compares that hash on the next write. A first sighting requires content that
|
|
7
|
-
differs from HEAD. A stored failing pytest run is read before that fallback
|
|
8
|
-
and applies while the candidate's content still matches the stored hash.
|
|
9
|
-
Each concern lives in a ``tdd_enforcer_parts`` submodule; this entry wires them
|
|
10
|
-
into one PreToolUse gate and re-exports their surface for the test suite.
|
|
11
|
-
"""
|
|
12
|
-
|
|
13
|
-
import json
|
|
14
|
-
import sys
|
|
15
|
-
from pathlib import Path
|
|
16
|
-
|
|
17
|
-
try:
|
|
18
|
-
_hooks_root_directory = str(Path(__file__).resolve().parent.parent)
|
|
19
|
-
_blocking_directory = str(Path(__file__).resolve().parent)
|
|
20
|
-
for each_bootstrap_directory in (_hooks_root_directory, _blocking_directory):
|
|
21
|
-
if each_bootstrap_directory not in sys.path:
|
|
22
|
-
sys.path.insert(0, each_bootstrap_directory)
|
|
23
|
-
from code_rules_shared import (
|
|
24
|
-
is_agent_home_tooling,
|
|
25
|
-
is_ephemeral_script_path,
|
|
26
|
-
is_under_session_scratchpad,
|
|
27
|
-
)
|
|
28
|
-
from codex_apply_patch import (
|
|
29
|
-
payload_patch_target_paths,
|
|
30
|
-
)
|
|
31
|
-
from hooks_constants.pre_tool_use_dispatcher_constants import APPLY_PATCH_TOOL_NAME
|
|
32
|
-
from tdd_enforcer_parts import (
|
|
33
|
-
candidate_paths,
|
|
34
|
-
content_analysis,
|
|
35
|
-
content_hash_store,
|
|
36
|
-
decisions,
|
|
37
|
-
freshness,
|
|
38
|
-
git_tracking,
|
|
39
|
-
path_classification,
|
|
40
|
-
)
|
|
41
|
-
from tdd_enforcer_parts.config.tdd_enforcer_constants import PYTHON_SOURCE_EXTENSION
|
|
42
|
-
except ImportError as import_error:
|
|
43
|
-
raise ImportError(
|
|
44
|
-
"tdd_enforcer: cannot import its tdd_enforcer_parts submodules; "
|
|
45
|
-
"ensure the hooks directory is importable."
|
|
46
|
-
) from import_error
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def _is_session_scratchpad_write(file_path: str, input_data: dict) -> bool:
|
|
50
|
-
return is_under_session_scratchpad(file_path, input_data)
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
def _is_agent_home_tooling_write(file_path: str) -> bool:
|
|
54
|
-
"""A coding agent's own home-directory tooling is outside the TDD gate."""
|
|
55
|
-
return is_agent_home_tooling(file_path)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
production_extensions = path_classification.production_extensions
|
|
59
|
-
skip_extensions = path_classification.skip_extensions
|
|
60
|
-
_is_inside_dotclaude_segment = path_classification._is_inside_dotclaude_segment
|
|
61
|
-
_directory_skip_components = path_classification._directory_skip_components
|
|
62
|
-
_matches_any_skip_pattern = path_classification._matches_any_skip_pattern
|
|
63
|
-
_extract_written_content = path_classification._extract_written_content
|
|
64
|
-
candidate_test_paths_for = candidate_paths.candidate_test_paths_for
|
|
65
|
-
_ancestor_tests_directories = candidate_paths._ancestor_tests_directories
|
|
66
|
-
_parent_walk_limit = candidate_paths._parent_walk_limit
|
|
67
|
-
_freshness_seconds = freshness._freshness_seconds
|
|
68
|
-
_read_candidate_text = freshness._read_candidate_text
|
|
69
|
-
has_recorded_or_fresh_test = content_hash_store.has_recorded_or_fresh_test
|
|
70
|
-
_is_constants_only_python_content = content_analysis._is_constants_only_python_content
|
|
71
|
-
_is_post_edit_import_only = content_analysis._is_post_edit_import_only
|
|
72
|
-
_is_post_edit_constants_only = content_analysis._is_post_edit_constants_only
|
|
73
|
-
is_absent_but_tracked = git_tracking.is_absent_but_tracked
|
|
74
|
-
build_deny_reason = decisions.build_deny_reason
|
|
75
|
-
emit_allow = decisions.emit_allow
|
|
76
|
-
emit_deny = decisions.emit_deny
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
def _resolve_payload(input_data: dict) -> tuple[str, dict, str]:
|
|
80
|
-
tool_input = input_data.get("tool_input", {})
|
|
81
|
-
return input_data.get("tool_name", ""), tool_input, tool_input.get("file_path", "")
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
def _is_silently_skipped(
|
|
85
|
-
file_path: str, extension: str, path: Path, repository_root: str = ""
|
|
86
|
-
) -> bool:
|
|
87
|
-
if _is_inside_dotclaude_segment(file_path) or is_ephemeral_script_path(
|
|
88
|
-
file_path, repository_root
|
|
89
|
-
):
|
|
90
|
-
return True
|
|
91
|
-
if _is_agent_home_tooling_write(file_path):
|
|
92
|
-
return True
|
|
93
|
-
if extension in skip_extensions():
|
|
94
|
-
return True
|
|
95
|
-
if extension not in production_extensions():
|
|
96
|
-
return True
|
|
97
|
-
path_with_forward_slashes = str(path).lower().replace("\\", "/")
|
|
98
|
-
return _matches_any_skip_pattern(path.name.lower(), path_with_forward_slashes)
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
def _edit_is_constants_or_import_only(
|
|
102
|
-
tool_name: str, tool_input: dict, path: Path, extension: str
|
|
103
|
-
) -> bool:
|
|
104
|
-
if (
|
|
105
|
-
tool_name not in ("Edit", "MultiEdit")
|
|
106
|
-
or extension != PYTHON_SOURCE_EXTENSION
|
|
107
|
-
or not path.exists()
|
|
108
|
-
):
|
|
109
|
-
return False
|
|
110
|
-
existing_content = _read_candidate_text(path)
|
|
111
|
-
if existing_content is None:
|
|
112
|
-
return False
|
|
113
|
-
if _is_post_edit_import_only(existing_content, tool_name, tool_input):
|
|
114
|
-
return True
|
|
115
|
-
return _is_post_edit_constants_only(existing_content, tool_name, tool_input)
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
def _write_is_exempt(
|
|
119
|
-
tool_name: str, tool_input: dict, path: Path, extension: str
|
|
120
|
-
) -> bool:
|
|
121
|
-
if is_absent_but_tracked(path):
|
|
122
|
-
return True
|
|
123
|
-
written_content = _extract_written_content(tool_name, tool_input)
|
|
124
|
-
if (
|
|
125
|
-
tool_name == "Write"
|
|
126
|
-
and extension == PYTHON_SOURCE_EXTENSION
|
|
127
|
-
and _is_constants_only_python_content(written_content)
|
|
128
|
-
):
|
|
129
|
-
return True
|
|
130
|
-
return _edit_is_constants_or_import_only(tool_name, tool_input, path, extension)
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
def _decide_for_target(
|
|
134
|
-
tool_name: str, tool_input: dict, file_path: str, input_data: dict
|
|
135
|
-
) -> tuple[bool, str] | None:
|
|
136
|
-
"""Return (is_allowed, deny_reason) for one target file, or None to skip silently."""
|
|
137
|
-
if not file_path:
|
|
138
|
-
return None
|
|
139
|
-
if _is_session_scratchpad_write(file_path, input_data):
|
|
140
|
-
return None
|
|
141
|
-
path = Path(file_path)
|
|
142
|
-
extension = path.suffix.lower()
|
|
143
|
-
repository_root = str(input_data.get("cwd") or "")
|
|
144
|
-
if _is_silently_skipped(file_path, extension, path, repository_root):
|
|
145
|
-
return None
|
|
146
|
-
if _write_is_exempt(tool_name, tool_input, path, extension):
|
|
147
|
-
return True, ""
|
|
148
|
-
all_candidates = candidate_test_paths_for(path)
|
|
149
|
-
session_id = str(input_data.get("session_id") or "")
|
|
150
|
-
is_recorded_or_fresh = has_recorded_or_fresh_test(
|
|
151
|
-
all_candidates, session_id, repository_root, _freshness_seconds()
|
|
152
|
-
)
|
|
153
|
-
if is_recorded_or_fresh:
|
|
154
|
-
return True, ""
|
|
155
|
-
return False, build_deny_reason(path, all_candidates)
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
def _decide_apply_patch(input_data: dict, tool_input: dict) -> None:
|
|
159
|
-
"""Run the TDD gate over every file a Codex apply_patch payload touches."""
|
|
160
|
-
for each_file_path in payload_patch_target_paths(input_data, tool_input):
|
|
161
|
-
decision = _decide_for_target(
|
|
162
|
-
APPLY_PATCH_TOOL_NAME, tool_input, each_file_path, input_data
|
|
163
|
-
)
|
|
164
|
-
if decision is None:
|
|
165
|
-
continue
|
|
166
|
-
is_allowed, deny_reason = decision
|
|
167
|
-
if not is_allowed:
|
|
168
|
-
emit_deny(deny_reason)
|
|
169
|
-
return
|
|
170
|
-
emit_allow()
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
def main() -> None:
|
|
174
|
-
"""Run the TDD gate over one PreToolUse Write, Edit, MultiEdit, or apply_patch payload."""
|
|
175
|
-
try:
|
|
176
|
-
input_data = json.load(sys.stdin)
|
|
177
|
-
except json.JSONDecodeError:
|
|
178
|
-
sys.exit(0)
|
|
179
|
-
tool_name, tool_input, file_path = _resolve_payload(input_data)
|
|
180
|
-
if tool_name == APPLY_PATCH_TOOL_NAME:
|
|
181
|
-
_decide_apply_patch(input_data, tool_input)
|
|
182
|
-
sys.exit(0)
|
|
183
|
-
decision = _decide_for_target(tool_name, tool_input, file_path, input_data)
|
|
184
|
-
if decision is None:
|
|
185
|
-
sys.exit(0)
|
|
186
|
-
is_allowed, deny_reason = decision
|
|
187
|
-
if is_allowed:
|
|
188
|
-
emit_allow()
|
|
189
|
-
sys.exit(0)
|
|
190
|
-
emit_deny(deny_reason)
|
|
191
|
-
sys.exit(0)
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
if __name__ == "__main__":
|
|
195
|
-
main()
|