claude-dev-env 8.10.0 → 8.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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.agents-home.test.mjs +76 -1
- package/bin/install.mjs +53 -31
- 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 +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/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
|
@@ -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
|
+
)
|
|
@@ -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:
|