claude-dev-env 2.34.0 → 2.36.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 (45) hide show
  1. package/.agents/skills/autoconverge/SKILL.md +19 -15
  2. package/.agents/skills/autoconverge/reference/gotchas.md +3 -3
  3. package/.agents/skills/autoconverge/reference/multi-pr.md +6 -2
  4. package/.agents/skills/autoconverge/workflow/converge.contract.test.mjs +11 -0
  5. package/.agents/skills/autoconverge/workflow/converge.mjs +26 -33
  6. package/.agents/skills/autoconverge/workflow/converge.run-input.test.mjs +26 -2
  7. package/.agents/skills/autoconverge/workflow/converge_multi.mjs +12 -28
  8. package/.agents/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +16 -0
  9. package/bin/cde.mjs +175 -0
  10. package/bin/cde.test.mjs +222 -0
  11. package/bin/install-constants.mjs +4 -0
  12. package/bin/install.cursor-rules.test.mjs +233 -2
  13. package/bin/install.mjs +49 -3
  14. package/bin/pstack-model-preferences.codex.json +23 -0
  15. package/bin/select-pstack-models.test.mjs +378 -0
  16. package/hooks/hooks_constants/tool_names.py +20 -0
  17. package/hooks/validators/fast_save_validators.py +63 -89
  18. package/hooks/validators/run_all_validators.py +16 -8
  19. package/hooks/validators/test_fast_save_validators_policy_lint.py +25 -0
  20. package/hooks/validators/test_run_all_validators_policy_lint.py +52 -0
  21. package/package.json +3 -2
  22. package/rules/pstack-models.md +119 -0
  23. package/scripts/audit-hooks-cli.mjs +54 -0
  24. package/scripts/audit-hooks.mjs +397 -0
  25. package/scripts/cde_lint.py +258 -0
  26. package/scripts/policy_lint/__init__.py +41 -0
  27. package/scripts/policy_lint/adapter_configuration.py +91 -0
  28. package/scripts/policy_lint/adapter_detectors.py +328 -0
  29. package/scripts/policy_lint/adapter_pairing.py +145 -0
  30. package/scripts/policy_lint/adapter_support.py +97 -0
  31. package/scripts/policy_lint/adapters.py +352 -0
  32. package/scripts/policy_lint/config/__init__.py +1 -0
  33. package/scripts/policy_lint/config/constants.py +66 -0
  34. package/scripts/policy_lint/engine.py +224 -0
  35. package/scripts/policy_lint/model.py +387 -0
  36. package/scripts/policy_lint/registry.py +162 -0
  37. package/scripts/policy_lint/render.py +91 -0
  38. package/scripts/policy_lint/selection.py +372 -0
  39. package/scripts/policy_lint/selection_git.py +147 -0
  40. package/scripts/select_pstack_models.mjs +304 -0
  41. package/scripts/tests/test-audit-hooks.mjs +379 -0
  42. package/scripts/tests/test_cde_lint.py +374 -0
  43. package/scripts/tests/test_policy_lint_rules_engine.py +381 -0
  44. package/scripts/tests/test_policy_lint_rules_registry.py +252 -0
  45. package/scripts/tests/test_policy_lint_selection.py +373 -0
@@ -0,0 +1,381 @@
1
+ """Engine and diagnostic adapter tests for policy lint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ import types
7
+ from collections.abc import Callable, Iterable
8
+ from pathlib import Path, PurePosixPath
9
+
10
+ import pytest
11
+ from policy_lint import adapters
12
+ from policy_lint.config.constants import INCOMPLETE_EXIT_CODE
13
+ from policy_lint.engine import lint
14
+ from policy_lint.model import (
15
+ ContentOrigin,
16
+ Diagnostic,
17
+ Document,
18
+ DocumentRule,
19
+ DocumentSet,
20
+ LintReport,
21
+ LintRequest,
22
+ Location,
23
+ SelectionKind,
24
+ Severity,
25
+ )
26
+
27
+ _CHANGED_RULE_SETS = frozenset({"changed"})
28
+ _SAMPLE_SOURCE = "x = 1\n"
29
+
30
+
31
+ def _accepts_every_document(document: Document) -> bool:
32
+ return document.path.as_posix() != ""
33
+
34
+
35
+ def _source_document(
36
+ relative_path: str,
37
+ changed_lines: frozenset[int] | None = None,
38
+ ) -> Document:
39
+ return Document(
40
+ PurePosixPath(relative_path),
41
+ _SAMPLE_SOURCE,
42
+ None,
43
+ changed_lines,
44
+ ContentOrigin.EDITOR,
45
+ )
46
+
47
+
48
+ def _changed_document_rule(
49
+ rule_id: str,
50
+ collect_diagnostics: Callable[[Document, Path], Iterable[Diagnostic]],
51
+ ) -> DocumentRule:
52
+ return DocumentRule(
53
+ rule_id, _CHANGED_RULE_SETS, _accepts_every_document, collect_diagnostics
54
+ )
55
+
56
+
57
+ def _ensure_git_repository(repository_root: Path) -> None:
58
+ git_directory = repository_root / ".git"
59
+ if git_directory.exists():
60
+ return
61
+ subprocess.run(
62
+ ("git", "init"),
63
+ cwd=repository_root,
64
+ check=True,
65
+ capture_output=True,
66
+ )
67
+
68
+
69
+ def _lint_documents(
70
+ repository_root: Path,
71
+ all_documents: tuple[Document, ...],
72
+ all_rules: tuple[DocumentRule, ...],
73
+ ) -> LintReport:
74
+ _ensure_git_repository(repository_root)
75
+ document_set = DocumentSet(all_documents, SelectionKind.TEXT, repository_root)
76
+ request = LintRequest(repository_root, document_set)
77
+ return lint(request, all_registry=all_rules)
78
+
79
+
80
+ def _flaky_diagnostics(document: Document) -> tuple[Diagnostic, ...]:
81
+ if document.path.name == "second.py":
82
+ raise ImportError("detector missing")
83
+ return (
84
+ Diagnostic(
85
+ "flaky",
86
+ Severity.ERROR,
87
+ "partial finding",
88
+ Location(document.path, 2, 1),
89
+ ),
90
+ )
91
+
92
+
93
+ def _stable_diagnostics(document: Document) -> tuple[Diagnostic, ...]:
94
+ return (
95
+ Diagnostic(
96
+ "stable",
97
+ Severity.ERROR,
98
+ "kept finding",
99
+ Location(document.path, 3, 1),
100
+ ),
101
+ )
102
+
103
+
104
+ def test_line_prefix_message_keeps_real_line(
105
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
106
+ ) -> None:
107
+ def fake_hooks_module(module_name: str) -> object:
108
+ if not module_name:
109
+ raise AssertionError("missing module")
110
+ return types.SimpleNamespace(
111
+ validate_content_for_full_gate=lambda *args, **kwargs: [
112
+ "Line 47: comment found"
113
+ ]
114
+ )
115
+
116
+ monkeypatch.setattr(adapters, "_hooks_module", fake_hooks_module)
117
+ all_diagnostics = adapters.code_rule_diagnostics(
118
+ Document.from_text("pkg/mod.py", _SAMPLE_SOURCE), tmp_path
119
+ )
120
+ assert len(all_diagnostics) == 1
121
+ assert all_diagnostics[0].location is not None
122
+ assert all_diagnostics[0].location.start_line == 47
123
+
124
+
125
+ def test_path_line_message_keeps_real_line(
126
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
127
+ ) -> None:
128
+ fake_outcome = types.SimpleNamespace(
129
+ name="ruff",
130
+ passed=False,
131
+ output="C:/Users/example/AppData/Local/Temp/tmp123/pkg/mod.py:47: magic number",
132
+ )
133
+
134
+ def fake_hooks_module(module_name: str) -> object:
135
+ if not module_name:
136
+ raise AssertionError("missing module")
137
+ return types.SimpleNamespace(
138
+ validate_proposed_file=lambda *args, **kwargs: [fake_outcome]
139
+ )
140
+
141
+ monkeypatch.setattr(adapters, "_hooks_module", fake_hooks_module)
142
+ all_diagnostics = adapters.validator_diagnostics(
143
+ Document.from_text("pkg/mod.py", _SAMPLE_SOURCE), tmp_path
144
+ )
145
+ assert len(all_diagnostics) == 1
146
+ assert all_diagnostics[0].location is not None
147
+ assert all_diagnostics[0].location.start_line == 47
148
+ assert all_diagnostics[0].message == "pkg/mod.py:47: magic number"
149
+
150
+
151
+ def test_unknown_line_has_no_location() -> None:
152
+ document = Document.from_text("pkg/mod.py", _SAMPLE_SOURCE)
153
+ all_diagnostics = adapters._diagnostics_for_messages(
154
+ document, "plain-language", ("Banned prose term: landscape",)
155
+ )
156
+ assert len(all_diagnostics) == 1
157
+ assert all_diagnostics[0].location is None
158
+
159
+
160
+ def test_changed_line_filter_keeps_matching_line(tmp_path: Path) -> None:
161
+ repository_root = tmp_path.resolve()
162
+ document = _source_document("pkg/mod.py", changed_lines=frozenset({47}))
163
+
164
+ def collect_diagnostics(
165
+ document: Document, repository_root: Path | None = None
166
+ ) -> tuple[Diagnostic, ...]:
167
+ del repository_root
168
+ return (
169
+ Diagnostic(
170
+ "code-rules",
171
+ Severity.ERROR,
172
+ "Line 47: comment found",
173
+ Location(document.path, 47, 1),
174
+ ),
175
+ )
176
+
177
+ lint_report = _lint_documents(
178
+ repository_root,
179
+ (document,),
180
+ (_changed_document_rule("code-rules", collect_diagnostics),),
181
+ )
182
+ assert len(lint_report.diagnostics) == 1
183
+ assert lint_report.diagnostics[0].location is not None
184
+ assert lint_report.diagnostics[0].location.start_line == 47
185
+
186
+
187
+ def test_changed_line_filter_drops_other_located_line(tmp_path: Path) -> None:
188
+ repository_root = tmp_path.resolve()
189
+ document = _source_document("pkg/mod.py", changed_lines=frozenset({1}))
190
+
191
+ def collect_diagnostics(
192
+ document: Document, repository_root: Path | None = None
193
+ ) -> tuple[Diagnostic, ...]:
194
+ del repository_root
195
+ return (
196
+ Diagnostic(
197
+ "code-rules",
198
+ Severity.ERROR,
199
+ "Line 47: comment found",
200
+ Location(document.path, 47, 1),
201
+ ),
202
+ )
203
+
204
+ lint_report = _lint_documents(
205
+ repository_root,
206
+ (document,),
207
+ (_changed_document_rule("code-rules", collect_diagnostics),),
208
+ )
209
+ assert lint_report.diagnostics == ()
210
+
211
+
212
+ def test_lint_keeps_located_findings_when_changed_lines_are_empty(
213
+ tmp_path: Path,
214
+ ) -> None:
215
+ repository_root = tmp_path.resolve()
216
+ document = _source_document("pkg/mod.py", changed_lines=frozenset())
217
+
218
+ def collect_diagnostics(
219
+ document: Document, repository_root: Path | None = None
220
+ ) -> tuple[Diagnostic, ...]:
221
+ del repository_root
222
+ return (
223
+ Diagnostic(
224
+ "probe",
225
+ Severity.ERROR,
226
+ "finding",
227
+ Location(document.path, 1, 1),
228
+ ),
229
+ )
230
+
231
+ lint_report = _lint_documents(
232
+ repository_root,
233
+ (document,),
234
+ (_changed_document_rule("probe", collect_diagnostics),),
235
+ )
236
+ assert len(lint_report.diagnostics) == 1
237
+
238
+
239
+ def test_code_rule_diagnostics_includes_comment_policy_without_prior_text(
240
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
241
+ ) -> None:
242
+ include_comment_policy: dict[str, bool] = {}
243
+
244
+ def validate_content_for_full_gate(
245
+ *all_arguments: object, **all_keywords: object
246
+ ) -> list[str]:
247
+ del all_arguments
248
+ include_comment_policy["enabled"] = bool(all_keywords["include_comment_policy"])
249
+ return []
250
+
251
+ monkeypatch.setattr(
252
+ adapters,
253
+ "_hooks_module",
254
+ lambda _module_name: types.SimpleNamespace(
255
+ validate_content_for_full_gate=validate_content_for_full_gate
256
+ ),
257
+ )
258
+ adapters.code_rule_diagnostics(
259
+ Document.from_text("pkg/new.py", _SAMPLE_SOURCE), tmp_path
260
+ )
261
+ assert include_comment_policy == {"enabled": True}
262
+
263
+
264
+ def test_code_rule_diagnostics_keeps_legacy_stdout_private(
265
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
266
+ ) -> None:
267
+ def validate_content_for_full_gate(
268
+ *all_arguments: object, **all_keywords: object
269
+ ) -> list[str]:
270
+ del all_arguments, all_keywords
271
+ print("large file advisory")
272
+ return ["Line 2: real diagnostic"]
273
+
274
+ monkeypatch.setattr(
275
+ adapters,
276
+ "_hooks_module",
277
+ lambda _module_name: types.SimpleNamespace(
278
+ validate_content_for_full_gate=validate_content_for_full_gate
279
+ ),
280
+ )
281
+ all_diagnostics = adapters.code_rule_diagnostics(
282
+ Document.from_text("pkg/mod.py", _SAMPLE_SOURCE), tmp_path
283
+ )
284
+ captured_output = capsys.readouterr()
285
+ assert captured_output.out == ""
286
+ assert captured_output.err == ""
287
+ assert [each_diagnostic.message for each_diagnostic in all_diagnostics] == [
288
+ "Line 2: real diagnostic"
289
+ ]
290
+
291
+
292
+ def test_state_description_diagnostics_maps_and_scopes_phrases(
293
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
294
+ ) -> None:
295
+ monkeypatch.setattr(
296
+ adapters,
297
+ "_hooks_module",
298
+ lambda _module_name: types.SimpleNamespace(
299
+ find_violations=lambda _text, _path: ["used to", "replaced by"]
300
+ ),
301
+ )
302
+ source_document = Document(
303
+ PurePosixPath("module.py"),
304
+ "# " + "replaced" + " by unchanged\n# " + "used" + " to changed\n",
305
+ None,
306
+ frozenset({2}),
307
+ ContentOrigin.EDITOR,
308
+ )
309
+ lint_report = _lint_documents(
310
+ tmp_path,
311
+ (source_document,),
312
+ (_changed_document_rule("state-description", adapters.state_description_diagnostics),),
313
+ )
314
+ assert len(lint_report.diagnostics) == 1
315
+ assert lint_report.diagnostics[0].location is not None
316
+ assert lint_report.diagnostics[0].location.start_line == 2
317
+
318
+
319
+ def test_unlocated_finding_survives_changed_line_filter(tmp_path: Path) -> None:
320
+ repository_root = tmp_path.resolve()
321
+ document = _source_document("pkg/mod.py", changed_lines=frozenset({8}))
322
+
323
+ def collect_diagnostics(
324
+ document: Document, repository_root: Path | None = None
325
+ ) -> tuple[Diagnostic, ...]:
326
+ del repository_root
327
+ return (
328
+ Diagnostic(
329
+ "plain-language",
330
+ Severity.ERROR,
331
+ "Banned prose term: landscape",
332
+ None,
333
+ ),
334
+ )
335
+
336
+ lint_report = _lint_documents(
337
+ repository_root,
338
+ (document,),
339
+ (_changed_document_rule("plain-language", collect_diagnostics),),
340
+ )
341
+ assert len(lint_report.diagnostics) == 1
342
+ assert lint_report.diagnostics[0].location is None
343
+
344
+
345
+ def test_failed_adapter_rolls_back_continues_and_exits_incomplete(
346
+ tmp_path: Path,
347
+ ) -> None:
348
+ repository_root = tmp_path.resolve()
349
+ all_documents = (
350
+ _source_document("pkg/first.py"),
351
+ _source_document("pkg/second.py"),
352
+ )
353
+ all_rules = (
354
+ _changed_document_rule("flaky", lambda document, _root: _flaky_diagnostics(document)),
355
+ _changed_document_rule("stable", lambda document, _root: _stable_diagnostics(document)),
356
+ )
357
+ lint_report = _lint_documents(repository_root, all_documents, all_rules)
358
+ assert lint_report.failed_rules == ("flaky",)
359
+ assert lint_report.executed_rules == ("stable",)
360
+ assert lint_report.exit_code == INCOMPLETE_EXIT_CODE
361
+ assert [each_rule.rule_id for each_rule in lint_report.diagnostics] == [
362
+ "stable",
363
+ "stable",
364
+ ]
365
+
366
+
367
+ def test_process_interrupt_is_not_swallowed(tmp_path: Path) -> None:
368
+ repository_root = tmp_path.resolve()
369
+
370
+ def interrupt_diagnostics(
371
+ document: Document, repository_root: Path | None = None
372
+ ) -> tuple[Diagnostic, ...]:
373
+ del document, repository_root
374
+ raise KeyboardInterrupt
375
+
376
+ with pytest.raises(KeyboardInterrupt):
377
+ _lint_documents(
378
+ repository_root,
379
+ (_source_document("pkg/mod.py"),),
380
+ (_changed_document_rule("interrupt", interrupt_diagnostics),),
381
+ )
@@ -0,0 +1,252 @@
1
+ """Registry, grouping, and configuration tests for policy lint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import subprocess
7
+ import types
8
+ from pathlib import Path, PurePosixPath
9
+
10
+ import pytest
11
+ from policy_lint import adapters, registry
12
+ from policy_lint.engine import lint
13
+ from policy_lint.model import (
14
+ Document,
15
+ DocumentRule,
16
+ DocumentSet,
17
+ LintRequest,
18
+ SelectionKind,
19
+ )
20
+
21
+ _FUNCTION_SOURCE = "def work() -> None:\n pass\n"
22
+ _CHANGED_RULE_SETS = frozenset({"changed"})
23
+
24
+
25
+ def _rule_named(rule_id: str) -> DocumentRule:
26
+ for each_rule in registry.default_registry():
27
+ if each_rule.rule_id == rule_id and isinstance(each_rule, DocumentRule):
28
+ return each_rule
29
+ raise AssertionError(rule_id)
30
+
31
+
32
+ def _ensure_git_repository(repository_root: Path) -> None:
33
+ git_directory = repository_root / ".git"
34
+ if git_directory.exists():
35
+ return
36
+ subprocess.run(
37
+ ("git", "init"),
38
+ cwd=repository_root,
39
+ check=True,
40
+ capture_output=True,
41
+ )
42
+
43
+
44
+ def test_hook_format_accepts_claude_settings() -> None:
45
+ settings_document = Document.from_text(".claude/settings.json", "{}")
46
+ hooks_document = Document.from_text("hooks/hooks.json", "{}")
47
+ format_rule = _rule_named("hook-format")
48
+ configuration_rule = _rule_named("hook-configuration")
49
+ assert format_rule.accepts(settings_document) is True
50
+ assert format_rule.accepts(hooks_document) is False
51
+ assert configuration_rule.accepts(hooks_document) is True
52
+ assert configuration_rule.accepts(settings_document) is False
53
+
54
+
55
+ def test_workflow_substitution_accepts_workflow_js_suffix() -> None:
56
+ workflow_document = Document.from_text("packages/foo/bar.workflow.js", "loop")
57
+ directory_document = Document.from_text("docs/workflow/readme.md", "loop")
58
+ assert adapters.accepts_workflow(workflow_document) is True
59
+ assert adapters.accepts_workflow(directory_document) is False
60
+
61
+
62
+ def test_open_questions_cover_repository_and_claude_plans() -> None:
63
+ docs_plan = Document.from_text("docs/plans/packet.md", "# Plan\n")
64
+ claude_plan = Document.from_text(".claude/plans/packet.md", "# Plan\n")
65
+ other_markdown = Document.from_text("docs/notes.md", "# Notes\n")
66
+ assert adapters.accepts_plans(docs_plan) is True
67
+ assert adapters.accepts_plans(claude_plan) is True
68
+ assert adapters.accepts_plans(other_markdown) is False
69
+
70
+
71
+ def test_open_question_diagnostics_scan_claude_plans(
72
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
73
+ ) -> None:
74
+ def fake_hooks_module(module_name: str) -> object:
75
+ if not module_name:
76
+ raise AssertionError("missing module")
77
+ return types.SimpleNamespace(_content_has_open_questions=lambda text: True)
78
+
79
+ monkeypatch.setattr(adapters, "_hooks_module", fake_hooks_module)
80
+ all_diagnostics = adapters.open_question_diagnostics(
81
+ Document.from_text(".claude/plans/packet.md", "## Open Questions\n"),
82
+ tmp_path,
83
+ )
84
+ assert len(all_diagnostics) == 1
85
+ assert all_diagnostics[0].rule_id == "open-questions"
86
+
87
+
88
+ def test_test_pairing_diagnostics_reports_unmatched_changed_production_file(
89
+ tmp_path: Path,
90
+ ) -> None:
91
+ document_set = DocumentSet(
92
+ (Document.from_text("src/feature.py", _FUNCTION_SOURCE),),
93
+ SelectionKind.STAGED,
94
+ tmp_path,
95
+ )
96
+ all_diagnostics = adapters.test_pairing_diagnostics(document_set)
97
+ assert len(all_diagnostics) == 1
98
+ assert all_diagnostics[0].location is not None
99
+ assert all_diagnostics[0].location.path == PurePosixPath("src/feature.py")
100
+
101
+
102
+ def test_test_pairing_diagnostics_exempts_constants_only_python_modules(
103
+ tmp_path: Path,
104
+ ) -> None:
105
+ document_set = DocumentSet(
106
+ (
107
+ Document.from_text(
108
+ "hooks_constants/tool_names.py",
109
+ "TOOL_NAME: str = \"tool\"\n",
110
+ ),
111
+ ),
112
+ SelectionKind.STAGED,
113
+ tmp_path,
114
+ )
115
+ assert adapters.test_pairing_diagnostics(document_set) == ()
116
+
117
+
118
+ def test_test_pairing_diagnostics_accepts_changed_matching_test(tmp_path: Path) -> None:
119
+ document_set = DocumentSet(
120
+ (
121
+ Document.from_text("src/feature.py", _FUNCTION_SOURCE),
122
+ Document.from_text("tests/test_feature.py", _FUNCTION_SOURCE),
123
+ ),
124
+ SelectionKind.BASE,
125
+ tmp_path,
126
+ )
127
+ assert adapters.test_pairing_diagnostics(document_set) == ()
128
+
129
+
130
+ @pytest.mark.parametrize(
131
+ ("production_path", "test_path"),
132
+ (
133
+ ("scripts/policy_lint/adapters.py", "scripts/tests/test_policy_lint_rules.py"),
134
+ (
135
+ "scripts/policy_lint/selection.py",
136
+ "scripts/tests/test_policy_lint_selection.py",
137
+ ),
138
+ (
139
+ "hooks/validators/run_all_validators.py",
140
+ "hooks/validators/test_run_all_validators_integration.py",
141
+ ),
142
+ ("bin/install-plan.mjs", "bin/install.plan.test.mjs"),
143
+ ),
144
+ )
145
+ def test_test_pairing_diagnostics_accepts_grouped_families(
146
+ tmp_path: Path, production_path: str, test_path: str
147
+ ) -> None:
148
+ document_set = DocumentSet(
149
+ (
150
+ Document.from_text(production_path, _FUNCTION_SOURCE),
151
+ Document.from_text(test_path, _FUNCTION_SOURCE),
152
+ ),
153
+ SelectionKind.STAGED,
154
+ tmp_path,
155
+ )
156
+ assert adapters.test_pairing_diagnostics(document_set) == ()
157
+
158
+
159
+ def test_test_pairing_diagnostics_rejects_a_different_family(tmp_path: Path) -> None:
160
+ document_set = DocumentSet(
161
+ (
162
+ Document.from_text("src/feature.py", _FUNCTION_SOURCE),
163
+ Document.from_text("tests/test_feature_helper.py", _FUNCTION_SOURCE),
164
+ ),
165
+ SelectionKind.STAGED,
166
+ tmp_path,
167
+ )
168
+ all_diagnostics = adapters.test_pairing_diagnostics(document_set)
169
+ assert len(all_diagnostics) == 1
170
+
171
+
172
+ def test_test_pairing_diagnostics_recognizes_spec_test_paths(tmp_path: Path) -> None:
173
+ document_set = DocumentSet(
174
+ (
175
+ Document.from_text("src/widget.ts", _FUNCTION_SOURCE),
176
+ Document.from_text("tests/widget.spec.ts", _FUNCTION_SOURCE),
177
+ ),
178
+ SelectionKind.STAGED,
179
+ tmp_path,
180
+ )
181
+ assert adapters.test_pairing_diagnostics(document_set) == ()
182
+
183
+
184
+ def test_hook_configuration_diagnostics_rejects_registered_action_boundary_path(
185
+ tmp_path: Path,
186
+ ) -> None:
187
+ configuration_document = Document.from_text(
188
+ "hooks/hooks.json",
189
+ json.dumps(
190
+ {
191
+ "hooks": {
192
+ "PreToolUse": [
193
+ {"hooks": [{"command": "hooks/blocking/deny.py"}]}
194
+ ]
195
+ }
196
+ }
197
+ ),
198
+ )
199
+ all_diagnostics = adapters.hook_configuration_diagnostics(
200
+ configuration_document, tmp_path
201
+ )
202
+ assert len(all_diagnostics) == 1
203
+ assert all_diagnostics[0].rule_id == "hook-configuration"
204
+
205
+
206
+ def test_hook_configuration_diagnostics_ignore_description_text(tmp_path: Path) -> None:
207
+ configuration_document = Document.from_text(
208
+ "hooks/hooks.json",
209
+ json.dumps({"description": "blocking safety controls", "hooks": {}}),
210
+ )
211
+ assert adapters.hook_configuration_diagnostics(configuration_document, tmp_path) == ()
212
+
213
+
214
+ def test_hook_configuration_diagnostics_ignore_task_path(tmp_path: Path) -> None:
215
+ configuration_document = Document.from_text(
216
+ "hooks/hooks.json",
217
+ json.dumps({"hooks": {"PostToolUse": [{"command": "task_runner.py"}]}}),
218
+ )
219
+ assert adapters.hook_configuration_diagnostics(configuration_document, tmp_path) == ()
220
+
221
+
222
+ def test_registry_omits_native_ruff_mypy_javascript_adapters() -> None:
223
+ all_rule_ids = {each_rule.rule_id for each_rule in registry.default_registry()}
224
+ assert "ruff" not in all_rule_ids
225
+ assert "mypy" not in all_rule_ids
226
+ assert "eslint" not in all_rule_ids
227
+ assert "javascript" not in all_rule_ids
228
+
229
+
230
+ def test_registry_registers_unique_python_validator_rule() -> None:
231
+ validator_rule = _rule_named("validators")
232
+ assert validator_rule.accepts(Document.from_text("module.py", _FUNCTION_SOURCE))
233
+ assert not validator_rule.accepts(Document.from_text("module.ts", _FUNCTION_SOURCE))
234
+
235
+
236
+ def test_lint_keeps_skipped_rules_out_of_executed_rules(tmp_path: Path) -> None:
237
+ skipped_rule = DocumentRule(
238
+ "skipped",
239
+ _CHANGED_RULE_SETS,
240
+ lambda _document: False,
241
+ lambda _document, _root: (),
242
+ )
243
+ _ensure_git_repository(tmp_path)
244
+ document_set = DocumentSet(
245
+ (Document.from_text("pkg/mod.py", "x = 1\n"),),
246
+ SelectionKind.TEXT,
247
+ tmp_path,
248
+ )
249
+ request = LintRequest(tmp_path, document_set)
250
+ all_rules_report = lint(request, all_registry=(skipped_rule,))
251
+ assert all_rules_report.executed_rules == ()
252
+ assert all_rules_report.skipped_rules == ("skipped",)