engineering-process 0.1.0__py3-none-any.whl
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.
- engineering_process/__init__.py +3 -0
- engineering_process/_download_worker.py +73 -0
- engineering_process/_supervisor_posix.py +162 -0
- engineering_process/_supervisor_windows.py +148 -0
- engineering_process/_windows_job.py +419 -0
- engineering_process/bootstrap.py +217 -0
- engineering_process/bundles.py +81 -0
- engineering_process/cli.py +1012 -0
- engineering_process/contracts.py +1274 -0
- engineering_process/distribution.py +90 -0
- engineering_process/environment.py +753 -0
- engineering_process/helper_launch.py +37 -0
- engineering_process/lifecycle.py +1024 -0
- engineering_process/managed.py +89 -0
- engineering_process/markdown.py +78 -0
- engineering_process/publication.py +463 -0
- engineering_process/requirements-build.txt +1 -0
- engineering_process/requirements-dev.txt +10 -0
- engineering_process/requirements-runtime.txt +3 -0
- engineering_process/runner.py +191 -0
- engineering_process/runtime.py +55 -0
- engineering_process/skills.py +118 -0
- engineering_process/supervision.py +72 -0
- engineering_process/syncing.py +398 -0
- engineering_process/tooling.py +891 -0
- engineering_process-0.1.0.data/data/share/engineering-process/bundles.json +47 -0
- engineering_process-0.1.0.data/data/share/engineering-process/examples/change.json +26 -0
- engineering_process-0.1.0.data/data/share/engineering-process/examples/plan.json +28 -0
- engineering_process-0.1.0.data/data/share/engineering-process/examples/project.json +54 -0
- engineering_process-0.1.0.data/data/share/engineering-process/examples/review.json +31 -0
- engineering_process-0.1.0.data/data/share/engineering-process/schemas/change.schema.json +88 -0
- engineering_process-0.1.0.data/data/share/engineering-process/schemas/lifecycle.schema.json +122 -0
- engineering_process-0.1.0.data/data/share/engineering-process/schemas/plan.schema.json +105 -0
- engineering_process-0.1.0.data/data/share/engineering-process/schemas/process-lock.schema.json +39 -0
- engineering_process-0.1.0.data/data/share/engineering-process/schemas/project.schema.json +515 -0
- engineering_process-0.1.0.data/data/share/engineering-process/schemas/review.schema.json +140 -0
- engineering_process-0.1.0.data/data/share/engineering-process/schemas/reviewer-attestation.schema.json +30 -0
- engineering_process-0.1.0.data/data/share/engineering-process/schemas/verification.schema.json +120 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/assess-design/SKILL.md +39 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/build-frontend/SKILL.md +38 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/build-frontend-foundation/SKILL.md +36 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/change-api/SKILL.md +36 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/cross-repo-change/SKILL.md +37 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/define-change-contract/SKILL.md +40 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/design-module/SKILL.md +37 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/evolve-process/SKILL.md +37 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/finish-change/SKILL.md +34 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/govern-ui/SKILL.md +40 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/implement-change/SKILL.md +41 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/implement-module/SKILL.md +36 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/implement-use-case/SKILL.md +36 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/integrate-mcp/SKILL.md +37 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/maintain-docs/SKILL.md +35 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/plan-change/SKILL.md +37 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/publish-change/SKILL.md +38 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/review-change/SKILL.md +49 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/run-change/SKILL.md +51 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/run-change/references/execution.md +88 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/run-project-command/SKILL.md +55 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/specify-use-case/SKILL.md +37 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/verify-change/SKILL.md +38 -0
- engineering_process-0.1.0.data/data/share/engineering-process/templates/AGENTS.process.md +17 -0
- engineering_process-0.1.0.data/data/share/engineering-process/templates/PULL_REQUEST_TEMPLATE.md +27 -0
- engineering_process-0.1.0.dist-info/METADATA +387 -0
- engineering_process-0.1.0.dist-info/RECORD +69 -0
- engineering_process-0.1.0.dist-info/WHEEL +5 -0
- engineering_process-0.1.0.dist-info/entry_points.txt +2 -0
- engineering_process-0.1.0.dist-info/licenses/LICENSE +21 -0
- engineering_process-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1274 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path, PurePosixPath
|
|
7
|
+
from typing import Any
|
|
8
|
+
from urllib.parse import urlsplit
|
|
9
|
+
|
|
10
|
+
import regex as bounded_regex
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
NAME_PATTERN = re.compile(r"^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$")
|
|
14
|
+
PROFILE_PATTERN = re.compile(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")
|
|
15
|
+
SEMVER_PATTERN = re.compile(
|
|
16
|
+
r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)"
|
|
17
|
+
r"(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?"
|
|
18
|
+
r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$"
|
|
19
|
+
)
|
|
20
|
+
DIGEST_PATTERN = re.compile(r"^sha256:[0-9a-f]{64}$")
|
|
21
|
+
SKILL_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
|
22
|
+
MUTATION_SCOPES = {
|
|
23
|
+
"host-configuration",
|
|
24
|
+
"network",
|
|
25
|
+
"project-files",
|
|
26
|
+
"user-files",
|
|
27
|
+
}
|
|
28
|
+
TOOL_VERSION_PATTERN = re.compile(r"^[0-9A-Za-z](?:[0-9A-Za-z._+-]*[0-9A-Za-z])?$")
|
|
29
|
+
PLATFORM_PATTERN = re.compile(
|
|
30
|
+
r"^(?:linux-(?:glibc|musl)-(?:x64|arm64)|macos-(?:x64|arm64)|windows-(?:x64|arm64))$"
|
|
31
|
+
)
|
|
32
|
+
COMMAND_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]*$")
|
|
33
|
+
CHECKSUM_PATTERN = re.compile(r"^(?:sha256:[0-9a-f]{64}|sha512:[0-9a-f]{128})$")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ContractError(ValueError):
|
|
37
|
+
"""Raised when a process contract is invalid."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class Check:
|
|
42
|
+
identifier: str
|
|
43
|
+
run: tuple[str, ...]
|
|
44
|
+
timeout_seconds: int
|
|
45
|
+
working_directory: str
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class EnvironmentProbe:
|
|
50
|
+
run: tuple[str, ...]
|
|
51
|
+
timeout_seconds: int
|
|
52
|
+
working_directory: str
|
|
53
|
+
output_stream: str
|
|
54
|
+
output_regex: str | None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class EnvironmentRequirement:
|
|
59
|
+
identifier: str
|
|
60
|
+
description: str
|
|
61
|
+
probe: EnvironmentProbe
|
|
62
|
+
remediation: str
|
|
63
|
+
setup_action: str | None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class ManagedCommand:
|
|
68
|
+
executable: str
|
|
69
|
+
script: str | None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class ManagedToolArtifact:
|
|
74
|
+
platform: str
|
|
75
|
+
url: str
|
|
76
|
+
checksum: str
|
|
77
|
+
archive_format: str
|
|
78
|
+
strip_components: int
|
|
79
|
+
max_download_bytes: int
|
|
80
|
+
max_extracted_bytes: int
|
|
81
|
+
max_files: int
|
|
82
|
+
commands: dict[str, ManagedCommand]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class ManagedTool:
|
|
87
|
+
identifier: str
|
|
88
|
+
version: str
|
|
89
|
+
artifacts: dict[str, ManagedToolArtifact]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True)
|
|
93
|
+
class SetupAction:
|
|
94
|
+
identifier: str
|
|
95
|
+
kind: str
|
|
96
|
+
run: tuple[str, ...]
|
|
97
|
+
tool: str | None
|
|
98
|
+
timeout_seconds: int
|
|
99
|
+
working_directory: str
|
|
100
|
+
mutations: tuple[str, ...]
|
|
101
|
+
requires: tuple[str, ...]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(frozen=True)
|
|
105
|
+
class ProjectEnvironment:
|
|
106
|
+
default_profile: str
|
|
107
|
+
foreground_only: bool
|
|
108
|
+
profiles: dict[str, tuple[str, ...]]
|
|
109
|
+
requirements: dict[str, EnvironmentRequirement]
|
|
110
|
+
managed_tools: dict[str, ManagedTool]
|
|
111
|
+
setup_actions: dict[str, SetupAction]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass(frozen=True)
|
|
115
|
+
class Project:
|
|
116
|
+
identifier: str
|
|
117
|
+
profiles: dict[str, tuple[Check, ...]]
|
|
118
|
+
required_profiles: tuple[str, ...] = ()
|
|
119
|
+
environment: ProjectEnvironment | None = None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass(frozen=True)
|
|
123
|
+
class ProcessLock:
|
|
124
|
+
version: str
|
|
125
|
+
digest: str
|
|
126
|
+
skills: tuple[str, ...]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def read_json(path: Path) -> Any:
|
|
130
|
+
try:
|
|
131
|
+
data = path.read_bytes()
|
|
132
|
+
except OSError as error:
|
|
133
|
+
raise ContractError(f"{path}: cannot read: {error}") from error
|
|
134
|
+
if len(data) > 1_000_000:
|
|
135
|
+
raise ContractError(f"{path}: contract exceeds the 1 MB limit")
|
|
136
|
+
if data.startswith(b"\xef\xbb\xbf"):
|
|
137
|
+
raise ContractError(f"{path}: UTF-8 BOM is not allowed")
|
|
138
|
+
try:
|
|
139
|
+
text = data.decode("utf-8")
|
|
140
|
+
except UnicodeDecodeError as error:
|
|
141
|
+
raise ContractError(f"{path}: must be UTF-8: {error}") from error
|
|
142
|
+
try:
|
|
143
|
+
return json.loads(text)
|
|
144
|
+
except json.JSONDecodeError as error:
|
|
145
|
+
raise ContractError(
|
|
146
|
+
f"{path}:{error.lineno}:{error.colno}: invalid JSON: {error.msg}"
|
|
147
|
+
) from error
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _object(value: Any, path: str) -> dict[str, Any]:
|
|
151
|
+
if not isinstance(value, dict):
|
|
152
|
+
raise ContractError(f"{path}: must be an object")
|
|
153
|
+
return value
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _exact_keys(
|
|
157
|
+
value: dict[str, Any],
|
|
158
|
+
*,
|
|
159
|
+
required: set[str],
|
|
160
|
+
optional: set[str] | None = None,
|
|
161
|
+
path: str,
|
|
162
|
+
) -> None:
|
|
163
|
+
optional = optional or set()
|
|
164
|
+
actual = set(value)
|
|
165
|
+
missing = sorted(required - actual)
|
|
166
|
+
extra = sorted(actual - required - optional)
|
|
167
|
+
if missing:
|
|
168
|
+
raise ContractError(f"{path}: missing properties: {', '.join(missing)}")
|
|
169
|
+
if extra:
|
|
170
|
+
raise ContractError(f"{path}: unknown properties: {', '.join(extra)}")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _string(value: Any, path: str, *, max_length: int = 4096) -> str:
|
|
174
|
+
if not isinstance(value, str) or not value or value != value.strip():
|
|
175
|
+
raise ContractError(f"{path}: must be a non-empty trimmed string")
|
|
176
|
+
if "\x00" in value:
|
|
177
|
+
raise ContractError(f"{path}: must not contain NUL")
|
|
178
|
+
if len(value) > max_length:
|
|
179
|
+
raise ContractError(f"{path}: exceeds {max_length} characters")
|
|
180
|
+
return value
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _string_list(
|
|
184
|
+
value: Any,
|
|
185
|
+
path: str,
|
|
186
|
+
*,
|
|
187
|
+
minimum: int = 1,
|
|
188
|
+
pattern: re.Pattern[str] | None = None,
|
|
189
|
+
) -> list[str]:
|
|
190
|
+
if not isinstance(value, list) or len(value) < minimum:
|
|
191
|
+
raise ContractError(f"{path}: must contain at least {minimum} item(s)")
|
|
192
|
+
result: list[str] = []
|
|
193
|
+
for index, item in enumerate(value):
|
|
194
|
+
text = _string(item, f"{path}[{index}]")
|
|
195
|
+
if pattern is not None and pattern.fullmatch(text) is None:
|
|
196
|
+
raise ContractError(f"{path}[{index}]: has an invalid format")
|
|
197
|
+
result.append(text)
|
|
198
|
+
if len(set(result)) != len(result):
|
|
199
|
+
raise ContractError(f"{path}: duplicate items are not allowed")
|
|
200
|
+
return result
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _schema_version(document: dict[str, Any], path: str) -> None:
|
|
204
|
+
if document.get("schemaVersion") != 1:
|
|
205
|
+
raise ContractError(f"{path}.schemaVersion: must be 1")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _timeout(value: Any, path: str) -> int:
|
|
209
|
+
if (
|
|
210
|
+
isinstance(value, bool)
|
|
211
|
+
or not isinstance(value, int)
|
|
212
|
+
or value < 1
|
|
213
|
+
or value > 86_400
|
|
214
|
+
):
|
|
215
|
+
raise ContractError(f"{path}: must be an integer from 1 to 86400")
|
|
216
|
+
return value
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _working_directory(value: Any, path: str) -> str:
|
|
220
|
+
working_directory = _string(value, path, max_length=512)
|
|
221
|
+
work_path = Path(working_directory)
|
|
222
|
+
if work_path.is_absolute() or ".." in work_path.parts:
|
|
223
|
+
raise ContractError(f"{path}: must stay within the project")
|
|
224
|
+
return working_directory
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _bounded_integer(value: Any, path: str, *, minimum: int, maximum: int) -> int:
|
|
228
|
+
if (
|
|
229
|
+
isinstance(value, bool)
|
|
230
|
+
or not isinstance(value, int)
|
|
231
|
+
or value < minimum
|
|
232
|
+
or value > maximum
|
|
233
|
+
):
|
|
234
|
+
raise ContractError(
|
|
235
|
+
f"{path}: must be an integer from {minimum} to {maximum}"
|
|
236
|
+
)
|
|
237
|
+
return value
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _relative_tool_path(value: Any, path: str, *, strict_portable: bool) -> str:
|
|
241
|
+
text = _string(value, path, max_length=512)
|
|
242
|
+
if not strict_portable:
|
|
243
|
+
legacy_candidate = Path(text)
|
|
244
|
+
if (
|
|
245
|
+
legacy_candidate.is_absolute()
|
|
246
|
+
or ".." in legacy_candidate.parts
|
|
247
|
+
or text in {".", ".."}
|
|
248
|
+
):
|
|
249
|
+
raise ContractError(f"{path}: must be a contained relative file path")
|
|
250
|
+
return legacy_candidate.as_posix()
|
|
251
|
+
candidate = PurePosixPath(text)
|
|
252
|
+
if (
|
|
253
|
+
"\\" in text
|
|
254
|
+
or ":" in text
|
|
255
|
+
or candidate.is_absolute()
|
|
256
|
+
or ".." in candidate.parts
|
|
257
|
+
or text in {".", ".."}
|
|
258
|
+
or text.endswith("/")
|
|
259
|
+
or candidate.as_posix() != text
|
|
260
|
+
):
|
|
261
|
+
raise ContractError(f"{path}: must be a contained relative file path")
|
|
262
|
+
return text
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _https_url(value: Any, path: str) -> str:
|
|
266
|
+
text = _string(value, path, max_length=2048)
|
|
267
|
+
if "\\" in text or any(
|
|
268
|
+
ord(character) < 0x21 or ord(character) > 0x7e for character in text
|
|
269
|
+
):
|
|
270
|
+
raise ContractError(f"{path}: must contain only printable ASCII URI characters")
|
|
271
|
+
try:
|
|
272
|
+
parsed = urlsplit(text)
|
|
273
|
+
parsed_port = parsed.port
|
|
274
|
+
except ValueError as error:
|
|
275
|
+
raise ContractError(f"{path}: invalid HTTPS URL: {error}") from error
|
|
276
|
+
if (
|
|
277
|
+
parsed.scheme != "https"
|
|
278
|
+
or not parsed.hostname
|
|
279
|
+
or parsed.username is not None
|
|
280
|
+
or parsed.password is not None
|
|
281
|
+
or parsed.fragment
|
|
282
|
+
):
|
|
283
|
+
raise ContractError(
|
|
284
|
+
f"{path}: must be an HTTPS URL without credentials or a fragment"
|
|
285
|
+
)
|
|
286
|
+
if parsed_port == 0:
|
|
287
|
+
raise ContractError(f"{path}: HTTPS port must be from 1 to 65535")
|
|
288
|
+
return text
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _validate_environment(
|
|
292
|
+
document: Any,
|
|
293
|
+
path: str,
|
|
294
|
+
*,
|
|
295
|
+
require_foreground_only: bool,
|
|
296
|
+
require_native_windows_commands: bool,
|
|
297
|
+
) -> ProjectEnvironment:
|
|
298
|
+
value = _object(document, path)
|
|
299
|
+
required = {
|
|
300
|
+
"defaultProfile",
|
|
301
|
+
"managedTools",
|
|
302
|
+
"profiles",
|
|
303
|
+
"requirements",
|
|
304
|
+
"setupActions",
|
|
305
|
+
}
|
|
306
|
+
if require_foreground_only:
|
|
307
|
+
required.add("foregroundOnly")
|
|
308
|
+
_exact_keys(
|
|
309
|
+
value,
|
|
310
|
+
required=required,
|
|
311
|
+
path=path,
|
|
312
|
+
)
|
|
313
|
+
default_profile = _string(
|
|
314
|
+
value["defaultProfile"], f"{path}.defaultProfile", max_length=64
|
|
315
|
+
)
|
|
316
|
+
if PROFILE_PATTERN.fullmatch(default_profile) is None:
|
|
317
|
+
raise ContractError(f"{path}.defaultProfile: invalid profile name")
|
|
318
|
+
if require_foreground_only and value["foregroundOnly"] is not True:
|
|
319
|
+
raise ContractError(f"{path}.foregroundOnly: must attest true")
|
|
320
|
+
|
|
321
|
+
raw_tools = value["managedTools"]
|
|
322
|
+
if not isinstance(raw_tools, list):
|
|
323
|
+
raise ContractError(f"{path}.managedTools: must be an array")
|
|
324
|
+
if len(raw_tools) > 64:
|
|
325
|
+
raise ContractError(f"{path}.managedTools: exceeds 64 items")
|
|
326
|
+
managed_tools: dict[str, ManagedTool] = {}
|
|
327
|
+
for tool_index, raw_tool in enumerate(raw_tools):
|
|
328
|
+
tool_path = f"{path}.managedTools[{tool_index}]"
|
|
329
|
+
tool = _object(raw_tool, tool_path)
|
|
330
|
+
_exact_keys(
|
|
331
|
+
tool,
|
|
332
|
+
required={"id", "version", "artifacts"},
|
|
333
|
+
path=tool_path,
|
|
334
|
+
)
|
|
335
|
+
identifier = _string(tool["id"], f"{tool_path}.id", max_length=64)
|
|
336
|
+
if PROFILE_PATTERN.fullmatch(identifier) is None:
|
|
337
|
+
raise ContractError(f"{tool_path}.id: invalid tool name")
|
|
338
|
+
if identifier in managed_tools:
|
|
339
|
+
raise ContractError(f"{path}.managedTools: duplicate tool id {identifier}")
|
|
340
|
+
version = _string(tool["version"], f"{tool_path}.version", max_length=128)
|
|
341
|
+
if TOOL_VERSION_PATTERN.fullmatch(version) is None:
|
|
342
|
+
raise ContractError(f"{tool_path}.version: invalid portable version")
|
|
343
|
+
raw_artifacts = tool["artifacts"]
|
|
344
|
+
if not isinstance(raw_artifacts, list) or not raw_artifacts:
|
|
345
|
+
raise ContractError(f"{tool_path}.artifacts: must contain at least one item")
|
|
346
|
+
if len(raw_artifacts) > 16:
|
|
347
|
+
raise ContractError(f"{tool_path}.artifacts: exceeds 16 items")
|
|
348
|
+
artifacts: dict[str, ManagedToolArtifact] = {}
|
|
349
|
+
for artifact_index, raw_artifact in enumerate(raw_artifacts):
|
|
350
|
+
artifact_path = f"{tool_path}.artifacts[{artifact_index}]"
|
|
351
|
+
artifact = _object(raw_artifact, artifact_path)
|
|
352
|
+
_exact_keys(
|
|
353
|
+
artifact,
|
|
354
|
+
required={
|
|
355
|
+
"archiveFormat",
|
|
356
|
+
"checksum",
|
|
357
|
+
"commands",
|
|
358
|
+
"maxDownloadBytes",
|
|
359
|
+
"maxExtractedBytes",
|
|
360
|
+
"maxFiles",
|
|
361
|
+
"platform",
|
|
362
|
+
"stripComponents",
|
|
363
|
+
"url",
|
|
364
|
+
},
|
|
365
|
+
path=artifact_path,
|
|
366
|
+
)
|
|
367
|
+
platform_name = _string(
|
|
368
|
+
artifact["platform"], f"{artifact_path}.platform", max_length=32
|
|
369
|
+
)
|
|
370
|
+
if PLATFORM_PATTERN.fullmatch(platform_name) is None:
|
|
371
|
+
raise ContractError(f"{artifact_path}.platform: unsupported platform")
|
|
372
|
+
if platform_name in artifacts:
|
|
373
|
+
raise ContractError(
|
|
374
|
+
f"{tool_path}.artifacts: duplicate platform {platform_name}"
|
|
375
|
+
)
|
|
376
|
+
archive_format = artifact["archiveFormat"]
|
|
377
|
+
if archive_format not in {"file", "tar.gz", "zip"}:
|
|
378
|
+
raise ContractError(
|
|
379
|
+
f"{artifact_path}.archiveFormat: unsupported format"
|
|
380
|
+
)
|
|
381
|
+
strip_components = _bounded_integer(
|
|
382
|
+
artifact["stripComponents"],
|
|
383
|
+
f"{artifact_path}.stripComponents",
|
|
384
|
+
minimum=0,
|
|
385
|
+
maximum=1,
|
|
386
|
+
)
|
|
387
|
+
raw_commands = _object(artifact["commands"], f"{artifact_path}.commands")
|
|
388
|
+
if not raw_commands:
|
|
389
|
+
raise ContractError(
|
|
390
|
+
f"{artifact_path}.commands: must define at least one command"
|
|
391
|
+
)
|
|
392
|
+
commands: dict[str, ManagedCommand] = {}
|
|
393
|
+
for command_name, raw_command in raw_commands.items():
|
|
394
|
+
if COMMAND_PATTERN.fullmatch(command_name) is None:
|
|
395
|
+
raise ContractError(
|
|
396
|
+
f"{artifact_path}.commands.{command_name}: invalid command name"
|
|
397
|
+
)
|
|
398
|
+
command_path = f"{artifact_path}.commands.{command_name}"
|
|
399
|
+
if isinstance(raw_command, str):
|
|
400
|
+
executable = _relative_tool_path(
|
|
401
|
+
raw_command,
|
|
402
|
+
command_path,
|
|
403
|
+
strict_portable=require_native_windows_commands,
|
|
404
|
+
)
|
|
405
|
+
script = None
|
|
406
|
+
elif require_native_windows_commands:
|
|
407
|
+
command = _object(raw_command, command_path)
|
|
408
|
+
_exact_keys(
|
|
409
|
+
command,
|
|
410
|
+
required={"executable", "script"},
|
|
411
|
+
path=command_path,
|
|
412
|
+
)
|
|
413
|
+
executable = _relative_tool_path(
|
|
414
|
+
command["executable"],
|
|
415
|
+
f"{command_path}.executable",
|
|
416
|
+
strict_portable=True,
|
|
417
|
+
)
|
|
418
|
+
script = _relative_tool_path(
|
|
419
|
+
command["script"],
|
|
420
|
+
f"{command_path}.script",
|
|
421
|
+
strict_portable=True,
|
|
422
|
+
)
|
|
423
|
+
if executable == script:
|
|
424
|
+
raise ContractError(
|
|
425
|
+
f"{command_path}: executable and script must differ"
|
|
426
|
+
)
|
|
427
|
+
else:
|
|
428
|
+
raise ContractError(
|
|
429
|
+
f"{command_path}: legacy manifests require a relative command path"
|
|
430
|
+
)
|
|
431
|
+
basename = Path(executable).name
|
|
432
|
+
if platform_name.startswith("windows-"):
|
|
433
|
+
if require_native_windows_commands:
|
|
434
|
+
if Path(executable).suffix.casefold() != ".exe":
|
|
435
|
+
raise ContractError(
|
|
436
|
+
f"{command_path}: executable must be a native .exe"
|
|
437
|
+
)
|
|
438
|
+
allowed_basenames = {
|
|
439
|
+
basename.casefold()
|
|
440
|
+
if script is not None
|
|
441
|
+
else f"{command_name}.exe".casefold()
|
|
442
|
+
}
|
|
443
|
+
else:
|
|
444
|
+
allowed_basenames = {
|
|
445
|
+
command_name.casefold(),
|
|
446
|
+
f"{command_name}.bat".casefold(),
|
|
447
|
+
f"{command_name}.cmd".casefold(),
|
|
448
|
+
f"{command_name}.exe".casefold(),
|
|
449
|
+
}
|
|
450
|
+
else:
|
|
451
|
+
allowed_basenames = (
|
|
452
|
+
{basename.casefold()}
|
|
453
|
+
if script is not None
|
|
454
|
+
else {command_name.casefold()}
|
|
455
|
+
)
|
|
456
|
+
if basename.casefold() not in allowed_basenames:
|
|
457
|
+
raise ContractError(
|
|
458
|
+
f"{artifact_path}.commands.{command_name}: executable basename "
|
|
459
|
+
+ (
|
|
460
|
+
"must be the matching native .exe command"
|
|
461
|
+
if platform_name.startswith("windows-")
|
|
462
|
+
and require_native_windows_commands
|
|
463
|
+
else "must match the command name"
|
|
464
|
+
)
|
|
465
|
+
)
|
|
466
|
+
commands[command_name] = ManagedCommand(
|
|
467
|
+
executable=executable,
|
|
468
|
+
script=script,
|
|
469
|
+
)
|
|
470
|
+
if list(commands) != sorted(commands):
|
|
471
|
+
raise ContractError(f"{artifact_path}.commands: must be sorted")
|
|
472
|
+
if archive_format == "file" and (
|
|
473
|
+
strip_components != 0
|
|
474
|
+
or len(commands) != 1
|
|
475
|
+
or next(iter(commands.values())).script is not None
|
|
476
|
+
):
|
|
477
|
+
raise ContractError(
|
|
478
|
+
f"{artifact_path}: file artifacts require stripComponents 0 and one direct command"
|
|
479
|
+
)
|
|
480
|
+
checksum = _string(
|
|
481
|
+
artifact["checksum"], f"{artifact_path}.checksum", max_length=136
|
|
482
|
+
)
|
|
483
|
+
if CHECKSUM_PATTERN.fullmatch(checksum) is None:
|
|
484
|
+
raise ContractError(f"{artifact_path}.checksum: invalid checksum")
|
|
485
|
+
artifacts[platform_name] = ManagedToolArtifact(
|
|
486
|
+
platform=platform_name,
|
|
487
|
+
url=_https_url(artifact["url"], f"{artifact_path}.url"),
|
|
488
|
+
checksum=checksum,
|
|
489
|
+
archive_format=archive_format,
|
|
490
|
+
strip_components=strip_components,
|
|
491
|
+
max_download_bytes=_bounded_integer(
|
|
492
|
+
artifact["maxDownloadBytes"],
|
|
493
|
+
f"{artifact_path}.maxDownloadBytes",
|
|
494
|
+
minimum=1,
|
|
495
|
+
maximum=4_294_967_296,
|
|
496
|
+
),
|
|
497
|
+
max_extracted_bytes=_bounded_integer(
|
|
498
|
+
artifact["maxExtractedBytes"],
|
|
499
|
+
f"{artifact_path}.maxExtractedBytes",
|
|
500
|
+
minimum=1,
|
|
501
|
+
maximum=8_589_934_592,
|
|
502
|
+
),
|
|
503
|
+
max_files=_bounded_integer(
|
|
504
|
+
artifact["maxFiles"],
|
|
505
|
+
f"{artifact_path}.maxFiles",
|
|
506
|
+
minimum=1,
|
|
507
|
+
maximum=1_000_000,
|
|
508
|
+
),
|
|
509
|
+
commands=commands,
|
|
510
|
+
)
|
|
511
|
+
if list(artifacts) != sorted(artifacts):
|
|
512
|
+
raise ContractError(f"{tool_path}.artifacts: must be sorted by platform")
|
|
513
|
+
managed_tools[identifier] = ManagedTool(
|
|
514
|
+
identifier=identifier,
|
|
515
|
+
version=version,
|
|
516
|
+
artifacts=artifacts,
|
|
517
|
+
)
|
|
518
|
+
if list(managed_tools) != sorted(managed_tools):
|
|
519
|
+
raise ContractError(f"{path}.managedTools: must be sorted by id")
|
|
520
|
+
|
|
521
|
+
raw_actions = value["setupActions"]
|
|
522
|
+
if not isinstance(raw_actions, list):
|
|
523
|
+
raise ContractError(f"{path}.setupActions: must be an array")
|
|
524
|
+
if len(raw_actions) > 128:
|
|
525
|
+
raise ContractError(f"{path}.setupActions: exceeds 128 items")
|
|
526
|
+
actions: dict[str, SetupAction] = {}
|
|
527
|
+
for index, raw_action in enumerate(raw_actions):
|
|
528
|
+
action_path = f"{path}.setupActions[{index}]"
|
|
529
|
+
action = _object(raw_action, action_path)
|
|
530
|
+
kind = action.get("kind")
|
|
531
|
+
if kind == "command":
|
|
532
|
+
_exact_keys(
|
|
533
|
+
action,
|
|
534
|
+
required={"id", "kind", "run", "timeoutSeconds", "mutations"},
|
|
535
|
+
optional={"workingDirectory", "requires"},
|
|
536
|
+
path=action_path,
|
|
537
|
+
)
|
|
538
|
+
elif kind == "managed-tool":
|
|
539
|
+
_exact_keys(
|
|
540
|
+
action,
|
|
541
|
+
required={"id", "kind", "timeoutSeconds", "tool"},
|
|
542
|
+
optional={"requires"},
|
|
543
|
+
path=action_path,
|
|
544
|
+
)
|
|
545
|
+
else:
|
|
546
|
+
raise ContractError(
|
|
547
|
+
f"{action_path}.kind: must be command or managed-tool"
|
|
548
|
+
)
|
|
549
|
+
identifier = _string(action["id"], f"{action_path}.id", max_length=64)
|
|
550
|
+
if PROFILE_PATTERN.fullmatch(identifier) is None:
|
|
551
|
+
raise ContractError(f"{action_path}.id: invalid action name")
|
|
552
|
+
if identifier in actions:
|
|
553
|
+
raise ContractError(f"{path}.setupActions: duplicate action id {identifier}")
|
|
554
|
+
if kind == "command":
|
|
555
|
+
mutations = _string_list(
|
|
556
|
+
action["mutations"], f"{action_path}.mutations", minimum=1
|
|
557
|
+
)
|
|
558
|
+
invalid_mutations = sorted(set(mutations) - MUTATION_SCOPES)
|
|
559
|
+
if invalid_mutations:
|
|
560
|
+
raise ContractError(
|
|
561
|
+
f"{action_path}.mutations: unsupported scopes: "
|
|
562
|
+
+ ", ".join(invalid_mutations)
|
|
563
|
+
)
|
|
564
|
+
run = tuple(_string_list(action["run"], f"{action_path}.run"))
|
|
565
|
+
tool_identifier = None
|
|
566
|
+
working_directory = _working_directory(
|
|
567
|
+
action.get("workingDirectory", "."),
|
|
568
|
+
f"{action_path}.workingDirectory",
|
|
569
|
+
)
|
|
570
|
+
else:
|
|
571
|
+
mutations = ["network", "user-files"]
|
|
572
|
+
run = ()
|
|
573
|
+
tool_identifier = _string(
|
|
574
|
+
action["tool"], f"{action_path}.tool", max_length=64
|
|
575
|
+
)
|
|
576
|
+
if tool_identifier not in managed_tools:
|
|
577
|
+
raise ContractError(
|
|
578
|
+
f"{action_path}.tool: undefined managed tool {tool_identifier}"
|
|
579
|
+
)
|
|
580
|
+
working_directory = "."
|
|
581
|
+
requires = _string_list(
|
|
582
|
+
action.get("requires", []),
|
|
583
|
+
f"{action_path}.requires",
|
|
584
|
+
minimum=0,
|
|
585
|
+
pattern=PROFILE_PATTERN,
|
|
586
|
+
)
|
|
587
|
+
if mutations != sorted(mutations):
|
|
588
|
+
raise ContractError(f"{action_path}.mutations: must be sorted")
|
|
589
|
+
if requires != sorted(requires):
|
|
590
|
+
raise ContractError(f"{action_path}.requires: must be sorted")
|
|
591
|
+
actions[identifier] = SetupAction(
|
|
592
|
+
identifier=identifier,
|
|
593
|
+
kind=kind,
|
|
594
|
+
run=run,
|
|
595
|
+
tool=tool_identifier,
|
|
596
|
+
timeout_seconds=_timeout(
|
|
597
|
+
action["timeoutSeconds"], f"{action_path}.timeoutSeconds"
|
|
598
|
+
),
|
|
599
|
+
working_directory=working_directory,
|
|
600
|
+
mutations=tuple(sorted(mutations)),
|
|
601
|
+
requires=tuple(requires),
|
|
602
|
+
)
|
|
603
|
+
if list(actions) != sorted(actions):
|
|
604
|
+
raise ContractError(f"{path}.setupActions: must be sorted by id")
|
|
605
|
+
|
|
606
|
+
for identifier, action in actions.items():
|
|
607
|
+
missing = sorted(set(action.requires) - set(actions))
|
|
608
|
+
if missing:
|
|
609
|
+
raise ContractError(
|
|
610
|
+
f"{path}.setupActions.{identifier}.requires: undefined actions: "
|
|
611
|
+
+ ", ".join(missing)
|
|
612
|
+
)
|
|
613
|
+
|
|
614
|
+
visiting: set[str] = set()
|
|
615
|
+
visited: set[str] = set()
|
|
616
|
+
|
|
617
|
+
def visit(identifier: str) -> None:
|
|
618
|
+
if identifier in visiting:
|
|
619
|
+
raise ContractError(
|
|
620
|
+
f"{path}.setupActions: dependency cycle includes {identifier}"
|
|
621
|
+
)
|
|
622
|
+
if identifier in visited:
|
|
623
|
+
return
|
|
624
|
+
visiting.add(identifier)
|
|
625
|
+
for dependency in actions[identifier].requires:
|
|
626
|
+
visit(dependency)
|
|
627
|
+
visiting.remove(identifier)
|
|
628
|
+
visited.add(identifier)
|
|
629
|
+
|
|
630
|
+
for identifier in actions:
|
|
631
|
+
visit(identifier)
|
|
632
|
+
|
|
633
|
+
raw_requirements = value["requirements"]
|
|
634
|
+
if not isinstance(raw_requirements, list) or not raw_requirements:
|
|
635
|
+
raise ContractError(f"{path}.requirements: must contain at least one item")
|
|
636
|
+
if len(raw_requirements) > 128:
|
|
637
|
+
raise ContractError(f"{path}.requirements: exceeds 128 items")
|
|
638
|
+
requirements: dict[str, EnvironmentRequirement] = {}
|
|
639
|
+
for index, raw_requirement in enumerate(raw_requirements):
|
|
640
|
+
requirement_path = f"{path}.requirements[{index}]"
|
|
641
|
+
requirement = _object(raw_requirement, requirement_path)
|
|
642
|
+
_exact_keys(
|
|
643
|
+
requirement,
|
|
644
|
+
required={"id", "description", "probe", "remediation"},
|
|
645
|
+
optional={"setupAction"},
|
|
646
|
+
path=requirement_path,
|
|
647
|
+
)
|
|
648
|
+
identifier = _string(
|
|
649
|
+
requirement["id"], f"{requirement_path}.id", max_length=64
|
|
650
|
+
)
|
|
651
|
+
if PROFILE_PATTERN.fullmatch(identifier) is None:
|
|
652
|
+
raise ContractError(f"{requirement_path}.id: invalid requirement name")
|
|
653
|
+
if identifier in requirements:
|
|
654
|
+
raise ContractError(
|
|
655
|
+
f"{path}.requirements: duplicate requirement id {identifier}"
|
|
656
|
+
)
|
|
657
|
+
raw_probe = _object(requirement["probe"], f"{requirement_path}.probe")
|
|
658
|
+
_exact_keys(
|
|
659
|
+
raw_probe,
|
|
660
|
+
required={"run", "timeoutSeconds", "readOnly"},
|
|
661
|
+
optional={"workingDirectory", "outputRegex", "outputStream"},
|
|
662
|
+
path=f"{requirement_path}.probe",
|
|
663
|
+
)
|
|
664
|
+
if raw_probe["readOnly"] is not True:
|
|
665
|
+
raise ContractError(
|
|
666
|
+
f"{requirement_path}.probe.readOnly: must attest true"
|
|
667
|
+
)
|
|
668
|
+
output_stream = raw_probe.get("outputStream", "combined")
|
|
669
|
+
if output_stream not in {"combined", "stderr", "stdout"}:
|
|
670
|
+
raise ContractError(
|
|
671
|
+
f"{requirement_path}.probe.outputStream: unsupported stream"
|
|
672
|
+
)
|
|
673
|
+
output_regex = raw_probe.get("outputRegex")
|
|
674
|
+
if output_regex is not None:
|
|
675
|
+
output_regex = _string(
|
|
676
|
+
output_regex,
|
|
677
|
+
f"{requirement_path}.probe.outputRegex",
|
|
678
|
+
max_length=1024,
|
|
679
|
+
)
|
|
680
|
+
try:
|
|
681
|
+
bounded_regex.compile(output_regex)
|
|
682
|
+
except bounded_regex.error as error:
|
|
683
|
+
raise ContractError(
|
|
684
|
+
f"{requirement_path}.probe.outputRegex: invalid regex: {error}"
|
|
685
|
+
) from error
|
|
686
|
+
setup_action = requirement.get("setupAction")
|
|
687
|
+
if setup_action is not None:
|
|
688
|
+
setup_action = _string(
|
|
689
|
+
setup_action, f"{requirement_path}.setupAction", max_length=64
|
|
690
|
+
)
|
|
691
|
+
if setup_action not in actions:
|
|
692
|
+
raise ContractError(
|
|
693
|
+
f"{requirement_path}.setupAction: undefined action {setup_action}"
|
|
694
|
+
)
|
|
695
|
+
requirements[identifier] = EnvironmentRequirement(
|
|
696
|
+
identifier=identifier,
|
|
697
|
+
description=_string(
|
|
698
|
+
requirement["description"],
|
|
699
|
+
f"{requirement_path}.description",
|
|
700
|
+
max_length=512,
|
|
701
|
+
),
|
|
702
|
+
probe=EnvironmentProbe(
|
|
703
|
+
run=tuple(
|
|
704
|
+
_string_list(raw_probe["run"], f"{requirement_path}.probe.run")
|
|
705
|
+
),
|
|
706
|
+
timeout_seconds=_timeout(
|
|
707
|
+
raw_probe["timeoutSeconds"],
|
|
708
|
+
f"{requirement_path}.probe.timeoutSeconds",
|
|
709
|
+
),
|
|
710
|
+
working_directory=_working_directory(
|
|
711
|
+
raw_probe.get("workingDirectory", "."),
|
|
712
|
+
f"{requirement_path}.probe.workingDirectory",
|
|
713
|
+
),
|
|
714
|
+
output_stream=output_stream,
|
|
715
|
+
output_regex=output_regex,
|
|
716
|
+
),
|
|
717
|
+
remediation=_string(
|
|
718
|
+
requirement["remediation"],
|
|
719
|
+
f"{requirement_path}.remediation",
|
|
720
|
+
max_length=1024,
|
|
721
|
+
),
|
|
722
|
+
setup_action=setup_action,
|
|
723
|
+
)
|
|
724
|
+
if list(requirements) != sorted(requirements):
|
|
725
|
+
raise ContractError(f"{path}.requirements: must be sorted by id")
|
|
726
|
+
|
|
727
|
+
raw_profiles = _object(value["profiles"], f"{path}.profiles")
|
|
728
|
+
if not raw_profiles:
|
|
729
|
+
raise ContractError(f"{path}.profiles: must define at least one profile")
|
|
730
|
+
profiles: dict[str, tuple[str, ...]] = {}
|
|
731
|
+
for profile_name, raw_ids in raw_profiles.items():
|
|
732
|
+
if PROFILE_PATTERN.fullmatch(profile_name) is None:
|
|
733
|
+
raise ContractError(f"{path}.profiles.{profile_name}: invalid profile name")
|
|
734
|
+
identifiers = _string_list(
|
|
735
|
+
raw_ids,
|
|
736
|
+
f"{path}.profiles.{profile_name}",
|
|
737
|
+
pattern=PROFILE_PATTERN,
|
|
738
|
+
)
|
|
739
|
+
if identifiers != sorted(identifiers):
|
|
740
|
+
raise ContractError(
|
|
741
|
+
f"{path}.profiles.{profile_name}: requirements must be sorted"
|
|
742
|
+
)
|
|
743
|
+
missing = sorted(set(identifiers) - set(requirements))
|
|
744
|
+
if missing:
|
|
745
|
+
raise ContractError(
|
|
746
|
+
f"{path}.profiles.{profile_name}: undefined requirements: "
|
|
747
|
+
+ ", ".join(missing)
|
|
748
|
+
)
|
|
749
|
+
profiles[profile_name] = tuple(identifiers)
|
|
750
|
+
if default_profile not in profiles:
|
|
751
|
+
raise ContractError(f"{path}.defaultProfile: profile is not defined")
|
|
752
|
+
return ProjectEnvironment(
|
|
753
|
+
default_profile=default_profile,
|
|
754
|
+
foreground_only=require_foreground_only,
|
|
755
|
+
profiles=profiles,
|
|
756
|
+
requirements=requirements,
|
|
757
|
+
managed_tools=managed_tools,
|
|
758
|
+
setup_actions=actions,
|
|
759
|
+
)
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def validate_project(document: Any, path: str = "project") -> Project:
|
|
763
|
+
value = _object(document, path)
|
|
764
|
+
schema_version = value.get("schemaVersion")
|
|
765
|
+
if schema_version not in {1, 2, 3}:
|
|
766
|
+
raise ContractError(f"{path}.schemaVersion: must be 1, 2, or 3")
|
|
767
|
+
_exact_keys(
|
|
768
|
+
value,
|
|
769
|
+
required={"schemaVersion", "project", "lifecycle", "profiles"}
|
|
770
|
+
| ({"environment"} if schema_version >= 2 else set()),
|
|
771
|
+
optional={"$schema"},
|
|
772
|
+
path=path,
|
|
773
|
+
)
|
|
774
|
+
identifier = _string(value["project"], f"{path}.project", max_length=128)
|
|
775
|
+
if NAME_PATTERN.fullmatch(identifier) is None:
|
|
776
|
+
raise ContractError(f"{path}.project: must use lowercase project-id format")
|
|
777
|
+
|
|
778
|
+
lifecycle = _object(value["lifecycle"], f"{path}.lifecycle")
|
|
779
|
+
_exact_keys(
|
|
780
|
+
lifecycle,
|
|
781
|
+
required={"requiredProfiles"},
|
|
782
|
+
path=f"{path}.lifecycle",
|
|
783
|
+
)
|
|
784
|
+
required_profiles = _string_list(
|
|
785
|
+
lifecycle["requiredProfiles"],
|
|
786
|
+
f"{path}.lifecycle.requiredProfiles",
|
|
787
|
+
pattern=PROFILE_PATTERN,
|
|
788
|
+
)
|
|
789
|
+
if required_profiles != sorted(required_profiles):
|
|
790
|
+
raise ContractError(
|
|
791
|
+
f"{path}.lifecycle.requiredProfiles: must be sorted"
|
|
792
|
+
)
|
|
793
|
+
|
|
794
|
+
raw_profiles = _object(value["profiles"], f"{path}.profiles")
|
|
795
|
+
if not raw_profiles:
|
|
796
|
+
raise ContractError(f"{path}.profiles: must define at least one profile")
|
|
797
|
+
profiles: dict[str, tuple[Check, ...]] = {}
|
|
798
|
+
for profile_name, raw_checks in raw_profiles.items():
|
|
799
|
+
if PROFILE_PATTERN.fullmatch(profile_name) is None:
|
|
800
|
+
raise ContractError(
|
|
801
|
+
f"{path}.profiles.{profile_name}: invalid profile name"
|
|
802
|
+
)
|
|
803
|
+
if not isinstance(raw_checks, list) or not raw_checks:
|
|
804
|
+
raise ContractError(
|
|
805
|
+
f"{path}.profiles.{profile_name}: must contain at least one check"
|
|
806
|
+
)
|
|
807
|
+
checks: list[Check] = []
|
|
808
|
+
identifiers: set[str] = set()
|
|
809
|
+
for index, raw_check in enumerate(raw_checks):
|
|
810
|
+
check_path = f"{path}.profiles.{profile_name}[{index}]"
|
|
811
|
+
check = _object(raw_check, check_path)
|
|
812
|
+
_exact_keys(
|
|
813
|
+
check,
|
|
814
|
+
required={"id", "run", "timeoutSeconds"},
|
|
815
|
+
optional={"workingDirectory"},
|
|
816
|
+
path=check_path,
|
|
817
|
+
)
|
|
818
|
+
check_id = _string(check["id"], f"{check_path}.id", max_length=64)
|
|
819
|
+
if PROFILE_PATTERN.fullmatch(check_id) is None:
|
|
820
|
+
raise ContractError(f"{check_path}.id: invalid check name")
|
|
821
|
+
if check_id in identifiers:
|
|
822
|
+
raise ContractError(
|
|
823
|
+
f"{path}.profiles.{profile_name}: duplicate check id {check_id}"
|
|
824
|
+
)
|
|
825
|
+
identifiers.add(check_id)
|
|
826
|
+
argv = _string_list(check["run"], f"{check_path}.run")
|
|
827
|
+
timeout = _timeout(check["timeoutSeconds"], f"{check_path}.timeoutSeconds")
|
|
828
|
+
working_directory = _working_directory(
|
|
829
|
+
check.get("workingDirectory", "."),
|
|
830
|
+
f"{check_path}.workingDirectory",
|
|
831
|
+
)
|
|
832
|
+
checks.append(
|
|
833
|
+
Check(
|
|
834
|
+
identifier=check_id,
|
|
835
|
+
run=tuple(argv),
|
|
836
|
+
timeout_seconds=timeout,
|
|
837
|
+
working_directory=working_directory,
|
|
838
|
+
)
|
|
839
|
+
)
|
|
840
|
+
profiles[profile_name] = tuple(checks)
|
|
841
|
+
missing_required = sorted(set(required_profiles) - set(profiles))
|
|
842
|
+
if missing_required:
|
|
843
|
+
raise ContractError(
|
|
844
|
+
f"{path}.lifecycle.requiredProfiles: undefined profiles: "
|
|
845
|
+
f"{', '.join(missing_required)}"
|
|
846
|
+
)
|
|
847
|
+
environment = (
|
|
848
|
+
_validate_environment(
|
|
849
|
+
value["environment"],
|
|
850
|
+
f"{path}.environment",
|
|
851
|
+
require_foreground_only=schema_version == 3,
|
|
852
|
+
require_native_windows_commands=schema_version == 3,
|
|
853
|
+
)
|
|
854
|
+
if schema_version >= 2
|
|
855
|
+
else None
|
|
856
|
+
)
|
|
857
|
+
if environment is not None:
|
|
858
|
+
missing_environment_profiles = sorted(set(profiles) - set(environment.profiles))
|
|
859
|
+
if missing_environment_profiles:
|
|
860
|
+
raise ContractError(
|
|
861
|
+
f"{path}.environment.profiles: missing verification profiles: "
|
|
862
|
+
+ ", ".join(missing_environment_profiles)
|
|
863
|
+
)
|
|
864
|
+
return Project(
|
|
865
|
+
identifier=identifier,
|
|
866
|
+
profiles=profiles,
|
|
867
|
+
required_profiles=tuple(required_profiles),
|
|
868
|
+
environment=environment,
|
|
869
|
+
)
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
def validate_process_lock(document: Any, path: str = "process.lock") -> ProcessLock:
|
|
873
|
+
value = _object(document, path)
|
|
874
|
+
_exact_keys(
|
|
875
|
+
value,
|
|
876
|
+
required={"schemaVersion", "process", "skills"},
|
|
877
|
+
optional={"$schema"},
|
|
878
|
+
path=path,
|
|
879
|
+
)
|
|
880
|
+
_schema_version(value, path)
|
|
881
|
+
process = _object(value["process"], f"{path}.process")
|
|
882
|
+
_exact_keys(
|
|
883
|
+
process,
|
|
884
|
+
required={"version", "digest"},
|
|
885
|
+
path=f"{path}.process",
|
|
886
|
+
)
|
|
887
|
+
version = _string(process["version"], f"{path}.process.version", max_length=64)
|
|
888
|
+
if SEMVER_PATTERN.fullmatch(version) is None:
|
|
889
|
+
raise ContractError(f"{path}.process.version: must be SemVer")
|
|
890
|
+
digest = _string(process["digest"], f"{path}.process.digest", max_length=71)
|
|
891
|
+
if DIGEST_PATTERN.fullmatch(digest) is None:
|
|
892
|
+
raise ContractError(
|
|
893
|
+
f"{path}.process.digest: must be a lowercase sha256 digest"
|
|
894
|
+
)
|
|
895
|
+
skills = _string_list(value["skills"], f"{path}.skills", pattern=SKILL_PATTERN)
|
|
896
|
+
if skills != sorted(skills):
|
|
897
|
+
raise ContractError(f"{path}.skills: must be sorted")
|
|
898
|
+
return ProcessLock(version=version, digest=digest, skills=tuple(skills))
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
def validate_change(document: Any, path: str = "change") -> None:
|
|
902
|
+
value = _object(document, path)
|
|
903
|
+
_exact_keys(
|
|
904
|
+
value,
|
|
905
|
+
required={
|
|
906
|
+
"schemaVersion",
|
|
907
|
+
"id",
|
|
908
|
+
"summary",
|
|
909
|
+
"source",
|
|
910
|
+
"comparisonBase",
|
|
911
|
+
"specification",
|
|
912
|
+
"risk",
|
|
913
|
+
"affectedProjects",
|
|
914
|
+
"acceptanceCriteria",
|
|
915
|
+
"requiredProfiles",
|
|
916
|
+
"signOff",
|
|
917
|
+
},
|
|
918
|
+
optional={"$schema"},
|
|
919
|
+
path=path,
|
|
920
|
+
)
|
|
921
|
+
if value.get("schemaVersion") != 2:
|
|
922
|
+
raise ContractError(f"{path}.schemaVersion: must be 2")
|
|
923
|
+
identifier = _string(value["id"], f"{path}.id", max_length=64)
|
|
924
|
+
if PROFILE_PATTERN.fullmatch(identifier) is None:
|
|
925
|
+
raise ContractError(f"{path}.id: invalid change id")
|
|
926
|
+
_string(value["summary"], f"{path}.summary", max_length=500)
|
|
927
|
+
_string(value["source"], f"{path}.source", max_length=1000)
|
|
928
|
+
_string(value["comparisonBase"], f"{path}.comparisonBase", max_length=256)
|
|
929
|
+
specification = _object(value["specification"], f"{path}.specification")
|
|
930
|
+
_exact_keys(
|
|
931
|
+
specification,
|
|
932
|
+
required={"kind", "reference", "rationale"},
|
|
933
|
+
path=f"{path}.specification",
|
|
934
|
+
)
|
|
935
|
+
if specification["kind"] not in {"project", "change-contract"}:
|
|
936
|
+
raise ContractError(
|
|
937
|
+
f"{path}.specification.kind: must be project or change-contract"
|
|
938
|
+
)
|
|
939
|
+
_string(
|
|
940
|
+
specification["reference"],
|
|
941
|
+
f"{path}.specification.reference",
|
|
942
|
+
max_length=1000,
|
|
943
|
+
)
|
|
944
|
+
_string(
|
|
945
|
+
specification["rationale"],
|
|
946
|
+
f"{path}.specification.rationale",
|
|
947
|
+
max_length=2000,
|
|
948
|
+
)
|
|
949
|
+
if value["risk"] not in {"low", "medium", "high"}:
|
|
950
|
+
raise ContractError(f"{path}.risk: must be low, medium, or high")
|
|
951
|
+
_string_list(
|
|
952
|
+
value["affectedProjects"],
|
|
953
|
+
f"{path}.affectedProjects",
|
|
954
|
+
pattern=NAME_PATTERN,
|
|
955
|
+
)
|
|
956
|
+
_string_list(
|
|
957
|
+
value["requiredProfiles"],
|
|
958
|
+
f"{path}.requiredProfiles",
|
|
959
|
+
pattern=PROFILE_PATTERN,
|
|
960
|
+
)
|
|
961
|
+
|
|
962
|
+
criteria = value["acceptanceCriteria"]
|
|
963
|
+
if not isinstance(criteria, list) or not criteria:
|
|
964
|
+
raise ContractError(f"{path}.acceptanceCriteria: must not be empty")
|
|
965
|
+
criterion_ids: set[str] = set()
|
|
966
|
+
for index, raw_criterion in enumerate(criteria):
|
|
967
|
+
criterion_path = f"{path}.acceptanceCriteria[{index}]"
|
|
968
|
+
criterion = _object(raw_criterion, criterion_path)
|
|
969
|
+
_exact_keys(
|
|
970
|
+
criterion,
|
|
971
|
+
required={"id", "outcome"},
|
|
972
|
+
path=criterion_path,
|
|
973
|
+
)
|
|
974
|
+
identifier = _string(
|
|
975
|
+
criterion["id"], f"{criterion_path}.id", max_length=64
|
|
976
|
+
)
|
|
977
|
+
if PROFILE_PATTERN.fullmatch(identifier) is None:
|
|
978
|
+
raise ContractError(f"{criterion_path}.id: invalid criterion id")
|
|
979
|
+
if identifier in criterion_ids:
|
|
980
|
+
raise ContractError(
|
|
981
|
+
f"{path}.acceptanceCriteria: duplicate id {identifier}"
|
|
982
|
+
)
|
|
983
|
+
criterion_ids.add(identifier)
|
|
984
|
+
_string(criterion["outcome"], f"{criterion_path}.outcome", max_length=1000)
|
|
985
|
+
|
|
986
|
+
sign_off = _object(value["signOff"], f"{path}.signOff")
|
|
987
|
+
_exact_keys(
|
|
988
|
+
sign_off,
|
|
989
|
+
required={"required", "status", "evidence"},
|
|
990
|
+
path=f"{path}.signOff",
|
|
991
|
+
)
|
|
992
|
+
required = sign_off["required"]
|
|
993
|
+
if not isinstance(required, bool):
|
|
994
|
+
raise ContractError(f"{path}.signOff.required: must be boolean")
|
|
995
|
+
status = sign_off["status"]
|
|
996
|
+
allowed_statuses = {"pending", "approved"} if required else {"not-required"}
|
|
997
|
+
if status not in allowed_statuses:
|
|
998
|
+
raise ContractError(
|
|
999
|
+
f"{path}.signOff.status: invalid for required={str(required).lower()}"
|
|
1000
|
+
)
|
|
1001
|
+
evidence = sign_off["evidence"]
|
|
1002
|
+
if status == "approved":
|
|
1003
|
+
_string(evidence, f"{path}.signOff.evidence", max_length=1000)
|
|
1004
|
+
elif evidence is not None:
|
|
1005
|
+
raise ContractError(
|
|
1006
|
+
f"{path}.signOff.evidence: must be null unless status is approved"
|
|
1007
|
+
)
|
|
1008
|
+
|
|
1009
|
+
|
|
1010
|
+
def validate_plan(document: Any, path: str = "plan") -> None:
|
|
1011
|
+
value = _object(document, path)
|
|
1012
|
+
_exact_keys(
|
|
1013
|
+
value,
|
|
1014
|
+
required={
|
|
1015
|
+
"schemaVersion",
|
|
1016
|
+
"changeId",
|
|
1017
|
+
"contractDigest",
|
|
1018
|
+
"approach",
|
|
1019
|
+
"workItems",
|
|
1020
|
+
"acceptancePlan",
|
|
1021
|
+
"risks",
|
|
1022
|
+
"openDecisions",
|
|
1023
|
+
},
|
|
1024
|
+
optional={"$schema"},
|
|
1025
|
+
path=path,
|
|
1026
|
+
)
|
|
1027
|
+
_schema_version(value, path)
|
|
1028
|
+
change_id = _string(value["changeId"], f"{path}.changeId", max_length=64)
|
|
1029
|
+
if PROFILE_PATTERN.fullmatch(change_id) is None:
|
|
1030
|
+
raise ContractError(f"{path}.changeId: invalid change id")
|
|
1031
|
+
digest = _string(
|
|
1032
|
+
value["contractDigest"], f"{path}.contractDigest", max_length=71
|
|
1033
|
+
)
|
|
1034
|
+
if DIGEST_PATTERN.fullmatch(digest) is None:
|
|
1035
|
+
raise ContractError(f"{path}.contractDigest: must be a lowercase sha256 digest")
|
|
1036
|
+
_string(value["approach"], f"{path}.approach", max_length=4000)
|
|
1037
|
+
|
|
1038
|
+
work_items = value["workItems"]
|
|
1039
|
+
if not isinstance(work_items, list) or not work_items:
|
|
1040
|
+
raise ContractError(f"{path}.workItems: must not be empty")
|
|
1041
|
+
work_item_ids: set[str] = set()
|
|
1042
|
+
for index, raw_item in enumerate(work_items):
|
|
1043
|
+
item_path = f"{path}.workItems[{index}]"
|
|
1044
|
+
item = _object(raw_item, item_path)
|
|
1045
|
+
_exact_keys(
|
|
1046
|
+
item,
|
|
1047
|
+
required={"id", "outcome", "affectedPaths", "verificationProfiles"},
|
|
1048
|
+
path=item_path,
|
|
1049
|
+
)
|
|
1050
|
+
item_id = _string(item["id"], f"{item_path}.id", max_length=64)
|
|
1051
|
+
if PROFILE_PATTERN.fullmatch(item_id) is None:
|
|
1052
|
+
raise ContractError(f"{item_path}.id: invalid work-item id")
|
|
1053
|
+
if item_id in work_item_ids:
|
|
1054
|
+
raise ContractError(f"{path}.workItems: duplicate id {item_id}")
|
|
1055
|
+
work_item_ids.add(item_id)
|
|
1056
|
+
_string(item["outcome"], f"{item_path}.outcome", max_length=1000)
|
|
1057
|
+
_string_list(item["affectedPaths"], f"{item_path}.affectedPaths")
|
|
1058
|
+
_string_list(
|
|
1059
|
+
item["verificationProfiles"],
|
|
1060
|
+
f"{item_path}.verificationProfiles",
|
|
1061
|
+
pattern=PROFILE_PATTERN,
|
|
1062
|
+
)
|
|
1063
|
+
|
|
1064
|
+
acceptance_plan = value["acceptancePlan"]
|
|
1065
|
+
if not isinstance(acceptance_plan, list) or not acceptance_plan:
|
|
1066
|
+
raise ContractError(f"{path}.acceptancePlan: must not be empty")
|
|
1067
|
+
criterion_ids: set[str] = set()
|
|
1068
|
+
for index, raw_mapping in enumerate(acceptance_plan):
|
|
1069
|
+
mapping_path = f"{path}.acceptancePlan[{index}]"
|
|
1070
|
+
mapping = _object(raw_mapping, mapping_path)
|
|
1071
|
+
_exact_keys(
|
|
1072
|
+
mapping,
|
|
1073
|
+
required={"criterionId", "workItems", "verificationProfiles"},
|
|
1074
|
+
path=mapping_path,
|
|
1075
|
+
)
|
|
1076
|
+
criterion_id = _string(
|
|
1077
|
+
mapping["criterionId"], f"{mapping_path}.criterionId", max_length=64
|
|
1078
|
+
)
|
|
1079
|
+
if PROFILE_PATTERN.fullmatch(criterion_id) is None:
|
|
1080
|
+
raise ContractError(f"{mapping_path}.criterionId: invalid criterion id")
|
|
1081
|
+
if criterion_id in criterion_ids:
|
|
1082
|
+
raise ContractError(
|
|
1083
|
+
f"{path}.acceptancePlan: duplicate criterion {criterion_id}"
|
|
1084
|
+
)
|
|
1085
|
+
criterion_ids.add(criterion_id)
|
|
1086
|
+
mapped_items = _string_list(
|
|
1087
|
+
mapping["workItems"], f"{mapping_path}.workItems", pattern=PROFILE_PATTERN
|
|
1088
|
+
)
|
|
1089
|
+
unknown_items = sorted(set(mapped_items) - work_item_ids)
|
|
1090
|
+
if unknown_items:
|
|
1091
|
+
raise ContractError(
|
|
1092
|
+
f"{mapping_path}.workItems: unknown ids: {', '.join(unknown_items)}"
|
|
1093
|
+
)
|
|
1094
|
+
_string_list(
|
|
1095
|
+
mapping["verificationProfiles"],
|
|
1096
|
+
f"{mapping_path}.verificationProfiles",
|
|
1097
|
+
pattern=PROFILE_PATTERN,
|
|
1098
|
+
)
|
|
1099
|
+
|
|
1100
|
+
risks = value["risks"]
|
|
1101
|
+
if not isinstance(risks, list):
|
|
1102
|
+
raise ContractError(f"{path}.risks: must be an array")
|
|
1103
|
+
for index, raw_risk in enumerate(risks):
|
|
1104
|
+
risk_path = f"{path}.risks[{index}]"
|
|
1105
|
+
risk = _object(raw_risk, risk_path)
|
|
1106
|
+
_exact_keys(risk, required={"risk", "mitigation"}, path=risk_path)
|
|
1107
|
+
_string(risk["risk"], f"{risk_path}.risk", max_length=1000)
|
|
1108
|
+
_string(risk["mitigation"], f"{risk_path}.mitigation", max_length=1000)
|
|
1109
|
+
|
|
1110
|
+
decisions = value["openDecisions"]
|
|
1111
|
+
if not isinstance(decisions, list):
|
|
1112
|
+
raise ContractError(f"{path}.openDecisions: must be an array")
|
|
1113
|
+
if decisions:
|
|
1114
|
+
_string_list(decisions, f"{path}.openDecisions")
|
|
1115
|
+
|
|
1116
|
+
|
|
1117
|
+
def _validate_actor(value: Any, path: str) -> dict[str, str]:
|
|
1118
|
+
actor = _object(value, path)
|
|
1119
|
+
_exact_keys(actor, required={"actorId", "contextId", "kind"}, path=path)
|
|
1120
|
+
actor_id = _string(actor["actorId"], f"{path}.actorId", max_length=256)
|
|
1121
|
+
context_id = _string(actor["contextId"], f"{path}.contextId", max_length=256)
|
|
1122
|
+
kind = actor["kind"]
|
|
1123
|
+
if kind not in {"agent", "human"}:
|
|
1124
|
+
raise ContractError(f"{path}.kind: must be agent or human")
|
|
1125
|
+
return {"actorId": actor_id, "contextId": context_id, "kind": kind}
|
|
1126
|
+
|
|
1127
|
+
|
|
1128
|
+
def _validate_review(
|
|
1129
|
+
document: Any,
|
|
1130
|
+
path: str = "review",
|
|
1131
|
+
*,
|
|
1132
|
+
allow_legacy_unresolved_approval: bool = False,
|
|
1133
|
+
) -> None:
|
|
1134
|
+
value = _object(document, path)
|
|
1135
|
+
_exact_keys(
|
|
1136
|
+
value,
|
|
1137
|
+
required={
|
|
1138
|
+
"schemaVersion",
|
|
1139
|
+
"changeId",
|
|
1140
|
+
"cycle",
|
|
1141
|
+
"checkpoint",
|
|
1142
|
+
"workspaceFingerprint",
|
|
1143
|
+
"comparisonBase",
|
|
1144
|
+
"reviewer",
|
|
1145
|
+
"independence",
|
|
1146
|
+
"verdict",
|
|
1147
|
+
"findings",
|
|
1148
|
+
},
|
|
1149
|
+
optional={"$schema"},
|
|
1150
|
+
path=path,
|
|
1151
|
+
)
|
|
1152
|
+
if value.get("schemaVersion") != 2:
|
|
1153
|
+
raise ContractError(f"{path}.schemaVersion: must be 2")
|
|
1154
|
+
change_id = _string(value["changeId"], f"{path}.changeId", max_length=64)
|
|
1155
|
+
if PROFILE_PATTERN.fullmatch(change_id) is None:
|
|
1156
|
+
raise ContractError(f"{path}.changeId: invalid change id")
|
|
1157
|
+
cycle = value["cycle"]
|
|
1158
|
+
if isinstance(cycle, bool) or not isinstance(cycle, int) or cycle < 1:
|
|
1159
|
+
raise ContractError(f"{path}.cycle: must be a positive integer")
|
|
1160
|
+
_string(value["checkpoint"], f"{path}.checkpoint", max_length=256)
|
|
1161
|
+
fingerprint = _string(
|
|
1162
|
+
value["workspaceFingerprint"],
|
|
1163
|
+
f"{path}.workspaceFingerprint",
|
|
1164
|
+
max_length=71,
|
|
1165
|
+
)
|
|
1166
|
+
if DIGEST_PATTERN.fullmatch(fingerprint) is None:
|
|
1167
|
+
raise ContractError(
|
|
1168
|
+
f"{path}.workspaceFingerprint: must be a lowercase sha256 digest"
|
|
1169
|
+
)
|
|
1170
|
+
_string(value["comparisonBase"], f"{path}.comparisonBase", max_length=256)
|
|
1171
|
+
_validate_actor(value["reviewer"], f"{path}.reviewer")
|
|
1172
|
+
independence = _object(value["independence"], f"{path}.independence")
|
|
1173
|
+
_exact_keys(
|
|
1174
|
+
independence,
|
|
1175
|
+
required={"method", "attestedBy", "evidence"},
|
|
1176
|
+
path=f"{path}.independence",
|
|
1177
|
+
)
|
|
1178
|
+
if independence["method"] not in {"isolated-context", "separate-person"}:
|
|
1179
|
+
raise ContractError(f"{path}.independence.method: invalid method")
|
|
1180
|
+
_string(
|
|
1181
|
+
independence["attestedBy"],
|
|
1182
|
+
f"{path}.independence.attestedBy",
|
|
1183
|
+
max_length=256,
|
|
1184
|
+
)
|
|
1185
|
+
_string(
|
|
1186
|
+
independence["evidence"],
|
|
1187
|
+
f"{path}.independence.evidence",
|
|
1188
|
+
max_length=2000,
|
|
1189
|
+
)
|
|
1190
|
+
verdict = value["verdict"]
|
|
1191
|
+
if verdict not in {"approved", "changes-requested"}:
|
|
1192
|
+
raise ContractError(
|
|
1193
|
+
f"{path}.verdict: must be approved or changes-requested"
|
|
1194
|
+
)
|
|
1195
|
+
findings = value["findings"]
|
|
1196
|
+
if not isinstance(findings, list):
|
|
1197
|
+
raise ContractError(f"{path}.findings: must be an array")
|
|
1198
|
+
finding_ids: set[str] = set()
|
|
1199
|
+
unresolved_findings = 0
|
|
1200
|
+
for index, raw_finding in enumerate(findings):
|
|
1201
|
+
finding_path = f"{path}.findings[{index}]"
|
|
1202
|
+
finding = _object(raw_finding, finding_path)
|
|
1203
|
+
_exact_keys(
|
|
1204
|
+
finding,
|
|
1205
|
+
required={
|
|
1206
|
+
"id",
|
|
1207
|
+
"severity",
|
|
1208
|
+
"path",
|
|
1209
|
+
"line",
|
|
1210
|
+
"summary",
|
|
1211
|
+
"evidence",
|
|
1212
|
+
"status",
|
|
1213
|
+
"resolutionEvidence",
|
|
1214
|
+
},
|
|
1215
|
+
path=finding_path,
|
|
1216
|
+
)
|
|
1217
|
+
identifier = _string(finding["id"], f"{finding_path}.id", max_length=64)
|
|
1218
|
+
if PROFILE_PATTERN.fullmatch(identifier) is None:
|
|
1219
|
+
raise ContractError(f"{finding_path}.id: invalid finding id")
|
|
1220
|
+
if identifier in finding_ids:
|
|
1221
|
+
raise ContractError(f"{path}.findings: duplicate id {identifier}")
|
|
1222
|
+
finding_ids.add(identifier)
|
|
1223
|
+
if finding["severity"] not in {"critical", "high", "medium", "low"}:
|
|
1224
|
+
raise ContractError(f"{finding_path}.severity: invalid severity")
|
|
1225
|
+
_string(finding["path"], f"{finding_path}.path", max_length=1000)
|
|
1226
|
+
line = finding["line"]
|
|
1227
|
+
if line is not None and (
|
|
1228
|
+
isinstance(line, bool) or not isinstance(line, int) or line < 1
|
|
1229
|
+
):
|
|
1230
|
+
raise ContractError(
|
|
1231
|
+
f"{finding_path}.line: must be null or a positive integer"
|
|
1232
|
+
)
|
|
1233
|
+
_string(finding["summary"], f"{finding_path}.summary", max_length=1000)
|
|
1234
|
+
_string(finding["evidence"], f"{finding_path}.evidence", max_length=4000)
|
|
1235
|
+
if finding["status"] not in {
|
|
1236
|
+
"open",
|
|
1237
|
+
"resolved",
|
|
1238
|
+
"deferred",
|
|
1239
|
+
"false-positive",
|
|
1240
|
+
}:
|
|
1241
|
+
raise ContractError(f"{finding_path}.status: invalid status")
|
|
1242
|
+
if finding["status"] in {"open", "deferred"}:
|
|
1243
|
+
unresolved_findings += 1
|
|
1244
|
+
if finding["status"] == "open":
|
|
1245
|
+
if finding["resolutionEvidence"] is not None:
|
|
1246
|
+
raise ContractError(
|
|
1247
|
+
f"{finding_path}.resolutionEvidence: must be null while open"
|
|
1248
|
+
)
|
|
1249
|
+
else:
|
|
1250
|
+
_string(
|
|
1251
|
+
finding["resolutionEvidence"],
|
|
1252
|
+
f"{finding_path}.resolutionEvidence",
|
|
1253
|
+
max_length=4000,
|
|
1254
|
+
)
|
|
1255
|
+
if (
|
|
1256
|
+
verdict == "approved"
|
|
1257
|
+
and unresolved_findings
|
|
1258
|
+
and not allow_legacy_unresolved_approval
|
|
1259
|
+
):
|
|
1260
|
+
raise ContractError(
|
|
1261
|
+
f"{path}: approved review cannot contain open or deferred findings"
|
|
1262
|
+
)
|
|
1263
|
+
if verdict == "changes-requested" and not unresolved_findings:
|
|
1264
|
+
raise ContractError(
|
|
1265
|
+
f"{path}: changes-requested review must contain an open or deferred finding"
|
|
1266
|
+
)
|
|
1267
|
+
|
|
1268
|
+
|
|
1269
|
+
def validate_review(document: Any, path: str = "review") -> None:
|
|
1270
|
+
_validate_review(document, path, allow_legacy_unresolved_approval=False)
|
|
1271
|
+
|
|
1272
|
+
|
|
1273
|
+
def _validate_legacy_review(document: Any, path: str) -> None:
|
|
1274
|
+
_validate_review(document, path, allow_legacy_unresolved_approval=True)
|