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.
Files changed (69) hide show
  1. engineering_process/__init__.py +3 -0
  2. engineering_process/_download_worker.py +73 -0
  3. engineering_process/_supervisor_posix.py +162 -0
  4. engineering_process/_supervisor_windows.py +148 -0
  5. engineering_process/_windows_job.py +419 -0
  6. engineering_process/bootstrap.py +217 -0
  7. engineering_process/bundles.py +81 -0
  8. engineering_process/cli.py +1012 -0
  9. engineering_process/contracts.py +1274 -0
  10. engineering_process/distribution.py +90 -0
  11. engineering_process/environment.py +753 -0
  12. engineering_process/helper_launch.py +37 -0
  13. engineering_process/lifecycle.py +1024 -0
  14. engineering_process/managed.py +89 -0
  15. engineering_process/markdown.py +78 -0
  16. engineering_process/publication.py +463 -0
  17. engineering_process/requirements-build.txt +1 -0
  18. engineering_process/requirements-dev.txt +10 -0
  19. engineering_process/requirements-runtime.txt +3 -0
  20. engineering_process/runner.py +191 -0
  21. engineering_process/runtime.py +55 -0
  22. engineering_process/skills.py +118 -0
  23. engineering_process/supervision.py +72 -0
  24. engineering_process/syncing.py +398 -0
  25. engineering_process/tooling.py +891 -0
  26. engineering_process-0.1.0.data/data/share/engineering-process/bundles.json +47 -0
  27. engineering_process-0.1.0.data/data/share/engineering-process/examples/change.json +26 -0
  28. engineering_process-0.1.0.data/data/share/engineering-process/examples/plan.json +28 -0
  29. engineering_process-0.1.0.data/data/share/engineering-process/examples/project.json +54 -0
  30. engineering_process-0.1.0.data/data/share/engineering-process/examples/review.json +31 -0
  31. engineering_process-0.1.0.data/data/share/engineering-process/schemas/change.schema.json +88 -0
  32. engineering_process-0.1.0.data/data/share/engineering-process/schemas/lifecycle.schema.json +122 -0
  33. engineering_process-0.1.0.data/data/share/engineering-process/schemas/plan.schema.json +105 -0
  34. engineering_process-0.1.0.data/data/share/engineering-process/schemas/process-lock.schema.json +39 -0
  35. engineering_process-0.1.0.data/data/share/engineering-process/schemas/project.schema.json +515 -0
  36. engineering_process-0.1.0.data/data/share/engineering-process/schemas/review.schema.json +140 -0
  37. engineering_process-0.1.0.data/data/share/engineering-process/schemas/reviewer-attestation.schema.json +30 -0
  38. engineering_process-0.1.0.data/data/share/engineering-process/schemas/verification.schema.json +120 -0
  39. engineering_process-0.1.0.data/data/share/engineering-process/skills/assess-design/SKILL.md +39 -0
  40. engineering_process-0.1.0.data/data/share/engineering-process/skills/build-frontend/SKILL.md +38 -0
  41. engineering_process-0.1.0.data/data/share/engineering-process/skills/build-frontend-foundation/SKILL.md +36 -0
  42. engineering_process-0.1.0.data/data/share/engineering-process/skills/change-api/SKILL.md +36 -0
  43. engineering_process-0.1.0.data/data/share/engineering-process/skills/cross-repo-change/SKILL.md +37 -0
  44. engineering_process-0.1.0.data/data/share/engineering-process/skills/define-change-contract/SKILL.md +40 -0
  45. engineering_process-0.1.0.data/data/share/engineering-process/skills/design-module/SKILL.md +37 -0
  46. engineering_process-0.1.0.data/data/share/engineering-process/skills/evolve-process/SKILL.md +37 -0
  47. engineering_process-0.1.0.data/data/share/engineering-process/skills/finish-change/SKILL.md +34 -0
  48. engineering_process-0.1.0.data/data/share/engineering-process/skills/govern-ui/SKILL.md +40 -0
  49. engineering_process-0.1.0.data/data/share/engineering-process/skills/implement-change/SKILL.md +41 -0
  50. engineering_process-0.1.0.data/data/share/engineering-process/skills/implement-module/SKILL.md +36 -0
  51. engineering_process-0.1.0.data/data/share/engineering-process/skills/implement-use-case/SKILL.md +36 -0
  52. engineering_process-0.1.0.data/data/share/engineering-process/skills/integrate-mcp/SKILL.md +37 -0
  53. engineering_process-0.1.0.data/data/share/engineering-process/skills/maintain-docs/SKILL.md +35 -0
  54. engineering_process-0.1.0.data/data/share/engineering-process/skills/plan-change/SKILL.md +37 -0
  55. engineering_process-0.1.0.data/data/share/engineering-process/skills/publish-change/SKILL.md +38 -0
  56. engineering_process-0.1.0.data/data/share/engineering-process/skills/review-change/SKILL.md +49 -0
  57. engineering_process-0.1.0.data/data/share/engineering-process/skills/run-change/SKILL.md +51 -0
  58. engineering_process-0.1.0.data/data/share/engineering-process/skills/run-change/references/execution.md +88 -0
  59. engineering_process-0.1.0.data/data/share/engineering-process/skills/run-project-command/SKILL.md +55 -0
  60. engineering_process-0.1.0.data/data/share/engineering-process/skills/specify-use-case/SKILL.md +37 -0
  61. engineering_process-0.1.0.data/data/share/engineering-process/skills/verify-change/SKILL.md +38 -0
  62. engineering_process-0.1.0.data/data/share/engineering-process/templates/AGENTS.process.md +17 -0
  63. engineering_process-0.1.0.data/data/share/engineering-process/templates/PULL_REQUEST_TEMPLATE.md +27 -0
  64. engineering_process-0.1.0.dist-info/METADATA +387 -0
  65. engineering_process-0.1.0.dist-info/RECORD +69 -0
  66. engineering_process-0.1.0.dist-info/WHEEL +5 -0
  67. engineering_process-0.1.0.dist-info/entry_points.txt +2 -0
  68. engineering_process-0.1.0.dist-info/licenses/LICENSE +21 -0
  69. 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)