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.
Files changed (82) 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-constants.mjs +5 -0
  9. package/bin/install.agents-home.test.mjs +76 -1
  10. package/bin/install.cursor-rules.test.mjs +6 -0
  11. package/bin/install.mjs +55 -33
  12. package/bin/install.test.mjs +37 -0
  13. package/bin/install.transaction.test.mjs +29 -7
  14. package/bin/seed-codex-pstack-models.mjs +27 -0
  15. package/bin/seed-codex-pstack-models.test.mjs +72 -0
  16. package/docs/CODE_RULES.md +5 -1
  17. package/hooks/blocking/code_rules_enforcer.py +1 -3
  18. package/hooks/blocking/code_rules_magic_values.py +1 -3
  19. package/hooks/blocking/code_rules_paired_test.py +5 -6
  20. package/hooks/blocking/tdd_enforcer_parts/__init__.py +1 -1
  21. package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +1 -46
  22. package/hooks/blocking/test_pre_tool_use_dispatcher.py +2 -40
  23. package/hooks/followup_ledger.py +8 -1
  24. package/hooks/git-hooks/test_installed_policy_lint.py +4 -2
  25. package/hooks/hooks.json +27 -0
  26. package/hooks/hooks_constants/followup_ledger_constants.py +4 -0
  27. package/hooks/hooks_constants/messages.py +0 -1
  28. package/hooks/hooks_constants/nested_project_hooks_constants.py +37 -0
  29. package/hooks/hooks_constants/skill_loaded_reminder_constants.py +16 -4
  30. package/hooks/hooks_constants/test_messages.py +1 -7
  31. package/hooks/lifecycle/nested_project_hooks.py +395 -0
  32. package/hooks/lifecycle/test_nested_project_hooks.py +251 -0
  33. package/hooks/session/skill_loaded_reminder.py +28 -13
  34. package/hooks/session/test_skill_loaded_reminder.py +49 -2
  35. package/hooks/test_followup_ledger.py +18 -0
  36. package/package.json +2 -1
  37. package/pstack-codex-models.md +37 -0
  38. package/rules/bdd.md +1 -1
  39. package/rules/build-needs-a-user.md +53 -0
  40. package/rules/code-standards.md +1 -1
  41. package/rules/flag-non-breaking-findings.md +7 -0
  42. package/rules/paired-test-coverage.md +4 -2
  43. package/rules/testing.md +5 -1
  44. package/scripts/cde_lint.py +47 -1
  45. package/scripts/policy_lint/adapter_callers.py +124 -0
  46. package/scripts/policy_lint/adapter_pairing.py +4 -2
  47. package/scripts/policy_lint/adapter_support.py +4 -3
  48. package/scripts/policy_lint/adapters.py +15 -0
  49. package/scripts/policy_lint/check_catalog.py +24 -0
  50. package/scripts/policy_lint/config/approved_test_pairs.py +3 -2
  51. package/scripts/policy_lint/config/check_catalog_constants.py +13 -0
  52. package/scripts/policy_lint/config/constants.py +18 -0
  53. package/scripts/policy_lint/engine.py +9 -3
  54. package/scripts/policy_lint/model.py +6 -2
  55. package/scripts/policy_lint/registry.py +6 -0
  56. package/scripts/policy_lint/selection.py +2 -0
  57. package/scripts/tests/test_cde_lint.py +50 -0
  58. package/scripts/tests/test_policy_lint_rules_archive.py +16 -0
  59. package/scripts/tests/test_policy_lint_rules_callers.py +126 -0
  60. package/scripts/tests/test_policy_lint_rules_check_catalog.py +25 -1
  61. package/scripts/tests/test_policy_lint_rules_pairing.py +31 -2
  62. package/scripts/tests/test_policy_lint_selection.py +35 -0
  63. package/hooks/blocking/tdd_enforcer.py +0 -195
  64. package/hooks/blocking/tdd_enforcer_parts/candidate_paths.py +0 -142
  65. package/hooks/blocking/tdd_enforcer_parts/config/__init__.py +0 -1
  66. package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +0 -49
  67. package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +0 -298
  68. package/hooks/blocking/tdd_enforcer_parts/decisions.py +0 -96
  69. package/hooks/blocking/tdd_enforcer_parts/freshness.py +0 -95
  70. package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +0 -132
  71. package/hooks/blocking/tdd_enforcer_parts/path_classification.py +0 -119
  72. package/hooks/blocking/tdd_enforcer_parts/tests/test_candidate_paths.py +0 -31
  73. package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +0 -256
  74. package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +0 -46
  75. package/hooks/blocking/tdd_enforcer_parts/tests/test_freshness.py +0 -28
  76. package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +0 -111
  77. package/hooks/blocking/tdd_enforcer_parts/tests/test_path_classification.py +0 -36
  78. package/hooks/blocking/test_tdd_enforcer.py +0 -1506
  79. package/hooks/blocking/test_tdd_enforcer_restore.py +0 -108
  80. package/hooks/blocking/test_tdd_enforcer_scratchpad.py +0 -105
  81. package/scripts/codex_compat_watcher.py +0 -502
  82. package/scripts/tests/test_codex_compat_watcher.py +0 -599
@@ -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 LintReport, LintRequest, SelectionKind, TextDocument
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, 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
+ )