claude-dev-env 8.9.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.
- 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 +132 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/fix_pr_test_proof_constants.py +43 -0
- package/_shared/pr-loop/scripts/test_fix_pr_test_proof.py +126 -0
- package/bin/codex-skill-load-block.mjs +45 -0
- package/bin/codex-skill-load-block.test.mjs +72 -0
- package/bin/install-constants.mjs +5 -0
- package/bin/install.agents-home.test.mjs +76 -1
- package/bin/install.cursor-rules.test.mjs +6 -0
- package/bin/install.mjs +55 -33
- package/bin/install.test.mjs +37 -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/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/followup_ledger.py +8 -1
- package/hooks/git-hooks/test_installed_policy_lint.py +4 -2
- package/hooks/hooks.json +27 -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/skill_loaded_reminder_constants.py +16 -4
- 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/session/skill_loaded_reminder.py +28 -13
- package/hooks/session/test_skill_loaded_reminder.py +49 -2
- 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/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 +126 -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/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
package/scripts/cde_lint.py
CHANGED
|
@@ -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
|
|
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
|
+
)
|
|
@@ -5,9 +5,10 @@ from collections import Counter
|
|
|
5
5
|
from pathlib import PurePosixPath
|
|
6
6
|
|
|
7
7
|
from . import adapter_support
|
|
8
|
+
from .check_catalog import severity_for_check_id
|
|
8
9
|
from .config import constants
|
|
9
10
|
from .config.approved_test_pairs import APPROVED_TEST_PATHS_BY_PRODUCTION_PATH
|
|
10
|
-
from .model import Diagnostic, Document, DocumentSet, Location, SelectionKind
|
|
11
|
+
from .model import Diagnostic, Document, DocumentSet, Location, SelectionKind
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
def _is_test_path(path: PurePosixPath) -> bool:
|
|
@@ -319,9 +320,10 @@ def _changed_test_paths(document_set: DocumentSet) -> frozenset[PurePosixPath]:
|
|
|
319
320
|
def _pairing_diagnostic(document: Document) -> Diagnostic:
|
|
320
321
|
return Diagnostic(
|
|
321
322
|
"test-pairing",
|
|
322
|
-
|
|
323
|
+
severity_for_check_id("test-pairing"),
|
|
323
324
|
"Changed production file has no changed matching test",
|
|
324
325
|
Location(document.path, 1, 1),
|
|
326
|
+
"test-pairing",
|
|
325
327
|
)
|
|
326
328
|
|
|
327
329
|
|
|
@@ -4,7 +4,7 @@ from collections.abc import Callable, Iterable
|
|
|
4
4
|
from pathlib import Path
|
|
5
5
|
from types import ModuleType
|
|
6
6
|
|
|
7
|
-
from .check_catalog import check_id_for_message
|
|
7
|
+
from .check_catalog import check_id_for_message, severity_for_check_id
|
|
8
8
|
from .model import Diagnostic, Document, Location, Severity
|
|
9
9
|
|
|
10
10
|
HookModuleLoader = Callable[[str], ModuleType]
|
|
@@ -69,13 +69,14 @@ def _diagnostics_for_messages(
|
|
|
69
69
|
location = (
|
|
70
70
|
None if maybe_line is None else Location(document.path, maybe_line, 1)
|
|
71
71
|
)
|
|
72
|
+
check_id = check_id_for_message(rule_id, each_message)
|
|
72
73
|
all_diagnostics.append(
|
|
73
74
|
Diagnostic(
|
|
74
75
|
rule_id,
|
|
75
|
-
|
|
76
|
+
severity_for_check_id(check_id),
|
|
76
77
|
each_message,
|
|
77
78
|
location,
|
|
78
|
-
|
|
79
|
+
check_id,
|
|
79
80
|
)
|
|
80
81
|
)
|
|
81
82
|
return tuple(all_diagnostics)
|
|
@@ -9,6 +9,7 @@ from types import ModuleType
|
|
|
9
9
|
from shared_tree_paths import resolve_shared_scripts_directory
|
|
10
10
|
|
|
11
11
|
from . import (
|
|
12
|
+
adapter_callers,
|
|
12
13
|
adapter_configuration,
|
|
13
14
|
adapter_contrast_framing,
|
|
14
15
|
adapter_detectors,
|
|
@@ -245,6 +246,20 @@ def test_pairing_diagnostics(document_set: DocumentSet) -> tuple[Diagnostic, ...
|
|
|
245
246
|
return adapter_pairing.test_pairing_diagnostics(document_set, _hooks_module)
|
|
246
247
|
|
|
247
248
|
|
|
249
|
+
def uncalled_new_file_diagnostics(
|
|
250
|
+
document_set: DocumentSet,
|
|
251
|
+
) -> tuple[Diagnostic, ...]:
|
|
252
|
+
"""Report new code files that nothing outside their tests calls.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
document_set: Staged or base change selection.
|
|
256
|
+
|
|
257
|
+
Returns:
|
|
258
|
+
One diagnostic for each uncalled new file.
|
|
259
|
+
"""
|
|
260
|
+
return adapter_callers.uncalled_new_file_diagnostics(document_set)
|
|
261
|
+
|
|
262
|
+
|
|
248
263
|
def terminology_diagnostics(document_set: DocumentSet) -> tuple[Diagnostic, ...]:
|
|
249
264
|
"""Report staged prose that near-misses a newly introduced identifier.
|
|
250
265
|
|
|
@@ -21,8 +21,12 @@ from .config.check_catalog_constants import (
|
|
|
21
21
|
ALL_BUNDLING_RULE_IDS,
|
|
22
22
|
ALL_CHECK_CATALOG_ENTRIES,
|
|
23
23
|
CHECK_ID_SEPARATOR,
|
|
24
|
+
SEVERITY_BREAKING,
|
|
25
|
+
SEVERITY_BY_CHECK_ID,
|
|
26
|
+
SEVERITY_SMELL,
|
|
24
27
|
UNCLASSIFIED_CHECK_NAME,
|
|
25
28
|
)
|
|
29
|
+
from .model import Severity
|
|
26
30
|
|
|
27
31
|
|
|
28
32
|
def check_id_for_message(rule_id: str, message: str) -> str:
|
|
@@ -42,3 +46,23 @@ def check_id_for_message(rule_id: str, message: str) -> str:
|
|
|
42
46
|
if each_entry.message_marker in message:
|
|
43
47
|
return rule_id + CHECK_ID_SEPARATOR + each_entry.check_name
|
|
44
48
|
return rule_id + CHECK_ID_SEPARATOR + UNCLASSIFIED_CHECK_NAME
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def severity_for_check_id(check_id: str) -> Severity:
|
|
52
|
+
"""Return the severity ``SEVERITY_BY_CHECK_ID`` declares for one check.
|
|
53
|
+
|
|
54
|
+
::
|
|
55
|
+
|
|
56
|
+
"code-rules/paired-test-missing-function" -> Severity.WARNING
|
|
57
|
+
"code-rules/unclassified" -> Severity.ERROR
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
check_id: The identifier ``check_id_for_message`` returned.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
``Severity.WARNING`` for a smell, which the lint records and passes,
|
|
64
|
+
and ``Severity.ERROR`` for every other check.
|
|
65
|
+
"""
|
|
66
|
+
if SEVERITY_BY_CHECK_ID.get(check_id, SEVERITY_BREAKING) == SEVERITY_SMELL:
|
|
67
|
+
return Severity.WARNING
|
|
68
|
+
return Severity.ERROR
|
|
@@ -6,11 +6,11 @@ APPROVED_TEST_PATHS_BY_PRODUCTION_PATH: dict[
|
|
|
6
6
|
PurePosixPath, frozenset[PurePosixPath]
|
|
7
7
|
] = {
|
|
8
8
|
PurePosixPath(
|
|
9
|
-
".
|
|
9
|
+
".agents/skills/verify/scripts/verify_installer_constants/constants.mjs"
|
|
10
10
|
): frozenset(
|
|
11
11
|
{
|
|
12
12
|
PurePosixPath(
|
|
13
|
-
".
|
|
13
|
+
".agents/skills/verify/scripts/verify-installer.test.mjs"
|
|
14
14
|
),
|
|
15
15
|
}
|
|
16
16
|
),
|
|
@@ -21,6 +21,7 @@ APPROVED_TEST_PATHS_BY_PRODUCTION_PATH: dict[
|
|
|
21
21
|
),
|
|
22
22
|
PurePosixPath("packages/claude-dev-env/bin/install.mjs"): frozenset(
|
|
23
23
|
{
|
|
24
|
+
PurePosixPath("packages/claude-dev-env/bin/install.agents-home.test.mjs"),
|
|
24
25
|
PurePosixPath("packages/claude-dev-env/bin/install.cursor-rules.test.mjs"),
|
|
25
26
|
}
|
|
26
27
|
),
|
|
@@ -56,4 +56,17 @@ ALL_CHECK_CATALOG_ENTRIES: tuple[CheckCatalogEntry, ...] = (
|
|
|
56
56
|
"missing type annotation (CODE_RULES §6)", "missing-type-annotation"
|
|
57
57
|
),
|
|
58
58
|
CheckCatalogEntry("Single-letter variable", "single-letter-variable"),
|
|
59
|
+
CheckCatalogEntry(
|
|
60
|
+
"is exercised by no test in the module's paired test suite",
|
|
61
|
+
"paired-test-missing-function",
|
|
62
|
+
),
|
|
63
|
+
CheckCatalogEntry("but exercises nowhere", "paired-test-omitted-function"),
|
|
59
64
|
)
|
|
65
|
+
|
|
66
|
+
SEVERITY_BREAKING: str = "breaking"
|
|
67
|
+
SEVERITY_SMELL: str = "smell"
|
|
68
|
+
SEVERITY_BY_CHECK_ID: dict[str, str] = {
|
|
69
|
+
"code-rules/paired-test-missing-function": SEVERITY_SMELL,
|
|
70
|
+
"code-rules/paired-test-omitted-function": SEVERITY_SMELL,
|
|
71
|
+
"test-pairing": SEVERITY_SMELL,
|
|
72
|
+
}
|
|
@@ -6,6 +6,7 @@ GIT_EXECUTABLE = "git"
|
|
|
6
6
|
UTF8_ENCODING = "utf-8"
|
|
7
7
|
PATH_SEPARATOR = "/"
|
|
8
8
|
ARCHIVED_SKILLS_DIRECTORY_NAME = "skill-archive"
|
|
9
|
+
ARCHIVED_HOOKS_DIRECTORY_NAME = "hooks-archived"
|
|
9
10
|
NUL_BYTE = b"\0"
|
|
10
11
|
ALL_GIT_ROOT_ARGUMENTS = ("rev-parse", "--show-toplevel")
|
|
11
12
|
ALL_GIT_HEAD_ARGUMENTS = ("rev-parse", "--verify", "HEAD")
|
|
@@ -20,6 +21,7 @@ ALL_MISSING_BLOB_ERROR_FRAGMENTS = (
|
|
|
20
21
|
"does not exist in",
|
|
21
22
|
"does not exist (at stage",
|
|
22
23
|
"exists on disk, but not in",
|
|
24
|
+
": bad file",
|
|
23
25
|
)
|
|
24
26
|
GIT_DIFF_ARGUMENT = "diff"
|
|
25
27
|
GIT_CACHED_FLAG = "--cached"
|
|
@@ -170,3 +172,19 @@ RETIRED_HOOK_PROSE_MESSAGE = (
|
|
|
170
172
|
)
|
|
171
173
|
|
|
172
174
|
CONTRAST_FRAMING_RULE_ID = "contrast-framing"
|
|
175
|
+
|
|
176
|
+
UNCALLED_NEW_FILE_RULE_ID = "uncalled-new-file"
|
|
177
|
+
UNCALLED_NEW_FILE_MESSAGE = (
|
|
178
|
+
"New file {file_name} has no caller outside its tests. Wire it into a hook,"
|
|
179
|
+
" workflow, skill, command or import in this change, or drop it."
|
|
180
|
+
)
|
|
181
|
+
ALL_CALLED_CODE_SUFFIXES = frozenset({".py", ".mjs", ".js", ".sh", ".ps1"})
|
|
182
|
+
ALL_CALLED_CODE_SEGMENTS = ("/scripts/", "/hooks/", "/bin/")
|
|
183
|
+
ALL_CALLER_SEARCH_SKIPPED_DIRECTORIES = frozenset(
|
|
184
|
+
{".git", "node_modules", "__pycache__", ".venv"}
|
|
185
|
+
)
|
|
186
|
+
ALL_INVENTORY_FILE_NAMES = frozenset(
|
|
187
|
+
{"CHANGELOG.md", "README.md", "ever-shipped-skills.mjs"}
|
|
188
|
+
)
|
|
189
|
+
ALL_UNCALLED_EXEMPT_FILE_NAMES = frozenset({"__init__.py", "conftest.py"})
|
|
190
|
+
CALLER_NAME_PATTERN_TEMPLATE = r"(?<![\w-]){name}(?![\w-])"
|
|
@@ -3,7 +3,10 @@ from __future__ import annotations
|
|
|
3
3
|
from collections.abc import Iterable, Sequence
|
|
4
4
|
from pathlib import Path, PurePosixPath
|
|
5
5
|
|
|
6
|
-
from .config.constants import
|
|
6
|
+
from .config.constants import (
|
|
7
|
+
ARCHIVED_HOOKS_DIRECTORY_NAME,
|
|
8
|
+
ARCHIVED_SKILLS_DIRECTORY_NAME,
|
|
9
|
+
)
|
|
7
10
|
from .model import (
|
|
8
11
|
ChangeSetRule,
|
|
9
12
|
Diagnostic,
|
|
@@ -27,7 +30,7 @@ def lint(
|
|
|
27
30
|
"""Run the selected rules on one source selection.
|
|
28
31
|
|
|
29
32
|
Repository and diff selections check active code. File and editor
|
|
30
|
-
selections include archived skills.
|
|
33
|
+
selections include archived skills and archived hooks.
|
|
31
34
|
|
|
32
35
|
Args:
|
|
33
36
|
request: Repository root, source, and rule sets.
|
|
@@ -55,7 +58,10 @@ def lint(
|
|
|
55
58
|
|
|
56
59
|
|
|
57
60
|
def _is_runtime_path(path: PurePosixPath) -> bool:
|
|
58
|
-
return
|
|
61
|
+
return (
|
|
62
|
+
path.parts[0] != ARCHIVED_SKILLS_DIRECTORY_NAME
|
|
63
|
+
and ARCHIVED_HOOKS_DIRECTORY_NAME not in path.parts
|
|
64
|
+
)
|
|
59
65
|
|
|
60
66
|
|
|
61
67
|
def _runtime_document_set(document_set: DocumentSet) -> DocumentSet:
|
|
@@ -341,11 +341,15 @@ class LintReport:
|
|
|
341
341
|
"""Return the process exit code for this report.
|
|
342
342
|
|
|
343
343
|
Returns:
|
|
344
|
-
Zero for clean output
|
|
344
|
+
Zero for clean output or warnings only, one for an error finding,
|
|
345
|
+
or three for rule failure.
|
|
345
346
|
"""
|
|
346
347
|
if self.failed_rules:
|
|
347
348
|
return INCOMPLETE_EXIT_CODE
|
|
348
|
-
if
|
|
349
|
+
if any(
|
|
350
|
+
each_diagnostic.severity is Severity.ERROR
|
|
351
|
+
for each_diagnostic in self.diagnostics
|
|
352
|
+
):
|
|
349
353
|
return 1
|
|
350
354
|
return 0
|
|
351
355
|
|
|
@@ -154,6 +154,12 @@ def default_registry() -> tuple[model.Rule, ...]:
|
|
|
154
154
|
frozenset({model.SelectionKind.STAGED, model.SelectionKind.BASE}),
|
|
155
155
|
adapters.terminology_diagnostics,
|
|
156
156
|
),
|
|
157
|
+
model.ChangeSetRule(
|
|
158
|
+
"uncalled-new-file",
|
|
159
|
+
frozenset({"changed"}),
|
|
160
|
+
frozenset({model.SelectionKind.STAGED, model.SelectionKind.BASE}),
|
|
161
|
+
adapters.uncalled_new_file_diagnostics,
|
|
162
|
+
),
|
|
157
163
|
model.RepositoryRule(
|
|
158
164
|
"repository-path-collision",
|
|
159
165
|
frozenset({"repository"}),
|
|
@@ -205,6 +205,8 @@ def _append_change(
|
|
|
205
205
|
all_renamed_paths: list[tuple[PurePosixPath, PurePosixPath]],
|
|
206
206
|
) -> None:
|
|
207
207
|
old_path, new_path = all_paths if status[:1] in {"R", "C"} else (all_paths[0], all_paths[0])
|
|
208
|
+
if status != "D" and (repository_root / new_path).is_symlink():
|
|
209
|
+
return
|
|
208
210
|
normalized_old_path = _normalize_path(repository_root, old_path)
|
|
209
211
|
normalized_new_path = _normalize_path(repository_root, new_path)
|
|
210
212
|
if status == "D":
|
|
@@ -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,126 @@
|
|
|
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
|
+
)
|