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.
- 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/audit-hooks-cli.mjs +54 -0
- package/scripts/audit-hooks.mjs +397 -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-audit-hooks.mjs +379 -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,387 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Iterable, Sequence
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
from pathlib import Path, PurePosixPath
|
|
7
|
+
from typing import TypeAlias
|
|
8
|
+
|
|
9
|
+
from .config.constants import INCOMPLETE_EXIT_CODE
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SelectionKind(StrEnum):
|
|
13
|
+
FILES = "files"
|
|
14
|
+
STAGED = "staged"
|
|
15
|
+
BASE = "base"
|
|
16
|
+
REPOSITORY = "repository"
|
|
17
|
+
TEXT = "text"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ContentOrigin(StrEnum):
|
|
21
|
+
WORKTREE = "worktree"
|
|
22
|
+
INDEX = "index"
|
|
23
|
+
REVISION_DIFF = "revision_diff"
|
|
24
|
+
TRACKED_TREE = "tracked_tree"
|
|
25
|
+
EDITOR = "editor"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Severity(StrEnum):
|
|
29
|
+
ERROR = "error"
|
|
30
|
+
WARNING = "warning"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class RuleScope(StrEnum):
|
|
34
|
+
DOCUMENT = "document"
|
|
35
|
+
CHANGESET = "changeset"
|
|
36
|
+
REPOSITORY = "repository"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class ExplicitFiles:
|
|
41
|
+
paths: tuple[Path, ...]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class StagedChanges:
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class BaseChanges:
|
|
51
|
+
revision: str
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class RepositoryTree:
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class TextDocument:
|
|
61
|
+
path: Path
|
|
62
|
+
text: str
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
LintSource: TypeAlias = (
|
|
66
|
+
ExplicitFiles | StagedChanges | BaseChanges | RepositoryTree | TextDocument
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class Document:
|
|
72
|
+
path: PurePosixPath
|
|
73
|
+
text: str
|
|
74
|
+
prior_text: str | None
|
|
75
|
+
changed_lines: frozenset[int] | None
|
|
76
|
+
origin: ContentOrigin
|
|
77
|
+
prior_path: PurePosixPath | None = None
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def from_text(
|
|
81
|
+
cls,
|
|
82
|
+
path: str | PurePosixPath,
|
|
83
|
+
text: str,
|
|
84
|
+
origin: ContentOrigin = ContentOrigin.EDITOR,
|
|
85
|
+
) -> Document:
|
|
86
|
+
"""Build a document from an unsaved text buffer.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
path: Repository-relative document path.
|
|
90
|
+
text: Unsaved document text.
|
|
91
|
+
origin: Content source label.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
A document with no prior content.
|
|
95
|
+
"""
|
|
96
|
+
normalized_path = PurePosixPath(
|
|
97
|
+
path.as_posix() if isinstance(path, PurePosixPath) else path
|
|
98
|
+
)
|
|
99
|
+
return cls(normalized_path, text, None, None, origin)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass(frozen=True)
|
|
103
|
+
class DocumentSet:
|
|
104
|
+
documents: tuple[Document, ...]
|
|
105
|
+
selection: SelectionKind
|
|
106
|
+
repository_root: Path
|
|
107
|
+
deleted_paths: tuple[PurePosixPath, ...] = ()
|
|
108
|
+
renamed_paths: tuple[tuple[PurePosixPath, PurePosixPath], ...] = ()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass(frozen=True)
|
|
112
|
+
class LintRequest:
|
|
113
|
+
repository_root: Path
|
|
114
|
+
source: LintSource | DocumentSet
|
|
115
|
+
rule_sets: frozenset[str] = frozenset({"changed"})
|
|
116
|
+
|
|
117
|
+
@classmethod
|
|
118
|
+
def files(
|
|
119
|
+
cls,
|
|
120
|
+
repository_root: Path,
|
|
121
|
+
all_paths: Sequence[Path],
|
|
122
|
+
all_rule_sets: frozenset[str] = frozenset({"changed"}),
|
|
123
|
+
) -> LintRequest:
|
|
124
|
+
"""Build a request for current worktree files.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
repository_root: Repository root.
|
|
128
|
+
all_paths: Files to read.
|
|
129
|
+
all_rule_sets: Rule sets requested by the caller.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
A file-selection request.
|
|
133
|
+
"""
|
|
134
|
+
return cls(repository_root, ExplicitFiles(tuple(all_paths)), all_rule_sets)
|
|
135
|
+
|
|
136
|
+
@classmethod
|
|
137
|
+
def staged(
|
|
138
|
+
cls,
|
|
139
|
+
repository_root: Path,
|
|
140
|
+
all_rule_sets: frozenset[str] = frozenset({"changed"}),
|
|
141
|
+
) -> LintRequest:
|
|
142
|
+
"""Build a request for staged index content.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
repository_root: Repository root.
|
|
146
|
+
all_rule_sets: Rule sets requested by the caller.
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
A staged-selection request.
|
|
150
|
+
"""
|
|
151
|
+
return cls(repository_root, StagedChanges(), all_rule_sets)
|
|
152
|
+
|
|
153
|
+
@classmethod
|
|
154
|
+
def base(
|
|
155
|
+
cls,
|
|
156
|
+
repository_root: Path,
|
|
157
|
+
revision: str,
|
|
158
|
+
all_rule_sets: frozenset[str] = frozenset({"changed"}),
|
|
159
|
+
) -> LintRequest:
|
|
160
|
+
"""Build a request for changes against a base revision.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
repository_root: Repository root.
|
|
164
|
+
revision: Revision that identifies the merge-base comparison.
|
|
165
|
+
all_rule_sets: Rule sets requested by the caller.
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
A base-selection request.
|
|
169
|
+
"""
|
|
170
|
+
return cls(repository_root, BaseChanges(revision), all_rule_sets)
|
|
171
|
+
|
|
172
|
+
@classmethod
|
|
173
|
+
def repository(
|
|
174
|
+
cls,
|
|
175
|
+
repository_root: Path,
|
|
176
|
+
all_rule_sets: frozenset[str] = frozenset({"repository"}),
|
|
177
|
+
) -> LintRequest:
|
|
178
|
+
"""Build a request for tracked worktree files.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
repository_root: Repository root.
|
|
182
|
+
all_rule_sets: Rule sets requested by the caller.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
A repository-selection request.
|
|
186
|
+
"""
|
|
187
|
+
return cls(repository_root, RepositoryTree(), all_rule_sets)
|
|
188
|
+
|
|
189
|
+
@classmethod
|
|
190
|
+
def documents(
|
|
191
|
+
cls,
|
|
192
|
+
repository_root: Path,
|
|
193
|
+
all_documents: Sequence[Document],
|
|
194
|
+
all_rule_sets: frozenset[str] = frozenset({"changed"}),
|
|
195
|
+
) -> LintRequest:
|
|
196
|
+
"""Build a request from already selected documents.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
repository_root: Repository root.
|
|
200
|
+
all_documents: Documents available to rules.
|
|
201
|
+
all_rule_sets: Rule sets requested by the caller.
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
A document-set request.
|
|
205
|
+
"""
|
|
206
|
+
document_set = DocumentSet(
|
|
207
|
+
tuple(all_documents), SelectionKind.TEXT, repository_root
|
|
208
|
+
)
|
|
209
|
+
return cls(repository_root, document_set, all_rule_sets)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@dataclass(frozen=True)
|
|
213
|
+
class Location:
|
|
214
|
+
path: PurePosixPath
|
|
215
|
+
start_line: int
|
|
216
|
+
start_column: int = 1
|
|
217
|
+
end_line: int | None = None
|
|
218
|
+
end_column: int | None = None
|
|
219
|
+
|
|
220
|
+
def normalized(self) -> Location:
|
|
221
|
+
"""Fill omitted range endpoints with the start position.
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
A location with complete source-range endpoints.
|
|
225
|
+
"""
|
|
226
|
+
return Location(
|
|
227
|
+
self.path,
|
|
228
|
+
self.start_line,
|
|
229
|
+
self.start_column,
|
|
230
|
+
self.end_line if self.end_line is not None else self.start_line,
|
|
231
|
+
self.end_column if self.end_column is not None else self.start_column,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
def as_dict(self) -> dict[str, object]:
|
|
235
|
+
"""Return a JSON-compatible source location.
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
The normalized location mapping.
|
|
239
|
+
"""
|
|
240
|
+
normalized_location = self.normalized()
|
|
241
|
+
return {
|
|
242
|
+
"path": normalized_location.path.as_posix(),
|
|
243
|
+
"start_line": normalized_location.start_line,
|
|
244
|
+
"start_column": normalized_location.start_column,
|
|
245
|
+
"end_line": normalized_location.end_line,
|
|
246
|
+
"end_column": normalized_location.end_column,
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@dataclass(frozen=True)
|
|
251
|
+
class Diagnostic:
|
|
252
|
+
rule_id: str
|
|
253
|
+
severity: Severity
|
|
254
|
+
message: str
|
|
255
|
+
location: Location | None = None
|
|
256
|
+
|
|
257
|
+
def as_dict(self) -> dict[str, object]:
|
|
258
|
+
"""Return a JSON-compatible diagnostic.
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
The diagnostic mapping.
|
|
262
|
+
"""
|
|
263
|
+
return {
|
|
264
|
+
"rule_id": self.rule_id,
|
|
265
|
+
"severity": self.severity.value,
|
|
266
|
+
"message": self.message,
|
|
267
|
+
"location": None if self.location is None else self.location.as_dict(),
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
DocumentChecker: TypeAlias = Callable[[Document, Path], Iterable[Diagnostic]]
|
|
272
|
+
DocumentSetChecker: TypeAlias = Callable[[DocumentSet], Iterable[Diagnostic]]
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@dataclass(frozen=True)
|
|
276
|
+
class DocumentRule:
|
|
277
|
+
rule_id: str
|
|
278
|
+
rule_sets: frozenset[str]
|
|
279
|
+
accepts: Callable[[Document], bool]
|
|
280
|
+
check: DocumentChecker
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@dataclass(frozen=True)
|
|
284
|
+
class ChangeSetRule:
|
|
285
|
+
rule_id: str
|
|
286
|
+
rule_sets: frozenset[str]
|
|
287
|
+
check: DocumentSetChecker
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
@dataclass(frozen=True)
|
|
291
|
+
class RepositoryRule:
|
|
292
|
+
rule_id: str
|
|
293
|
+
rule_sets: frozenset[str]
|
|
294
|
+
check: DocumentSetChecker
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
Rule: TypeAlias = DocumentRule | ChangeSetRule | RepositoryRule
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
@dataclass(frozen=True)
|
|
301
|
+
class EditorDiagnostic:
|
|
302
|
+
path: PurePosixPath
|
|
303
|
+
line: int
|
|
304
|
+
column: int
|
|
305
|
+
message: str
|
|
306
|
+
severity: Severity
|
|
307
|
+
rule_id: str
|
|
308
|
+
|
|
309
|
+
def as_dict(self) -> dict[str, object]:
|
|
310
|
+
"""Return an editor-compatible diagnostic mapping.
|
|
311
|
+
|
|
312
|
+
Returns:
|
|
313
|
+
The editor diagnostic mapping.
|
|
314
|
+
"""
|
|
315
|
+
return {
|
|
316
|
+
"path": self.path.as_posix(),
|
|
317
|
+
"line": self.line,
|
|
318
|
+
"column": self.column,
|
|
319
|
+
"message": self.message,
|
|
320
|
+
"severity": self.severity.value,
|
|
321
|
+
"rule_id": self.rule_id,
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
@dataclass(frozen=True)
|
|
326
|
+
class LintReport:
|
|
327
|
+
schema_version: int
|
|
328
|
+
diagnostics: tuple[Diagnostic, ...]
|
|
329
|
+
checked_documents: tuple[PurePosixPath, ...]
|
|
330
|
+
executed_rules: tuple[str, ...]
|
|
331
|
+
failed_rules: tuple[str, ...]
|
|
332
|
+
skipped_rules: tuple[str, ...]
|
|
333
|
+
|
|
334
|
+
@property
|
|
335
|
+
def exit_code(self) -> int:
|
|
336
|
+
"""Return the process exit code for this report.
|
|
337
|
+
|
|
338
|
+
Returns:
|
|
339
|
+
Zero for clean output, one for findings, or three for rule failure.
|
|
340
|
+
"""
|
|
341
|
+
if self.failed_rules:
|
|
342
|
+
return INCOMPLETE_EXIT_CODE
|
|
343
|
+
if self.diagnostics:
|
|
344
|
+
return 1
|
|
345
|
+
return 0
|
|
346
|
+
|
|
347
|
+
def as_dict(self) -> dict[str, object]:
|
|
348
|
+
"""Return the JSON result.
|
|
349
|
+
|
|
350
|
+
Returns:
|
|
351
|
+
The complete report mapping.
|
|
352
|
+
"""
|
|
353
|
+
return {
|
|
354
|
+
"schema_version": self.schema_version,
|
|
355
|
+
"diagnostics": [
|
|
356
|
+
each_diagnostic.as_dict() for each_diagnostic in self.diagnostics
|
|
357
|
+
],
|
|
358
|
+
"checked_documents": [
|
|
359
|
+
each_path.as_posix() for each_path in self.checked_documents
|
|
360
|
+
],
|
|
361
|
+
"executed_rules": list(self.executed_rules),
|
|
362
|
+
"failed_rules": list(self.failed_rules),
|
|
363
|
+
"skipped_rules": list(self.skipped_rules),
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
def editor_diagnostics(self) -> tuple[EditorDiagnostic, ...]:
|
|
367
|
+
"""Return source-located diagnostics in editor wire format.
|
|
368
|
+
|
|
369
|
+
Returns:
|
|
370
|
+
Diagnostics that carry a source location.
|
|
371
|
+
"""
|
|
372
|
+
all_editor_diagnostics: list[EditorDiagnostic] = []
|
|
373
|
+
for each_diagnostic in self.diagnostics:
|
|
374
|
+
if each_diagnostic.location is None:
|
|
375
|
+
continue
|
|
376
|
+
normalized_location = each_diagnostic.location.normalized()
|
|
377
|
+
all_editor_diagnostics.append(
|
|
378
|
+
EditorDiagnostic(
|
|
379
|
+
path=normalized_location.path,
|
|
380
|
+
line=normalized_location.start_line,
|
|
381
|
+
column=normalized_location.start_column,
|
|
382
|
+
message=each_diagnostic.message,
|
|
383
|
+
severity=each_diagnostic.severity,
|
|
384
|
+
rule_id=each_diagnostic.rule_id,
|
|
385
|
+
)
|
|
386
|
+
)
|
|
387
|
+
return tuple(all_editor_diagnostics)
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from pathlib import PurePosixPath
|
|
5
|
+
|
|
6
|
+
from . import adapters, model
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def build_document_rule(
|
|
10
|
+
rule_id: str,
|
|
11
|
+
accepts: Callable[[model.Document], bool],
|
|
12
|
+
check: model.DocumentChecker,
|
|
13
|
+
) -> model.DocumentRule:
|
|
14
|
+
"""Build a document rule for changed and repository selections.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
rule_id: Stable rule identifier.
|
|
18
|
+
accepts: Predicate for documents handled by the rule.
|
|
19
|
+
check: Pure document checker.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
A configured document rule.
|
|
23
|
+
"""
|
|
24
|
+
return model.DocumentRule(
|
|
25
|
+
rule_id, frozenset({"changed", "repository"}), accepts, check
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def repository_path_diagnostics(
|
|
30
|
+
document_set: model.DocumentSet,
|
|
31
|
+
) -> tuple[model.Diagnostic, ...]:
|
|
32
|
+
"""Report tracked paths that differ only by case.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
document_set: Repository documents to compare.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Path collision diagnostics.
|
|
39
|
+
"""
|
|
40
|
+
path_by_lower_name: dict[str, PurePosixPath] = {}
|
|
41
|
+
all_diagnostics: list[model.Diagnostic] = []
|
|
42
|
+
for each_document in document_set.documents:
|
|
43
|
+
lower_name = each_document.path.as_posix().lower()
|
|
44
|
+
previous_path = path_by_lower_name.get(lower_name)
|
|
45
|
+
if previous_path is not None and previous_path != each_document.path:
|
|
46
|
+
all_diagnostics.append(
|
|
47
|
+
model.Diagnostic(
|
|
48
|
+
"repository-path-collision",
|
|
49
|
+
model.Severity.ERROR,
|
|
50
|
+
f"Repository paths differ only by case: {previous_path}",
|
|
51
|
+
model.Location(each_document.path, 1, 1),
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
path_by_lower_name[lower_name] = each_document.path
|
|
55
|
+
return tuple(all_diagnostics)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _python_document_rules() -> tuple[model.Rule, ...]:
|
|
59
|
+
return (
|
|
60
|
+
build_document_rule(
|
|
61
|
+
"validators", adapters.accepts_python, adapters.validator_diagnostics
|
|
62
|
+
),
|
|
63
|
+
build_document_rule(
|
|
64
|
+
"code-rules", adapters.accepts_code, adapters.code_rule_diagnostics
|
|
65
|
+
),
|
|
66
|
+
build_document_rule(
|
|
67
|
+
"docstring-gate-count",
|
|
68
|
+
adapters.accepts_markdown,
|
|
69
|
+
adapters.docstring_gate_diagnostics,
|
|
70
|
+
),
|
|
71
|
+
build_document_rule(
|
|
72
|
+
"rmtree-safety", adapters.accepts_python, adapters.rmtree_diagnostics
|
|
73
|
+
),
|
|
74
|
+
build_document_rule(
|
|
75
|
+
"subprocess-budget",
|
|
76
|
+
adapters.accepts_python,
|
|
77
|
+
adapters.subprocess_budget_diagnostics,
|
|
78
|
+
),
|
|
79
|
+
build_document_rule(
|
|
80
|
+
"hook-prose-consistency",
|
|
81
|
+
adapters.accepts_python,
|
|
82
|
+
adapters.hook_prose_diagnostics,
|
|
83
|
+
),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _text_document_rules() -> tuple[model.Rule, ...]:
|
|
88
|
+
return (
|
|
89
|
+
build_document_rule(
|
|
90
|
+
"state-description",
|
|
91
|
+
adapters.accepts_source_or_markdown,
|
|
92
|
+
adapters.state_description_diagnostics,
|
|
93
|
+
),
|
|
94
|
+
build_document_rule(
|
|
95
|
+
"plain-language",
|
|
96
|
+
adapters.accepts_stored_prompt,
|
|
97
|
+
adapters.plain_language_diagnostics,
|
|
98
|
+
),
|
|
99
|
+
build_document_rule(
|
|
100
|
+
"open-questions", adapters.accepts_plans, adapters.open_question_diagnostics
|
|
101
|
+
),
|
|
102
|
+
build_document_rule(
|
|
103
|
+
"workflow-substitution",
|
|
104
|
+
adapters.accepts_workflow,
|
|
105
|
+
adapters.workflow_substitution_diagnostics,
|
|
106
|
+
),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _configuration_document_rules() -> tuple[model.Rule, ...]:
|
|
111
|
+
return (
|
|
112
|
+
build_document_rule(
|
|
113
|
+
"hook-configuration",
|
|
114
|
+
adapters.accepts_hook_configuration,
|
|
115
|
+
adapters.hook_configuration_diagnostics,
|
|
116
|
+
),
|
|
117
|
+
build_document_rule(
|
|
118
|
+
"hook-format",
|
|
119
|
+
adapters.accepts_hook_format,
|
|
120
|
+
adapters.hook_format_diagnostics,
|
|
121
|
+
),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def default_registry() -> tuple[model.Rule, ...]:
|
|
126
|
+
"""Return the default policy rules in a fixed order.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
The default policy rules.
|
|
130
|
+
"""
|
|
131
|
+
return (
|
|
132
|
+
*_python_document_rules(),
|
|
133
|
+
*_text_document_rules(),
|
|
134
|
+
*_configuration_document_rules(),
|
|
135
|
+
model.ChangeSetRule(
|
|
136
|
+
"test-pairing", frozenset({"changed"}), adapters.test_pairing_diagnostics
|
|
137
|
+
),
|
|
138
|
+
model.RepositoryRule(
|
|
139
|
+
"repository-path-collision",
|
|
140
|
+
frozenset({"repository"}),
|
|
141
|
+
repository_path_diagnostics,
|
|
142
|
+
),
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def selected_rules(
|
|
147
|
+
all_rules: tuple[model.Rule, ...], all_rule_sets: frozenset[str]
|
|
148
|
+
) -> tuple[model.Rule, ...]:
|
|
149
|
+
"""Select rules that belong to one requested rule set.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
all_rules: Candidate registry entries.
|
|
153
|
+
all_rule_sets: Rule sets requested by the caller.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
Matching rules in registry order.
|
|
157
|
+
"""
|
|
158
|
+
return tuple(
|
|
159
|
+
each_rule
|
|
160
|
+
for each_rule in all_rules
|
|
161
|
+
if each_rule.rule_sets.intersection(all_rule_sets)
|
|
162
|
+
)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Iterable
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
|
|
7
|
+
from .model import Diagnostic, EditorDiagnostic, LintReport
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ReportFormat(StrEnum):
|
|
11
|
+
TEXT = "text"
|
|
12
|
+
JSON = "json"
|
|
13
|
+
EDITOR = "editor"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def render(report: LintReport, report_format: ReportFormat) -> str:
|
|
17
|
+
"""Render a lint report.
|
|
18
|
+
|
|
19
|
+
::
|
|
20
|
+
|
|
21
|
+
json -> {"diagnostics": [...], "schema_version": 1, ...}
|
|
22
|
+
editor -> src/app.py:4:2: error: located finding [rule-a]
|
|
23
|
+
text -> editor lines plus diagnostics with no location
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
report: Lint report from the engine.
|
|
27
|
+
report_format: text, json, or editor.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
The formatted report body.
|
|
31
|
+
"""
|
|
32
|
+
if report_format is ReportFormat.JSON:
|
|
33
|
+
return _render_json(report)
|
|
34
|
+
if report_format is ReportFormat.EDITOR:
|
|
35
|
+
return _render_editor(report)
|
|
36
|
+
return _render_text(report)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _render_json(report: LintReport) -> str:
|
|
40
|
+
line_separator = "\n"
|
|
41
|
+
return json.dumps(report.as_dict(), sort_keys=True) + line_separator
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _render_editor(report: LintReport) -> str:
|
|
45
|
+
all_lines = [
|
|
46
|
+
_editor_line(each_diagnostic)
|
|
47
|
+
for each_diagnostic in report.editor_diagnostics()
|
|
48
|
+
]
|
|
49
|
+
all_lines.extend(
|
|
50
|
+
f"cde-lint:1:1: error: rule failed: {each_rule_id}"
|
|
51
|
+
for each_rule_id in report.failed_rules
|
|
52
|
+
)
|
|
53
|
+
return _join_lines(all_lines)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _render_text(report: LintReport) -> str:
|
|
57
|
+
all_lines = [
|
|
58
|
+
_text_line(each_diagnostic) for each_diagnostic in report.diagnostics
|
|
59
|
+
]
|
|
60
|
+
all_lines.extend(
|
|
61
|
+
f"error: rule failed: {each_rule_id}" for each_rule_id in report.failed_rules
|
|
62
|
+
)
|
|
63
|
+
return _join_lines(all_lines)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _join_lines(all_lines: Iterable[str]) -> str:
|
|
67
|
+
line_separator = "\n"
|
|
68
|
+
all_collected_lines = list(all_lines)
|
|
69
|
+
if not all_collected_lines:
|
|
70
|
+
return ""
|
|
71
|
+
return line_separator.join(all_collected_lines) + line_separator
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _editor_line(diagnostic: EditorDiagnostic) -> str:
|
|
75
|
+
return (
|
|
76
|
+
f"{diagnostic.path.as_posix()}:{diagnostic.line}:{diagnostic.column}: "
|
|
77
|
+
f"{diagnostic.severity.value}: {diagnostic.message} [{diagnostic.rule_id}]"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _text_line(diagnostic: Diagnostic) -> str:
|
|
82
|
+
if diagnostic.location is None:
|
|
83
|
+
return (
|
|
84
|
+
f"{diagnostic.severity.value}: {diagnostic.message} [{diagnostic.rule_id}]"
|
|
85
|
+
)
|
|
86
|
+
normalized_location = diagnostic.location.normalized()
|
|
87
|
+
return (
|
|
88
|
+
f"{normalized_location.path.as_posix()}:{normalized_location.start_line}:"
|
|
89
|
+
f"{normalized_location.start_column}: {diagnostic.severity.value}: "
|
|
90
|
+
f"{diagnostic.message} [{diagnostic.rule_id}]"
|
|
91
|
+
)
|