claude-dev-env 2.35.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.
- package/.agents/skills/autoconverge/SKILL.md +19 -15
- package/.agents/skills/autoconverge/reference/gotchas.md +3 -3
- package/.agents/skills/autoconverge/reference/multi-pr.md +6 -2
- package/.agents/skills/autoconverge/workflow/converge.contract.test.mjs +11 -0
- package/.agents/skills/autoconverge/workflow/converge.mjs +26 -33
- package/.agents/skills/autoconverge/workflow/converge.run-input.test.mjs +26 -2
- package/.agents/skills/autoconverge/workflow/converge_multi.mjs +12 -28
- package/.agents/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +16 -0
- package/bin/cde.mjs +175 -0
- package/bin/cde.test.mjs +222 -0
- package/bin/install-constants.mjs +4 -0
- package/bin/install.cursor-rules.test.mjs +233 -2
- package/bin/install.mjs +49 -3
- package/bin/pstack-model-preferences.codex.json +23 -0
- package/bin/select-pstack-models.test.mjs +378 -0
- package/hooks/hooks_constants/tool_names.py +20 -0
- package/hooks/validators/fast_save_validators.py +63 -89
- package/hooks/validators/run_all_validators.py +16 -8
- package/hooks/validators/test_fast_save_validators_policy_lint.py +25 -0
- package/hooks/validators/test_run_all_validators_policy_lint.py +52 -0
- package/package.json +3 -2
- package/rules/pstack-models.md +119 -0
- package/scripts/cde_lint.py +258 -0
- package/scripts/policy_lint/__init__.py +41 -0
- package/scripts/policy_lint/adapter_configuration.py +91 -0
- package/scripts/policy_lint/adapter_detectors.py +328 -0
- package/scripts/policy_lint/adapter_pairing.py +145 -0
- package/scripts/policy_lint/adapter_support.py +97 -0
- package/scripts/policy_lint/adapters.py +352 -0
- package/scripts/policy_lint/config/__init__.py +1 -0
- package/scripts/policy_lint/config/constants.py +66 -0
- package/scripts/policy_lint/engine.py +224 -0
- package/scripts/policy_lint/model.py +387 -0
- package/scripts/policy_lint/registry.py +162 -0
- package/scripts/policy_lint/render.py +91 -0
- package/scripts/policy_lint/selection.py +372 -0
- package/scripts/policy_lint/selection_git.py +147 -0
- package/scripts/select_pstack_models.mjs +304 -0
- package/scripts/tests/test_cde_lint.py +374 -0
- package/scripts/tests/test_policy_lint_rules_engine.py +381 -0
- package/scripts/tests/test_policy_lint_rules_registry.py +252 -0
- package/scripts/tests/test_policy_lint_selection.py +373 -0
|
@@ -0,0 +1,328 @@
|
|
|
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
|
|
7
|
+
|
|
8
|
+
from . import adapter_support
|
|
9
|
+
from .config import constants
|
|
10
|
+
from .model import Diagnostic, Document
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _stable_validator_message(document: Document, raw_message: str) -> str:
|
|
14
|
+
_prefix, path_separator, message_tail = raw_message.partition(document.path.name)
|
|
15
|
+
if not path_separator or not message_tail.startswith(":"):
|
|
16
|
+
return raw_message
|
|
17
|
+
return f"{document.path.as_posix()}{message_tail}"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _validator_messages(
|
|
21
|
+
document: Document, did_pass: bool, printed_text: str
|
|
22
|
+
) -> tuple[str, ...]:
|
|
23
|
+
if did_pass:
|
|
24
|
+
return ()
|
|
25
|
+
return tuple(
|
|
26
|
+
_stable_validator_message(document, each_line)
|
|
27
|
+
for each_line in printed_text.splitlines()
|
|
28
|
+
if each_line.strip()
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _messages_for_validator_outcomes(
|
|
33
|
+
document: Document, all_validator_outcomes: Iterable[object]
|
|
34
|
+
) -> tuple[str, ...]:
|
|
35
|
+
return tuple(
|
|
36
|
+
each_message
|
|
37
|
+
for each_outcome in all_validator_outcomes
|
|
38
|
+
for each_message in _validator_messages(
|
|
39
|
+
document,
|
|
40
|
+
bool(vars(each_outcome)["passed"]),
|
|
41
|
+
str(vars(each_outcome)["output"]),
|
|
42
|
+
)
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def validator_diagnostics(
|
|
47
|
+
document: Document,
|
|
48
|
+
repository_root: Path,
|
|
49
|
+
load_module: adapter_support.HookModuleLoader,
|
|
50
|
+
) -> tuple[Diagnostic, ...]:
|
|
51
|
+
"""Run the fast validator set on one document.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
document: Current source text and path.
|
|
55
|
+
repository_root: Request repository root for configuration resolution.
|
|
56
|
+
load_module: Hook module loader.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
Path-normalized diagnostics for failed validators.
|
|
60
|
+
"""
|
|
61
|
+
absolute_path = adapter_support._document_path(repository_root, document)
|
|
62
|
+
validator_module = load_module("validators.run_all_validators")
|
|
63
|
+
all_validator_outcomes = validator_module.validate_proposed_file(
|
|
64
|
+
absolute_path.as_posix(),
|
|
65
|
+
document.text,
|
|
66
|
+
config_source_path=absolute_path,
|
|
67
|
+
include_ruff=False,
|
|
68
|
+
excluded_validator_names=constants.ALL_OVERLAPPING_VALIDATOR_NAMES,
|
|
69
|
+
)
|
|
70
|
+
all_messages = _messages_for_validator_outcomes(document, all_validator_outcomes)
|
|
71
|
+
return adapter_support._diagnostics_for_messages(document, "validators", all_messages)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def code_rule_diagnostics(
|
|
75
|
+
document: Document,
|
|
76
|
+
repository_root: Path,
|
|
77
|
+
load_module: adapter_support.HookModuleLoader,
|
|
78
|
+
) -> tuple[Diagnostic, ...]:
|
|
79
|
+
"""Run the code-rule engine on one document.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
document: Current text and optional prior text.
|
|
83
|
+
repository_root: Request repository root for sibling-file resolution.
|
|
84
|
+
load_module: Hook module loader.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
Diagnostics from the code-rule engine.
|
|
88
|
+
"""
|
|
89
|
+
absolute_path = adapter_support._document_path(repository_root, document)
|
|
90
|
+
legacy_module = load_module("blocking.code_rules_enforcer")
|
|
91
|
+
legacy_stdout = StringIO()
|
|
92
|
+
legacy_stderr = StringIO()
|
|
93
|
+
with redirect_stdout(legacy_stdout), redirect_stderr(legacy_stderr):
|
|
94
|
+
all_messages = legacy_module.validate_content_for_full_gate(
|
|
95
|
+
document.text,
|
|
96
|
+
absolute_path.as_posix(),
|
|
97
|
+
old_content=document.prior_text or "",
|
|
98
|
+
defer_scope_to_caller=True,
|
|
99
|
+
sibling_directory=absolute_path.parent,
|
|
100
|
+
include_comment_policy=True,
|
|
101
|
+
)
|
|
102
|
+
return adapter_support._diagnostics_for_messages(document, "code-rules", all_messages)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def state_description_diagnostics(
|
|
106
|
+
document: Document,
|
|
107
|
+
repository_root: Path,
|
|
108
|
+
load_module: adapter_support.HookModuleLoader,
|
|
109
|
+
) -> tuple[Diagnostic, ...]:
|
|
110
|
+
"""Run the state-description detector on one document.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
document: Current document text.
|
|
114
|
+
repository_root: Request repository root for document-path resolution.
|
|
115
|
+
load_module: Hook module loader.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
State-description diagnostics.
|
|
119
|
+
"""
|
|
120
|
+
absolute_path = adapter_support._document_path(repository_root, document)
|
|
121
|
+
detector_module = load_module("blocking.state_description_blocker")
|
|
122
|
+
all_messages = detector_module.find_violations(
|
|
123
|
+
document.text, absolute_path.as_posix()
|
|
124
|
+
)
|
|
125
|
+
return adapter_support._diagnostics_for_state_messages(document, all_messages)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def plain_language_diagnostics(
|
|
129
|
+
document: Document,
|
|
130
|
+
repository_root: Path,
|
|
131
|
+
load_module: adapter_support.HookModuleLoader,
|
|
132
|
+
) -> tuple[Diagnostic, ...]:
|
|
133
|
+
"""Run the stored-prose detector on one document.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
document: Current document text.
|
|
137
|
+
repository_root: Request repository root for document-path resolution.
|
|
138
|
+
load_module: Hook module loader.
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
Plain-language diagnostics.
|
|
142
|
+
"""
|
|
143
|
+
adapter_support._document_path(repository_root, document)
|
|
144
|
+
detector_module = load_module("blocking.plain_language_blocker")
|
|
145
|
+
all_matches = detector_module.find_banned_terms(document.text)
|
|
146
|
+
all_messages = [f"Banned prose term: {each_term}" for each_term, _ in all_matches]
|
|
147
|
+
return adapter_support._diagnostics_for_messages(document, "plain-language", all_messages)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def docstring_gate_diagnostics(
|
|
151
|
+
document: Document,
|
|
152
|
+
repository_root: Path,
|
|
153
|
+
load_module: adapter_support.HookModuleLoader,
|
|
154
|
+
) -> tuple[Diagnostic, ...]:
|
|
155
|
+
"""Run the docstring gate-count detector on one document.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
document: Current document text.
|
|
159
|
+
repository_root: Request repository root for document-path resolution.
|
|
160
|
+
load_module: Hook module loader.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
Docstring gate diagnostics.
|
|
164
|
+
"""
|
|
165
|
+
absolute_path = adapter_support._document_path(repository_root, document)
|
|
166
|
+
detector_module = load_module("blocking.docstring_rule_gate_count_blocker")
|
|
167
|
+
if not detector_module.is_target_rule_file(absolute_path.as_posix()):
|
|
168
|
+
return ()
|
|
169
|
+
all_messages = detector_module.find_gate_count_drift(document.text)
|
|
170
|
+
return adapter_support._diagnostics_for_messages(document, "docstring-gate-count", all_messages)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def workflow_substitution_diagnostics(
|
|
174
|
+
document: Document,
|
|
175
|
+
repository_root: Path,
|
|
176
|
+
load_module: adapter_support.HookModuleLoader,
|
|
177
|
+
) -> tuple[Diagnostic, ...]:
|
|
178
|
+
"""Run the workflow substitution detector on one document.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
document: Current document text.
|
|
182
|
+
repository_root: Request repository root for document-path resolution.
|
|
183
|
+
load_module: Hook module loader.
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
Workflow substitution diagnostics.
|
|
187
|
+
"""
|
|
188
|
+
adapter_support._document_path(repository_root, document)
|
|
189
|
+
detector_module = load_module("blocking.workflow_substitution_slot_blocker")
|
|
190
|
+
if not detector_module.content_has_violation(document.text):
|
|
191
|
+
return ()
|
|
192
|
+
return adapter_support._diagnostics_for_messages(
|
|
193
|
+
document, "workflow-substitution", ("Workflow substitution slot is incomplete",)
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def rmtree_diagnostics(
|
|
198
|
+
document: Document,
|
|
199
|
+
repository_root: Path,
|
|
200
|
+
load_module: adapter_support.HookModuleLoader,
|
|
201
|
+
) -> tuple[Diagnostic, ...]:
|
|
202
|
+
"""Run the Windows cleanup detectors on one document.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
document: Current text.
|
|
206
|
+
repository_root: Request repository root for document-path resolution.
|
|
207
|
+
load_module: Hook module loader.
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
Cleanup safety diagnostics.
|
|
211
|
+
"""
|
|
212
|
+
absolute_path = adapter_support._document_path(repository_root, document)
|
|
213
|
+
unsafe_module = load_module("blocking.windows_rmtree_blocker")
|
|
214
|
+
duplicate_module = load_module("blocking.duplicate_rmtree_helper_blocker")
|
|
215
|
+
all_messages: list[str] = []
|
|
216
|
+
if unsafe_module.payload_contains_unsafe_rmtree(document.text):
|
|
217
|
+
all_messages.append("Unsafe rmtree ignore_errors usage")
|
|
218
|
+
if (
|
|
219
|
+
not duplicate_module.path_is_exempt(absolute_path.as_posix())
|
|
220
|
+
and duplicate_module.payload_defines_sanctioned_helper(document.text)
|
|
221
|
+
):
|
|
222
|
+
all_messages.append("Duplicate sanctioned rmtree helper")
|
|
223
|
+
return adapter_support._diagnostics_for_messages(document, "rmtree-safety", all_messages)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def subprocess_budget_diagnostics(
|
|
227
|
+
document: Document,
|
|
228
|
+
repository_root: Path,
|
|
229
|
+
load_module: adapter_support.HookModuleLoader,
|
|
230
|
+
) -> tuple[Diagnostic, ...]:
|
|
231
|
+
"""Run the subprocess budget detector on one document.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
document: Current text.
|
|
235
|
+
repository_root: Request repository root for document-path resolution.
|
|
236
|
+
load_module: Hook module loader.
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
Subprocess budget diagnostics.
|
|
240
|
+
"""
|
|
241
|
+
adapter_support._document_path(repository_root, document)
|
|
242
|
+
detector_module = load_module("blocking.subprocess_budget_completeness")
|
|
243
|
+
maybe_issue = detector_module.find_undercounted_budget(document.text)
|
|
244
|
+
if maybe_issue is None:
|
|
245
|
+
return ()
|
|
246
|
+
function_name, all_omitted_counts = maybe_issue
|
|
247
|
+
message = f"Subprocess budget omits counts in {function_name}: {sorted(all_omitted_counts)}"
|
|
248
|
+
return adapter_support._diagnostics_for_messages(document, "subprocess-budget", (message,))
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def hook_prose_diagnostics(
|
|
252
|
+
document: Document,
|
|
253
|
+
repository_root: Path,
|
|
254
|
+
load_module: adapter_support.HookModuleLoader,
|
|
255
|
+
) -> tuple[Diagnostic, ...]:
|
|
256
|
+
"""Run the hook-prose consistency detector on one document.
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
document: Current text.
|
|
260
|
+
repository_root: Request repository root for document-path resolution.
|
|
261
|
+
load_module: Hook module loader.
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
Hook-prose diagnostics.
|
|
265
|
+
"""
|
|
266
|
+
absolute_path = adapter_support._document_path(repository_root, document)
|
|
267
|
+
detector_module = load_module("blocking.hook_prose_detector_consistency")
|
|
268
|
+
if not detector_module.is_hook_python_module(absolute_path.as_posix()):
|
|
269
|
+
return ()
|
|
270
|
+
if not detector_module.content_has_violation(
|
|
271
|
+
document.text, absolute_path.as_posix()
|
|
272
|
+
):
|
|
273
|
+
return ()
|
|
274
|
+
return adapter_support._diagnostics_for_messages(
|
|
275
|
+
document,
|
|
276
|
+
"hook-prose-consistency",
|
|
277
|
+
("Hook prose detector does not match its output contract",),
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def open_question_diagnostics(
|
|
282
|
+
document: Document,
|
|
283
|
+
repository_root: Path,
|
|
284
|
+
load_module: adapter_support.HookModuleLoader,
|
|
285
|
+
) -> tuple[Diagnostic, ...]:
|
|
286
|
+
"""Run the open-question plan detector on one document.
|
|
287
|
+
|
|
288
|
+
Args:
|
|
289
|
+
document: Current plan text.
|
|
290
|
+
repository_root: Request repository root for document-path resolution.
|
|
291
|
+
load_module: Hook module loader.
|
|
292
|
+
|
|
293
|
+
Returns:
|
|
294
|
+
Plan question diagnostics.
|
|
295
|
+
"""
|
|
296
|
+
adapter_support._document_path(repository_root, document)
|
|
297
|
+
detector_module = load_module("blocking.open_questions_in_plans_blocker")
|
|
298
|
+
if not detector_module._content_has_open_questions(document.text):
|
|
299
|
+
return ()
|
|
300
|
+
return adapter_support._diagnostics_for_messages(
|
|
301
|
+
document, "open-questions", ("Plan contains an open question",)
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def hook_format_diagnostics(
|
|
306
|
+
document: Document,
|
|
307
|
+
repository_root: Path,
|
|
308
|
+
load_module: adapter_support.HookModuleLoader,
|
|
309
|
+
) -> tuple[Diagnostic, ...]:
|
|
310
|
+
"""Check one settings document for a nonportable hook command.
|
|
311
|
+
|
|
312
|
+
Args:
|
|
313
|
+
document: Current settings text and path.
|
|
314
|
+
repository_root: Request repository root for document-path resolution.
|
|
315
|
+
load_module: Hook module loader.
|
|
316
|
+
|
|
317
|
+
Returns:
|
|
318
|
+
A diagnostic when the command uses a home-relative Python script path.
|
|
319
|
+
"""
|
|
320
|
+
adapter_support._document_path(repository_root, document)
|
|
321
|
+
detector_module = load_module("validation.hook_format_validator")
|
|
322
|
+
if detector_module.SIMPLE_PATTERN.search(document.text) is None:
|
|
323
|
+
return ()
|
|
324
|
+
return adapter_support._diagnostics_for_messages(
|
|
325
|
+
document,
|
|
326
|
+
"hook-format",
|
|
327
|
+
("Hook command uses a home-relative Python script path",),
|
|
328
|
+
)
|
|
@@ -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)
|