claude-dev-env 8.10.0 → 8.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_regression.py +85 -3
  2. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_regression.py +34 -0
  3. package/_shared/pr-loop/scripts/fix_pr_test_proof.py +132 -0
  4. package/_shared/pr-loop/scripts/pr_loop_shared_constants/fix_pr_test_proof_constants.py +43 -0
  5. package/_shared/pr-loop/scripts/test_fix_pr_test_proof.py +126 -0
  6. package/bin/codex-skill-load-block.mjs +45 -0
  7. package/bin/codex-skill-load-block.test.mjs +72 -0
  8. package/bin/install.agents-home.test.mjs +76 -1
  9. package/bin/install.mjs +53 -31
  10. package/bin/install.test.mjs +37 -0
  11. package/bin/install.transaction.test.mjs +29 -7
  12. package/bin/seed-codex-pstack-models.mjs +27 -0
  13. package/bin/seed-codex-pstack-models.test.mjs +72 -0
  14. package/docs/CODE_RULES.md +5 -1
  15. package/hooks/blocking/code_rules_enforcer.py +1 -3
  16. package/hooks/blocking/code_rules_magic_values.py +1 -3
  17. package/hooks/blocking/code_rules_paired_test.py +5 -6
  18. package/hooks/blocking/tdd_enforcer_parts/__init__.py +1 -1
  19. package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +1 -46
  20. package/hooks/blocking/test_pre_tool_use_dispatcher.py +2 -40
  21. package/hooks/followup_ledger.py +8 -1
  22. package/hooks/git-hooks/test_installed_policy_lint.py +4 -2
  23. package/hooks/hooks.json +15 -0
  24. package/hooks/hooks_constants/followup_ledger_constants.py +4 -0
  25. package/hooks/hooks_constants/messages.py +0 -1
  26. package/hooks/hooks_constants/nested_project_hooks_constants.py +37 -0
  27. package/hooks/hooks_constants/test_messages.py +1 -7
  28. package/hooks/lifecycle/nested_project_hooks.py +395 -0
  29. package/hooks/lifecycle/test_nested_project_hooks.py +251 -0
  30. package/hooks/test_followup_ledger.py +18 -0
  31. package/package.json +2 -1
  32. package/pstack-codex-models.md +37 -0
  33. package/rules/bdd.md +1 -1
  34. package/rules/build-needs-a-user.md +53 -0
  35. package/rules/code-standards.md +1 -1
  36. package/rules/flag-non-breaking-findings.md +7 -0
  37. package/rules/paired-test-coverage.md +4 -2
  38. package/rules/testing.md +5 -1
  39. package/scripts/cde_lint.py +47 -1
  40. package/scripts/policy_lint/adapter_callers.py +124 -0
  41. package/scripts/policy_lint/adapter_pairing.py +4 -2
  42. package/scripts/policy_lint/adapter_support.py +4 -3
  43. package/scripts/policy_lint/adapters.py +15 -0
  44. package/scripts/policy_lint/check_catalog.py +24 -0
  45. package/scripts/policy_lint/config/approved_test_pairs.py +3 -2
  46. package/scripts/policy_lint/config/check_catalog_constants.py +13 -0
  47. package/scripts/policy_lint/config/constants.py +18 -0
  48. package/scripts/policy_lint/engine.py +9 -3
  49. package/scripts/policy_lint/model.py +6 -2
  50. package/scripts/policy_lint/registry.py +6 -0
  51. package/scripts/policy_lint/selection.py +2 -0
  52. package/scripts/tests/test_cde_lint.py +50 -0
  53. package/scripts/tests/test_policy_lint_rules_archive.py +16 -0
  54. package/scripts/tests/test_policy_lint_rules_callers.py +126 -0
  55. package/scripts/tests/test_policy_lint_rules_check_catalog.py +25 -1
  56. package/scripts/tests/test_policy_lint_rules_pairing.py +31 -2
  57. package/scripts/tests/test_policy_lint_selection.py +35 -0
  58. package/hooks/blocking/tdd_enforcer.py +0 -195
  59. package/hooks/blocking/tdd_enforcer_parts/candidate_paths.py +0 -142
  60. package/hooks/blocking/tdd_enforcer_parts/config/__init__.py +0 -1
  61. package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +0 -49
  62. package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +0 -298
  63. package/hooks/blocking/tdd_enforcer_parts/decisions.py +0 -96
  64. package/hooks/blocking/tdd_enforcer_parts/freshness.py +0 -95
  65. package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +0 -132
  66. package/hooks/blocking/tdd_enforcer_parts/path_classification.py +0 -119
  67. package/hooks/blocking/tdd_enforcer_parts/tests/test_candidate_paths.py +0 -31
  68. package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +0 -256
  69. package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +0 -46
  70. package/hooks/blocking/tdd_enforcer_parts/tests/test_freshness.py +0 -28
  71. package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +0 -111
  72. package/hooks/blocking/tdd_enforcer_parts/tests/test_path_classification.py +0 -36
  73. package/hooks/blocking/test_tdd_enforcer.py +0 -1506
  74. package/hooks/blocking/test_tdd_enforcer_restore.py +0 -108
  75. package/hooks/blocking/test_tdd_enforcer_scratchpad.py +0 -105
  76. package/scripts/codex_compat_watcher.py +0 -502
  77. package/scripts/tests/test_codex_compat_watcher.py +0 -599
@@ -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, Severity
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
- Severity.ERROR,
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
- Severity.ERROR,
76
+ severity_for_check_id(check_id),
76
77
  each_message,
77
78
  location,
78
- check_id_for_message(rule_id, each_message),
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
- ".cursor/skills/verify-claude-dev-env/scripts/verify_installer_constants/constants.mjs"
9
+ ".agents/skills/verify/scripts/verify_installer_constants/constants.mjs"
10
10
  ): frozenset(
11
11
  {
12
12
  PurePosixPath(
13
- ".cursor/skills/verify-claude-dev-env/scripts/verify-installer.test.mjs"
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 ARCHIVED_SKILLS_DIRECTORY_NAME
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 path.parts[0] != ARCHIVED_SKILLS_DIRECTORY_NAME
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, one for findings, or three for rule failure.
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 self.diagnostics:
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
- ".cursor/skills/verify-claude-dev-env/scripts/verify_installer_constants/constants.mjs"
201
+ ".agents/skills/verify/scripts/verify_installer_constants/constants.mjs"
190
202
  )
191
203
  suite_path = PurePosixPath(
192
- ".cursor/skills/verify-claude-dev-env/scripts/verify-installer.test.mjs"
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: