claude-dev-env 2.35.0 → 4.0.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 (99) hide show
  1. package/.agents/skills/_shared/advisor/scripts/README.md +1 -1
  2. package/.agents/skills/autoconverge/SKILL.md +19 -15
  3. package/.agents/skills/autoconverge/reference/gotchas.md +3 -3
  4. package/.agents/skills/autoconverge/reference/multi-pr.md +6 -2
  5. package/.agents/skills/autoconverge/workflow/converge.contract.test.mjs +11 -0
  6. package/.agents/skills/autoconverge/workflow/converge.mjs +26 -33
  7. package/.agents/skills/autoconverge/workflow/converge.run-input.test.mjs +26 -2
  8. package/.agents/skills/autoconverge/workflow/converge_multi.mjs +12 -28
  9. package/.agents/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +16 -0
  10. package/.agents/skills/team-advisor/SKILL.md +11 -11
  11. package/_shared/advisor/advisor-protocol.md +51 -51
  12. package/_shared/advisor/reference/advisor-block.md +9 -9
  13. package/_shared/advisor/reference/astra-rung.md +39 -0
  14. package/_shared/advisor/reference/identity.md +4 -4
  15. package/_shared/advisor/reference/lifecycle.md +6 -6
  16. package/_shared/advisor/reference/spawn-walk-log.md +9 -9
  17. package/_shared/advisor/reference/third-party-bind.md +11 -11
  18. package/_shared/advisor/reference/warm-up.md +13 -13
  19. package/_shared/advisor/scripts/codex_astra_advisor.py +308 -0
  20. package/_shared/advisor/scripts/codex_astra_preflight.py +115 -0
  21. package/_shared/advisor/scripts/codex_astra_reply.py +159 -0
  22. package/_shared/advisor/scripts/config/advisor_scripts_constants/advisor_route_constants.py +3 -3
  23. package/_shared/advisor/scripts/config/advisor_scripts_constants/astra_advisor_constants.py +38 -0
  24. package/_shared/advisor/scripts/model_tier_run_validator.py +17 -18
  25. package/_shared/advisor/scripts/tests/test_codex_astra_advisor.py +271 -0
  26. package/_shared/advisor/scripts/tests/test_codex_astra_preflight.py +24 -0
  27. package/_shared/advisor/scripts/tests/test_codex_astra_reply.py +42 -0
  28. package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +55 -30
  29. package/_shared/advisor/scripts/tests/test_tier_model_ids.py +14 -4
  30. package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +14 -0
  31. package/_shared/pr-loop/scripts/terminology_sweep.py +138 -50
  32. package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +13 -1
  33. package/bin/cde.mjs +175 -0
  34. package/bin/cde.test.mjs +222 -0
  35. package/bin/install-constants.mjs +4 -0
  36. package/bin/install.cursor-rules.test.mjs +233 -2
  37. package/bin/install.mjs +49 -3
  38. package/bin/pstack-model-preferences.codex.json +23 -0
  39. package/bin/select-pstack-models.test.mjs +378 -0
  40. package/docs/references/team-advisor-skill.md +3 -3
  41. package/hooks/hooks_constants/tool_names.py +20 -0
  42. package/hooks/validators/README.md +35 -1
  43. package/hooks/validators/fast_save_validators.py +63 -89
  44. package/hooks/validators/run_all_validators.py +16 -8
  45. package/hooks/validators/test_fast_save_validators_policy_lint.py +25 -0
  46. package/hooks/validators/test_run_all_validators_policy_lint.py +52 -0
  47. package/package.json +3 -2
  48. package/rules/pstack-models.md +119 -0
  49. package/scripts/cde_lint.py +258 -0
  50. package/scripts/check.ps1 +18 -6
  51. package/scripts/policy_lint/__init__.py +41 -0
  52. package/scripts/policy_lint/adapter_configuration.py +91 -0
  53. package/scripts/policy_lint/adapter_detectors.py +368 -0
  54. package/scripts/policy_lint/adapter_pairing.py +145 -0
  55. package/scripts/policy_lint/adapter_support.py +97 -0
  56. package/scripts/policy_lint/adapters.py +376 -0
  57. package/scripts/policy_lint/config/__init__.py +1 -0
  58. package/scripts/policy_lint/config/constants.py +77 -0
  59. package/scripts/policy_lint/engine.py +224 -0
  60. package/scripts/policy_lint/model.py +388 -0
  61. package/scripts/policy_lint/registry.py +171 -0
  62. package/scripts/policy_lint/render.py +91 -0
  63. package/scripts/policy_lint/selection.py +372 -0
  64. package/scripts/policy_lint/selection_git.py +147 -0
  65. package/scripts/repository_checks/__init__.py +1 -0
  66. package/scripts/repository_checks/claude_md.py +108 -0
  67. package/scripts/repository_checks/config/__init__.py +1 -0
  68. package/scripts/repository_checks/config/constants.py +150 -0
  69. package/scripts/repository_checks/env_var_documentation.py +243 -0
  70. package/scripts/repository_checks/hook_modules.py +34 -0
  71. package/scripts/repository_checks/models.py +37 -0
  72. package/scripts/repository_checks/package_inventory.py +118 -0
  73. package/scripts/repository_checks/pytest_testpaths.py +84 -0
  74. package/scripts/repository_checks/reporting.py +39 -0
  75. package/scripts/repository_checks/runner.py +93 -0
  76. package/scripts/repository_checks/tracked_secrets.py +147 -0
  77. package/scripts/repository_checks/tracked_tree.py +28 -0
  78. package/scripts/repository_policy.py +120 -0
  79. package/scripts/select_pstack_models.mjs +304 -0
  80. package/scripts/tests/repository_policy_test_support.py +89 -0
  81. package/scripts/tests/test_cde_lint.py +398 -0
  82. package/scripts/tests/test_claude_md.py +68 -0
  83. package/scripts/tests/test_env_var_documentation.py +133 -0
  84. package/scripts/tests/test_hook_modules.py +17 -0
  85. package/scripts/tests/test_models.py +26 -0
  86. package/scripts/tests/test_package_inventory.py +96 -0
  87. package/scripts/tests/test_policy_lint_rules_engine.py +381 -0
  88. package/scripts/tests/test_policy_lint_rules_registry.py +369 -0
  89. package/scripts/tests/test_policy_lint_selection.py +373 -0
  90. package/scripts/tests/test_pytest_testpaths.py +66 -0
  91. package/scripts/tests/test_reporting.py +54 -0
  92. package/scripts/tests/test_repository_policy.py +71 -0
  93. package/scripts/tests/test_runner.py +59 -0
  94. package/scripts/tests/test_tracked_secrets.py +175 -0
  95. package/scripts/tests/test_tracked_tree.py +28 -0
  96. package/_shared/advisor/reference/sol-rung.md +0 -39
  97. package/_shared/advisor/scripts/codex_sol_advisor.py +0 -581
  98. package/_shared/advisor/scripts/config/advisor_scripts_constants/sol_advisor_constants.py +0 -38
  99. package/_shared/advisor/scripts/tests/test_codex_sol_advisor.py +0 -740
@@ -0,0 +1,91 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from . import adapter_support
7
+ from .config import constants
8
+ from .model import Diagnostic, Document
9
+
10
+
11
+ def hook_configuration_diagnostics(
12
+ document: Document, repository_root: Path
13
+ ) -> tuple[Diagnostic, ...]:
14
+ """Check hook configuration for policy decision fields.
15
+
16
+ Args:
17
+ document: Current hook configuration text.
18
+ repository_root: Request repository root for document-path resolution.
19
+
20
+ Returns:
21
+ Hook configuration diagnostics.
22
+ """
23
+ adapter_support._document_path(repository_root, document)
24
+ if document.path.name != "hooks.json":
25
+ return ()
26
+ all_messages = _hook_configuration_messages(document.text)
27
+ return adapter_support._diagnostics_for_messages(
28
+ document, "hook-configuration", all_messages
29
+ )
30
+
31
+
32
+ def _hook_configuration_messages(configuration_text: str) -> tuple[str, ...]:
33
+ try:
34
+ parsed_configuration = json.loads(configuration_text)
35
+ except json.JSONDecodeError:
36
+ return ("Hook configuration is not valid JSON",)
37
+ if not isinstance(parsed_configuration, dict):
38
+ return ("Hook configuration must be an object",)
39
+ all_registered_strings = _registered_hook_strings(parsed_configuration)
40
+ if any(
41
+ _contains_action_boundary_marker(each_string)
42
+ for each_string in all_registered_strings
43
+ ):
44
+ return ("Hook configuration registers a deny, block, or ask policy path",)
45
+ return ()
46
+
47
+
48
+ def _registered_hook_strings(
49
+ all_configuration_entries: dict[object, object]
50
+ ) -> tuple[str, ...]:
51
+ all_registered_strings: list[str] = []
52
+ for each_key, each_entry in all_configuration_entries.items():
53
+ all_registered_strings.extend(
54
+ _registered_strings_for_entry(each_key, each_entry)
55
+ )
56
+ return tuple(all_registered_strings)
57
+
58
+
59
+ def _registered_strings_for_entry(
60
+ each_key: object, each_entry: object
61
+ ) -> tuple[str, ...]:
62
+ all_registered_strings: list[str] = []
63
+ if each_key in constants.ALL_HOOK_REGISTRATION_KEYS and isinstance(each_entry, str):
64
+ all_registered_strings.append(each_entry)
65
+ if each_key in constants.ALL_DECISION_REGISTRATION_KEYS and isinstance(each_entry, str):
66
+ all_registered_strings.append(each_entry)
67
+ all_registered_strings.extend(_registered_nested_strings(each_entry))
68
+ return tuple(all_registered_strings)
69
+
70
+
71
+ def _registered_nested_strings(each_entry: object) -> tuple[str, ...]:
72
+ if isinstance(each_entry, dict):
73
+ return _registered_hook_strings(each_entry)
74
+ if not isinstance(each_entry, list):
75
+ return ()
76
+ all_registered_strings: list[str] = []
77
+ for each_nested_entry in each_entry:
78
+ if isinstance(each_nested_entry, dict):
79
+ all_registered_strings.extend(_registered_hook_strings(each_nested_entry))
80
+ return tuple(all_registered_strings)
81
+
82
+
83
+ def _contains_action_boundary_marker(registered_string: str) -> bool:
84
+ normalized_string = registered_string.lower().replace("\\", "/")
85
+ normalized_string = normalized_string.replace(".", "/").replace("-", "/")
86
+ return any(
87
+ each_segment in constants.ALL_ACTION_BOUNDARY_SEGMENTS
88
+ or each_segment.startswith(constants.ALL_ACTION_BOUNDARY_PREFIXES)
89
+ for each_segment in normalized_string.split("/")
90
+ if each_segment
91
+ )
@@ -0,0 +1,368 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterable
4
+ from contextlib import redirect_stderr, redirect_stdout
5
+ from io import StringIO
6
+ from pathlib import Path, PurePosixPath
7
+
8
+ from . import adapter_support
9
+ from .config import constants
10
+ from .model import Diagnostic, Document, DocumentSet, Location, SelectionKind, Severity
11
+
12
+
13
+ def _terminology_diagnostic(finding: str) -> Diagnostic:
14
+ finding_match = constants.TERMINOLOGY_FINDING_PATTERN.fullmatch(finding)
15
+ if finding_match is None:
16
+ raise ValueError(finding)
17
+ return Diagnostic(
18
+ constants.TERMINOLOGY_SWEEP_RULE_ID,
19
+ Severity.ERROR,
20
+ finding_match.group(constants.TERMINOLOGY_MESSAGE_GROUP),
21
+ Location(
22
+ PurePosixPath(finding_match.group(constants.TERMINOLOGY_PATH_GROUP)),
23
+ int(finding_match.group(constants.TERMINOLOGY_LINE_NUMBER_GROUP)),
24
+ ),
25
+ )
26
+
27
+
28
+ def terminology_diagnostics(
29
+ document_set: DocumentSet,
30
+ load_module: adapter_support.HookModuleLoader,
31
+ ) -> tuple[Diagnostic, ...]:
32
+ """Report staged prose that near-misses a newly introduced identifier.
33
+
34
+ Args:
35
+ document_set: Candidate staged documents and repository root.
36
+ load_module: Shared script module loader.
37
+
38
+ Returns:
39
+ Diagnostics that name the prose file and line.
40
+ """
41
+ if document_set.selection is not SelectionKind.STAGED:
42
+ return ()
43
+ terminology_module = load_module(constants.TERMINOLOGY_SWEEP_MODULE_NAME)
44
+ helper_stdout = StringIO()
45
+ helper_stderr = StringIO()
46
+ with redirect_stdout(helper_stdout), redirect_stderr(helper_stderr):
47
+ all_findings = terminology_module.strict_staged_terminology_findings(
48
+ document_set.repository_root
49
+ )
50
+ return tuple(_terminology_diagnostic(each_finding) for each_finding in all_findings)
51
+
52
+
53
+ def _stable_validator_message(document: Document, raw_message: str) -> str:
54
+ _prefix, path_separator, message_tail = raw_message.partition(document.path.name)
55
+ if not path_separator or not message_tail.startswith(":"):
56
+ return raw_message
57
+ return f"{document.path.as_posix()}{message_tail}"
58
+
59
+
60
+ def _validator_messages(
61
+ document: Document, did_pass: bool, printed_text: str
62
+ ) -> tuple[str, ...]:
63
+ if did_pass:
64
+ return ()
65
+ return tuple(
66
+ _stable_validator_message(document, each_line)
67
+ for each_line in printed_text.splitlines()
68
+ if each_line.strip()
69
+ )
70
+
71
+
72
+ def _messages_for_validator_outcomes(
73
+ document: Document, all_validator_outcomes: Iterable[object]
74
+ ) -> tuple[str, ...]:
75
+ return tuple(
76
+ each_message
77
+ for each_outcome in all_validator_outcomes
78
+ for each_message in _validator_messages(
79
+ document,
80
+ bool(vars(each_outcome)["passed"]),
81
+ str(vars(each_outcome)["output"]),
82
+ )
83
+ )
84
+
85
+
86
+ def validator_diagnostics(
87
+ document: Document,
88
+ repository_root: Path,
89
+ load_module: adapter_support.HookModuleLoader,
90
+ ) -> tuple[Diagnostic, ...]:
91
+ """Run the fast validator set on one document.
92
+
93
+ Args:
94
+ document: Current source text and path.
95
+ repository_root: Request repository root for configuration resolution.
96
+ load_module: Hook module loader.
97
+
98
+ Returns:
99
+ Path-normalized diagnostics for failed validators.
100
+ """
101
+ absolute_path = adapter_support._document_path(repository_root, document)
102
+ validator_module = load_module("validators.run_all_validators")
103
+ all_validator_outcomes = validator_module.validate_proposed_file(
104
+ absolute_path.as_posix(),
105
+ document.text,
106
+ config_source_path=absolute_path,
107
+ include_ruff=False,
108
+ excluded_validator_names=constants.ALL_OVERLAPPING_VALIDATOR_NAMES,
109
+ )
110
+ all_messages = _messages_for_validator_outcomes(document, all_validator_outcomes)
111
+ return adapter_support._diagnostics_for_messages(document, "validators", all_messages)
112
+
113
+
114
+ def code_rule_diagnostics(
115
+ document: Document,
116
+ repository_root: Path,
117
+ load_module: adapter_support.HookModuleLoader,
118
+ ) -> tuple[Diagnostic, ...]:
119
+ """Run the code-rule engine on one document.
120
+
121
+ Args:
122
+ document: Current text and optional prior text.
123
+ repository_root: Request repository root for sibling-file resolution.
124
+ load_module: Hook module loader.
125
+
126
+ Returns:
127
+ Diagnostics from the code-rule engine.
128
+ """
129
+ absolute_path = adapter_support._document_path(repository_root, document)
130
+ legacy_module = load_module("blocking.code_rules_enforcer")
131
+ legacy_stdout = StringIO()
132
+ legacy_stderr = StringIO()
133
+ with redirect_stdout(legacy_stdout), redirect_stderr(legacy_stderr):
134
+ all_messages = legacy_module.validate_content_for_full_gate(
135
+ document.text,
136
+ absolute_path.as_posix(),
137
+ old_content=document.prior_text or "",
138
+ defer_scope_to_caller=True,
139
+ sibling_directory=absolute_path.parent,
140
+ include_comment_policy=True,
141
+ )
142
+ return adapter_support._diagnostics_for_messages(document, "code-rules", all_messages)
143
+
144
+
145
+ def state_description_diagnostics(
146
+ document: Document,
147
+ repository_root: Path,
148
+ load_module: adapter_support.HookModuleLoader,
149
+ ) -> tuple[Diagnostic, ...]:
150
+ """Run the state-description detector on one document.
151
+
152
+ Args:
153
+ document: Current document text.
154
+ repository_root: Request repository root for document-path resolution.
155
+ load_module: Hook module loader.
156
+
157
+ Returns:
158
+ State-description diagnostics.
159
+ """
160
+ absolute_path = adapter_support._document_path(repository_root, document)
161
+ detector_module = load_module("blocking.state_description_blocker")
162
+ all_messages = detector_module.find_violations(
163
+ document.text, absolute_path.as_posix()
164
+ )
165
+ return adapter_support._diagnostics_for_state_messages(document, all_messages)
166
+
167
+
168
+ def plain_language_diagnostics(
169
+ document: Document,
170
+ repository_root: Path,
171
+ load_module: adapter_support.HookModuleLoader,
172
+ ) -> tuple[Diagnostic, ...]:
173
+ """Run the stored-prose detector on one document.
174
+
175
+ Args:
176
+ document: Current document text.
177
+ repository_root: Request repository root for document-path resolution.
178
+ load_module: Hook module loader.
179
+
180
+ Returns:
181
+ Plain-language diagnostics.
182
+ """
183
+ adapter_support._document_path(repository_root, document)
184
+ detector_module = load_module("blocking.plain_language_blocker")
185
+ all_matches = detector_module.find_banned_terms(document.text)
186
+ all_messages = [f"Banned prose term: {each_term}" for each_term, _ in all_matches]
187
+ return adapter_support._diagnostics_for_messages(document, "plain-language", all_messages)
188
+
189
+
190
+ def docstring_gate_diagnostics(
191
+ document: Document,
192
+ repository_root: Path,
193
+ load_module: adapter_support.HookModuleLoader,
194
+ ) -> tuple[Diagnostic, ...]:
195
+ """Run the docstring gate-count detector on one document.
196
+
197
+ Args:
198
+ document: Current document text.
199
+ repository_root: Request repository root for document-path resolution.
200
+ load_module: Hook module loader.
201
+
202
+ Returns:
203
+ Docstring gate diagnostics.
204
+ """
205
+ absolute_path = adapter_support._document_path(repository_root, document)
206
+ detector_module = load_module("blocking.docstring_rule_gate_count_blocker")
207
+ if not detector_module.is_target_rule_file(absolute_path.as_posix()):
208
+ return ()
209
+ all_messages = detector_module.find_gate_count_drift(document.text)
210
+ return adapter_support._diagnostics_for_messages(document, "docstring-gate-count", all_messages)
211
+
212
+
213
+ def workflow_substitution_diagnostics(
214
+ document: Document,
215
+ repository_root: Path,
216
+ load_module: adapter_support.HookModuleLoader,
217
+ ) -> tuple[Diagnostic, ...]:
218
+ """Run the workflow substitution detector on one document.
219
+
220
+ Args:
221
+ document: Current document text.
222
+ repository_root: Request repository root for document-path resolution.
223
+ load_module: Hook module loader.
224
+
225
+ Returns:
226
+ Workflow substitution diagnostics.
227
+ """
228
+ adapter_support._document_path(repository_root, document)
229
+ detector_module = load_module("blocking.workflow_substitution_slot_blocker")
230
+ if not detector_module.content_has_violation(document.text):
231
+ return ()
232
+ return adapter_support._diagnostics_for_messages(
233
+ document, "workflow-substitution", ("Workflow substitution slot is incomplete",)
234
+ )
235
+
236
+
237
+ def rmtree_diagnostics(
238
+ document: Document,
239
+ repository_root: Path,
240
+ load_module: adapter_support.HookModuleLoader,
241
+ ) -> tuple[Diagnostic, ...]:
242
+ """Run the Windows cleanup detectors on one document.
243
+
244
+ Args:
245
+ document: Current text.
246
+ repository_root: Request repository root for document-path resolution.
247
+ load_module: Hook module loader.
248
+
249
+ Returns:
250
+ Cleanup safety diagnostics.
251
+ """
252
+ absolute_path = adapter_support._document_path(repository_root, document)
253
+ unsafe_module = load_module("blocking.windows_rmtree_blocker")
254
+ duplicate_module = load_module("blocking.duplicate_rmtree_helper_blocker")
255
+ all_messages: list[str] = []
256
+ if unsafe_module.payload_contains_unsafe_rmtree(document.text):
257
+ all_messages.append("Unsafe rmtree ignore_errors usage")
258
+ if (
259
+ not duplicate_module.path_is_exempt(absolute_path.as_posix())
260
+ and duplicate_module.payload_defines_sanctioned_helper(document.text)
261
+ ):
262
+ all_messages.append("Duplicate sanctioned rmtree helper")
263
+ return adapter_support._diagnostics_for_messages(document, "rmtree-safety", all_messages)
264
+
265
+
266
+ def subprocess_budget_diagnostics(
267
+ document: Document,
268
+ repository_root: Path,
269
+ load_module: adapter_support.HookModuleLoader,
270
+ ) -> tuple[Diagnostic, ...]:
271
+ """Run the subprocess budget detector on one document.
272
+
273
+ Args:
274
+ document: Current text.
275
+ repository_root: Request repository root for document-path resolution.
276
+ load_module: Hook module loader.
277
+
278
+ Returns:
279
+ Subprocess budget diagnostics.
280
+ """
281
+ adapter_support._document_path(repository_root, document)
282
+ detector_module = load_module("blocking.subprocess_budget_completeness")
283
+ maybe_issue = detector_module.find_undercounted_budget(document.text)
284
+ if maybe_issue is None:
285
+ return ()
286
+ function_name, all_omitted_counts = maybe_issue
287
+ message = f"Subprocess budget omits counts in {function_name}: {sorted(all_omitted_counts)}"
288
+ return adapter_support._diagnostics_for_messages(document, "subprocess-budget", (message,))
289
+
290
+
291
+ def hook_prose_diagnostics(
292
+ document: Document,
293
+ repository_root: Path,
294
+ load_module: adapter_support.HookModuleLoader,
295
+ ) -> tuple[Diagnostic, ...]:
296
+ """Run the hook-prose consistency detector on one document.
297
+
298
+ Args:
299
+ document: Current text.
300
+ repository_root: Request repository root for document-path resolution.
301
+ load_module: Hook module loader.
302
+
303
+ Returns:
304
+ Hook-prose diagnostics.
305
+ """
306
+ absolute_path = adapter_support._document_path(repository_root, document)
307
+ detector_module = load_module("blocking.hook_prose_detector_consistency")
308
+ if not detector_module.is_hook_python_module(absolute_path.as_posix()):
309
+ return ()
310
+ if not detector_module.content_has_violation(
311
+ document.text, absolute_path.as_posix()
312
+ ):
313
+ return ()
314
+ return adapter_support._diagnostics_for_messages(
315
+ document,
316
+ "hook-prose-consistency",
317
+ ("Hook prose detector does not match its output contract",),
318
+ )
319
+
320
+
321
+ def open_question_diagnostics(
322
+ document: Document,
323
+ repository_root: Path,
324
+ load_module: adapter_support.HookModuleLoader,
325
+ ) -> tuple[Diagnostic, ...]:
326
+ """Run the open-question plan detector on one document.
327
+
328
+ Args:
329
+ document: Current plan text.
330
+ repository_root: Request repository root for document-path resolution.
331
+ load_module: Hook module loader.
332
+
333
+ Returns:
334
+ Plan question diagnostics.
335
+ """
336
+ adapter_support._document_path(repository_root, document)
337
+ detector_module = load_module("blocking.open_questions_in_plans_blocker")
338
+ if not detector_module._content_has_open_questions(document.text):
339
+ return ()
340
+ return adapter_support._diagnostics_for_messages(
341
+ document, "open-questions", ("Plan contains an open question",)
342
+ )
343
+
344
+
345
+ def hook_format_diagnostics(
346
+ document: Document,
347
+ repository_root: Path,
348
+ load_module: adapter_support.HookModuleLoader,
349
+ ) -> tuple[Diagnostic, ...]:
350
+ """Check one settings document for a nonportable hook command.
351
+
352
+ Args:
353
+ document: Current settings text and path.
354
+ repository_root: Request repository root for document-path resolution.
355
+ load_module: Hook module loader.
356
+
357
+ Returns:
358
+ A diagnostic when the command uses a home-relative Python script path.
359
+ """
360
+ adapter_support._document_path(repository_root, document)
361
+ detector_module = load_module("validation.hook_format_validator")
362
+ if detector_module.SIMPLE_PATTERN.search(document.text) is None:
363
+ return ()
364
+ return adapter_support._diagnostics_for_messages(
365
+ document,
366
+ "hook-format",
367
+ ("Hook command uses a home-relative Python script path",),
368
+ )
@@ -0,0 +1,145 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import PurePosixPath
4
+
5
+ from . import adapter_support
6
+ from .config import constants
7
+ from .model import Diagnostic, Document, DocumentSet, Location, SelectionKind, Severity
8
+
9
+
10
+ def _is_test_path(path: PurePosixPath) -> bool:
11
+ normalized_name = path.name.lower()
12
+ normalized_parts = {each_part.lower() for each_part in path.parts}
13
+ return (
14
+ normalized_name.startswith(constants.ALL_TEST_FILE_PREFIXES)
15
+ or normalized_name.endswith(constants.ALL_TEST_FILE_SUFFIXES)
16
+ or ".test." in normalized_name
17
+ or ".spec." in normalized_name
18
+ or bool(normalized_parts.intersection(constants.ALL_TEST_DIRECTORY_NAMES))
19
+ )
20
+
21
+
22
+ def _is_constants_only_python_document(
23
+ document: Document, load_module: adapter_support.HookModuleLoader
24
+ ) -> bool:
25
+ if document.path.suffix.lower() != constants.PYTHON_SUFFIX:
26
+ return False
27
+ analysis_module = load_module("blocking.tdd_enforcer_parts.content_analysis")
28
+ return analysis_module._is_constants_only_python_content(document.text)
29
+
30
+
31
+ def _candidate_test_names(path: PurePosixPath) -> frozenset[str]:
32
+ stem = path.stem
33
+ suffix = path.suffix.lower()
34
+ if suffix == constants.PYTHON_SUFFIX:
35
+ return frozenset({f"test_{stem}.py", f"{stem}_test.py"})
36
+ if suffix in constants.ALL_CODE_SUFFIXES:
37
+ return frozenset({f"{stem}.test{suffix}", f"{stem}.spec{suffix}"})
38
+ return frozenset()
39
+
40
+
41
+ def _family_tokens(path: PurePosixPath) -> tuple[str, ...]:
42
+ normalized_stem = path.stem.lower().replace("-", "_").replace(".", "_")
43
+ normalized_stem = normalized_stem.removeprefix("test_")
44
+ normalized_stem = normalized_stem.removesuffix("_test")
45
+ return tuple(each_token for each_token in normalized_stem.split("_") if each_token)
46
+
47
+
48
+ def _is_policy_lint_test_match(
49
+ production_path: PurePosixPath, test_path: PurePosixPath
50
+ ) -> bool:
51
+ if constants.POLICY_LINT_DIRECTORY_NAME not in production_path.parts:
52
+ return False
53
+ test_prefix = (
54
+ constants.POLICY_LINT_SELECTION_TEST_PREFIX
55
+ if production_path.stem.lower() in {"selection", "selection_git"}
56
+ else constants.POLICY_LINT_RULES_TEST_PREFIX
57
+ )
58
+ normalized_test_stem = test_path.stem.lower()
59
+ return normalized_test_stem == test_prefix or normalized_test_stem.startswith(
60
+ f"{test_prefix}_"
61
+ )
62
+
63
+
64
+ def _is_grouped_test_match(
65
+ production_path: PurePosixPath, test_path: PurePosixPath
66
+ ) -> bool:
67
+ if _is_policy_lint_test_match(production_path, test_path):
68
+ return True
69
+ if production_path.stem.lower() in {
70
+ constants.RUN_ALL_VALIDATORS_STEM,
71
+ constants.FAST_SAVE_VALIDATORS_STEM,
72
+ }:
73
+ all_production_tokens = _family_tokens(production_path)
74
+ all_test_tokens = _family_tokens(test_path)
75
+ return all_test_tokens[: len(all_production_tokens)] == all_production_tokens
76
+ return _family_tokens(production_path) == _family_tokens(test_path)
77
+
78
+
79
+ def _has_changed_test(
80
+ production_path: PurePosixPath,
81
+ all_changed_test_paths: frozenset[PurePosixPath],
82
+ ) -> bool:
83
+ all_candidate_names = _candidate_test_names(production_path)
84
+ return any(
85
+ each_test_path.name.lower() in all_candidate_names
86
+ or _is_grouped_test_match(production_path, each_test_path)
87
+ for each_test_path in all_changed_test_paths
88
+ )
89
+
90
+
91
+ def _is_unpaired_production(
92
+ production_document: Document,
93
+ all_changed_test_paths: frozenset[PurePosixPath],
94
+ load_module: adapter_support.HookModuleLoader,
95
+ ) -> bool:
96
+ production_path = production_document.path
97
+ if production_path.suffix.lower() not in constants.ALL_CODE_SUFFIXES:
98
+ return False
99
+ if _is_test_path(production_path):
100
+ return False
101
+ if _is_constants_only_python_document(production_document, load_module):
102
+ return False
103
+ return not _has_changed_test(production_path, all_changed_test_paths)
104
+
105
+
106
+ def _changed_test_paths(document_set: DocumentSet) -> frozenset[PurePosixPath]:
107
+ return frozenset(
108
+ each_document.path
109
+ for each_document in document_set.documents
110
+ if _is_test_path(each_document.path)
111
+ )
112
+
113
+
114
+ def _pairing_diagnostic(document: Document) -> Diagnostic:
115
+ return Diagnostic(
116
+ "test-pairing",
117
+ Severity.ERROR,
118
+ "Changed production file has no changed matching test",
119
+ Location(document.path, 1, 1),
120
+ )
121
+
122
+
123
+ def test_pairing_diagnostics(
124
+ document_set: DocumentSet,
125
+ load_module: adapter_support.HookModuleLoader,
126
+ ) -> tuple[Diagnostic, ...]:
127
+ """Report changed production files without a changed matching test.
128
+
129
+ Args:
130
+ document_set: Staged or base change selection.
131
+ load_module: Hook module loader.
132
+
133
+ Returns:
134
+ Test-pairing diagnostics for unmatched production files.
135
+ """
136
+ if document_set.selection not in {SelectionKind.STAGED, SelectionKind.BASE}:
137
+ return ()
138
+ all_changed_test_paths = _changed_test_paths(document_set)
139
+ return tuple(
140
+ _pairing_diagnostic(each_document)
141
+ for each_document in document_set.documents
142
+ if _is_unpaired_production(
143
+ each_document, all_changed_test_paths, load_module
144
+ )
145
+ )
@@ -0,0 +1,97 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable, Iterable
4
+ from pathlib import Path
5
+ from types import ModuleType
6
+
7
+ from .model import Diagnostic, Document, Location, Severity
8
+
9
+ HookModuleLoader = Callable[[str], ModuleType]
10
+
11
+
12
+ def _document_path(repository_root: Path, document: Document) -> Path:
13
+ return repository_root / document.path.as_posix()
14
+
15
+
16
+ def _line_number(message: str) -> int | None:
17
+ line_prefix = "Line "
18
+ if not message.startswith(line_prefix):
19
+ return _path_line_number(message) or None
20
+ remainder = message[len(line_prefix) :]
21
+ line_text, separator, _tail = remainder.partition(":")
22
+ if not separator or not line_text.isdigit():
23
+ return _path_line_number(message) or None
24
+ parsed_line = int(line_text)
25
+ if parsed_line < 1:
26
+ return _path_line_number(message) or None
27
+ return parsed_line
28
+
29
+
30
+ def _path_line_number(message: str) -> int:
31
+ all_prefix_fields = message.partition(": ")[0].split(":")
32
+ for each_field_index, each_field in enumerate(all_prefix_fields):
33
+ if each_field.isdigit():
34
+ return _line_number_when_prefix_is_a_location(
35
+ all_prefix_fields, each_field_index
36
+ )
37
+ return 0
38
+
39
+
40
+ def _line_number_when_prefix_is_a_location(
41
+ all_prefix_fields: list[str], digit_field_index: int
42
+ ) -> int:
43
+ all_non_path_characters = ("|", '"')
44
+ if digit_field_index == 0:
45
+ return 0
46
+ all_path_fields = all_prefix_fields[:digit_field_index]
47
+ looks_like_a_path = not any(
48
+ each_character in each_path_field
49
+ for each_path_field in all_path_fields
50
+ for each_character in all_non_path_characters
51
+ )
52
+ all_trailing_fields = all_prefix_fields[digit_field_index + 1 :]
53
+ if not looks_like_a_path:
54
+ return 0
55
+ if not all(each_field.isdigit() for each_field in all_trailing_fields):
56
+ return 0
57
+ return int(all_prefix_fields[digit_field_index])
58
+
59
+
60
+ def _diagnostics_for_messages(
61
+ document: Document,
62
+ rule_id: str,
63
+ all_messages: Iterable[str],
64
+ ) -> tuple[Diagnostic, ...]:
65
+ all_diagnostics: list[Diagnostic] = []
66
+ for each_message in all_messages:
67
+ maybe_line = _line_number(each_message)
68
+ location = (
69
+ None if maybe_line is None else Location(document.path, maybe_line, 1)
70
+ )
71
+ all_diagnostics.append(
72
+ Diagnostic(rule_id, Severity.ERROR, each_message, location)
73
+ )
74
+ return tuple(all_diagnostics)
75
+
76
+
77
+ def _source_line_for_phrase(source_text: str, phrase: str) -> int | None:
78
+ normalized_phrase = phrase.casefold()
79
+ for each_line_number, each_line in enumerate(source_text.splitlines(), 1):
80
+ if normalized_phrase in each_line.casefold():
81
+ return each_line_number
82
+ return None
83
+
84
+
85
+ def _diagnostics_for_state_messages(
86
+ document: Document, all_messages: Iterable[str]
87
+ ) -> tuple[Diagnostic, ...]:
88
+ all_diagnostics: list[Diagnostic] = []
89
+ for each_message in all_messages:
90
+ maybe_line = _source_line_for_phrase(document.text, each_message)
91
+ location = (
92
+ None if maybe_line is None else Location(document.path, maybe_line, 1)
93
+ )
94
+ all_diagnostics.append(
95
+ Diagnostic("state-description", Severity.ERROR, each_message, location)
96
+ )
97
+ return tuple(all_diagnostics)