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,1024 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import tempfile
|
|
8
|
+
from contextlib import contextmanager
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .contracts import (
|
|
14
|
+
ContractError,
|
|
15
|
+
PROFILE_PATTERN,
|
|
16
|
+
Project,
|
|
17
|
+
_validate_legacy_review,
|
|
18
|
+
read_json,
|
|
19
|
+
validate_change,
|
|
20
|
+
validate_plan,
|
|
21
|
+
validate_review,
|
|
22
|
+
)
|
|
23
|
+
from .environment import require_environment_profile
|
|
24
|
+
from .runner import run_profile, source_state
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
PHASES = {
|
|
28
|
+
"specified",
|
|
29
|
+
"planned",
|
|
30
|
+
"implementing",
|
|
31
|
+
"verified",
|
|
32
|
+
"review-pending",
|
|
33
|
+
"changes-requested",
|
|
34
|
+
"approved",
|
|
35
|
+
"completed",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
FINDING_IDENTITY_FIELDS = (
|
|
39
|
+
"id",
|
|
40
|
+
"severity",
|
|
41
|
+
"path",
|
|
42
|
+
"line",
|
|
43
|
+
"summary",
|
|
44
|
+
"evidence",
|
|
45
|
+
)
|
|
46
|
+
UNRESOLVED_FINDING_STATUSES = {"open", "deferred"}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _timestamp() -> str:
|
|
50
|
+
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _digest_bytes(content: bytes) -> str:
|
|
54
|
+
return f"sha256:{hashlib.sha256(content).hexdigest()}"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _digest_file(path: Path) -> str:
|
|
58
|
+
try:
|
|
59
|
+
return _digest_bytes(path.read_bytes())
|
|
60
|
+
except OSError as error:
|
|
61
|
+
raise ContractError(f"{path}: cannot read lifecycle artifact: {error}") from error
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _resolve_commit(project_root: Path, reference: str) -> str:
|
|
65
|
+
try:
|
|
66
|
+
result = subprocess.run(
|
|
67
|
+
["git", "rev-parse", "--verify", f"{reference}^{{commit}}"],
|
|
68
|
+
cwd=project_root,
|
|
69
|
+
check=False,
|
|
70
|
+
stdin=subprocess.DEVNULL,
|
|
71
|
+
stdout=subprocess.PIPE,
|
|
72
|
+
stderr=subprocess.PIPE,
|
|
73
|
+
timeout=10,
|
|
74
|
+
)
|
|
75
|
+
except (OSError, subprocess.TimeoutExpired) as error:
|
|
76
|
+
raise ContractError(f"cannot resolve comparison base {reference}: {error}") from error
|
|
77
|
+
if result.returncode != 0:
|
|
78
|
+
detail = result.stderr.decode("utf-8", errors="replace").strip()
|
|
79
|
+
raise ContractError(
|
|
80
|
+
f"comparison base {reference} does not resolve to a commit"
|
|
81
|
+
+ (f": {detail}" if detail else "")
|
|
82
|
+
)
|
|
83
|
+
return result.stdout.decode("ascii").strip()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _actor(actor_id: str, context_id: str, kind: str) -> dict[str, str]:
|
|
87
|
+
if not actor_id or actor_id != actor_id.strip() or len(actor_id) > 256:
|
|
88
|
+
raise ContractError("actor id must be a non-empty trimmed value up to 256 characters")
|
|
89
|
+
if not context_id or context_id != context_id.strip() or len(context_id) > 256:
|
|
90
|
+
raise ContractError(
|
|
91
|
+
"context id must be a non-empty trimmed value up to 256 characters"
|
|
92
|
+
)
|
|
93
|
+
if kind not in {"agent", "human"}:
|
|
94
|
+
raise ContractError("actor kind must be agent or human")
|
|
95
|
+
return {"actorId": actor_id, "contextId": context_id, "kind": kind}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _runs_root(project_root: Path) -> Path:
|
|
99
|
+
return project_root / ".process" / "runs"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def lifecycle_environment_issues(project_root: Path) -> list[str]:
|
|
103
|
+
issues: list[str] = []
|
|
104
|
+
try:
|
|
105
|
+
top = subprocess.run(
|
|
106
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
107
|
+
cwd=project_root,
|
|
108
|
+
check=False,
|
|
109
|
+
stdin=subprocess.DEVNULL,
|
|
110
|
+
stdout=subprocess.PIPE,
|
|
111
|
+
stderr=subprocess.DEVNULL,
|
|
112
|
+
timeout=10,
|
|
113
|
+
)
|
|
114
|
+
except (OSError, subprocess.TimeoutExpired) as error:
|
|
115
|
+
return [f"cannot inspect Git lifecycle boundary: {error}"]
|
|
116
|
+
if top.returncode != 0:
|
|
117
|
+
return ["canonical lifecycle requires a Git repository"]
|
|
118
|
+
try:
|
|
119
|
+
repository_root = Path(top.stdout.decode("utf-8").strip()).resolve()
|
|
120
|
+
except UnicodeDecodeError:
|
|
121
|
+
return ["Git repository root is not valid UTF-8"]
|
|
122
|
+
if repository_root != project_root.resolve():
|
|
123
|
+
issues.append(
|
|
124
|
+
f"project root {project_root.resolve()} must equal Git root {repository_root}"
|
|
125
|
+
)
|
|
126
|
+
ignore = subprocess.run(
|
|
127
|
+
["git", "check-ignore", "-q", ".process/runs/__process_probe__"],
|
|
128
|
+
cwd=project_root,
|
|
129
|
+
check=False,
|
|
130
|
+
stdin=subprocess.DEVNULL,
|
|
131
|
+
stdout=subprocess.DEVNULL,
|
|
132
|
+
stderr=subprocess.DEVNULL,
|
|
133
|
+
timeout=10,
|
|
134
|
+
)
|
|
135
|
+
if ignore.returncode != 0:
|
|
136
|
+
issues.append(
|
|
137
|
+
".process/runs/ must be ignored so lifecycle evidence cannot dirty source"
|
|
138
|
+
)
|
|
139
|
+
return issues
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _run_root(project_root: Path, change_id: str) -> Path:
|
|
143
|
+
if PROFILE_PATTERN.fullmatch(change_id) is None or len(change_id) > 64:
|
|
144
|
+
raise ContractError(f"invalid change id: {change_id}")
|
|
145
|
+
return _runs_root(project_root) / change_id
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@contextmanager
|
|
149
|
+
def _change_lock(project_root: Path, change_id: str):
|
|
150
|
+
lock_root = _runs_root(project_root) / ".locks"
|
|
151
|
+
lock_root.mkdir(parents=True, exist_ok=True)
|
|
152
|
+
path = lock_root / f"{change_id}.lock"
|
|
153
|
+
handle = path.open("a+b")
|
|
154
|
+
try:
|
|
155
|
+
if os.name == "posix":
|
|
156
|
+
import fcntl
|
|
157
|
+
|
|
158
|
+
try:
|
|
159
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
160
|
+
except BlockingIOError as error:
|
|
161
|
+
raise ContractError(
|
|
162
|
+
f"change {change_id} is being mutated by another process"
|
|
163
|
+
) from error
|
|
164
|
+
else:
|
|
165
|
+
import msvcrt
|
|
166
|
+
|
|
167
|
+
if path.stat().st_size == 0:
|
|
168
|
+
handle.write(b"0")
|
|
169
|
+
handle.flush()
|
|
170
|
+
handle.seek(0)
|
|
171
|
+
try:
|
|
172
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
|
|
173
|
+
except OSError as error:
|
|
174
|
+
raise ContractError(
|
|
175
|
+
f"change {change_id} is being mutated by another process"
|
|
176
|
+
) from error
|
|
177
|
+
yield
|
|
178
|
+
finally:
|
|
179
|
+
try:
|
|
180
|
+
if os.name == "posix":
|
|
181
|
+
import fcntl
|
|
182
|
+
|
|
183
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
184
|
+
else:
|
|
185
|
+
import msvcrt
|
|
186
|
+
|
|
187
|
+
handle.seek(0)
|
|
188
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
|
189
|
+
except OSError:
|
|
190
|
+
pass
|
|
191
|
+
handle.close()
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _state_path(project_root: Path, change_id: str) -> Path:
|
|
195
|
+
return _run_root(project_root, change_id) / "state.json"
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _relative(project_root: Path, path: Path) -> str:
|
|
199
|
+
root = project_root.resolve()
|
|
200
|
+
resolved = path.resolve()
|
|
201
|
+
try:
|
|
202
|
+
return resolved.relative_to(root).as_posix()
|
|
203
|
+
except ValueError as error:
|
|
204
|
+
raise ContractError(f"{path}: lifecycle artifacts must stay within the project") from error
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _write_atomic(path: Path, document: dict[str, Any]) -> None:
|
|
208
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
209
|
+
content = (
|
|
210
|
+
json.dumps(document, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
|
|
211
|
+
).encode("utf-8")
|
|
212
|
+
descriptor, temporary_name = tempfile.mkstemp(
|
|
213
|
+
prefix=f".{path.name}.", suffix=".tmp", dir=path.parent
|
|
214
|
+
)
|
|
215
|
+
temporary = Path(temporary_name)
|
|
216
|
+
try:
|
|
217
|
+
with os.fdopen(descriptor, "wb") as output:
|
|
218
|
+
output.write(content)
|
|
219
|
+
output.flush()
|
|
220
|
+
os.fsync(output.fileno())
|
|
221
|
+
os.replace(temporary, path)
|
|
222
|
+
finally:
|
|
223
|
+
if temporary.exists():
|
|
224
|
+
temporary.unlink()
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _copy_document(
|
|
228
|
+
project_root: Path,
|
|
229
|
+
source: Path,
|
|
230
|
+
destination: Path,
|
|
231
|
+
) -> dict[str, str]:
|
|
232
|
+
document = read_json(source)
|
|
233
|
+
content = (
|
|
234
|
+
json.dumps(document, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
|
|
235
|
+
).encode("utf-8")
|
|
236
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
237
|
+
try:
|
|
238
|
+
destination.write_bytes(content)
|
|
239
|
+
except OSError as error:
|
|
240
|
+
raise ContractError(f"{destination}: cannot write lifecycle artifact: {error}") from error
|
|
241
|
+
return {
|
|
242
|
+
"path": _relative(project_root, destination),
|
|
243
|
+
"digest": _digest_bytes(content),
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _artifact_path(project_root: Path, artifact: dict[str, str]) -> Path:
|
|
248
|
+
relative = artifact.get("path")
|
|
249
|
+
digest = artifact.get("digest")
|
|
250
|
+
if not isinstance(relative, str) or not isinstance(digest, str):
|
|
251
|
+
raise ContractError("lifecycle state contains an invalid artifact reference")
|
|
252
|
+
path = (project_root / relative).resolve()
|
|
253
|
+
_relative(project_root, path)
|
|
254
|
+
if _digest_file(path) != digest:
|
|
255
|
+
raise ContractError(f"{path}: lifecycle artifact digest is stale")
|
|
256
|
+
return path
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _event(
|
|
260
|
+
state: dict[str, Any],
|
|
261
|
+
event: str,
|
|
262
|
+
actor: dict[str, str] | None,
|
|
263
|
+
**details: Any,
|
|
264
|
+
) -> None:
|
|
265
|
+
state["revision"] += 1
|
|
266
|
+
record: dict[str, Any] = {
|
|
267
|
+
"revision": state["revision"],
|
|
268
|
+
"event": event,
|
|
269
|
+
"at": _timestamp(),
|
|
270
|
+
}
|
|
271
|
+
if actor is not None:
|
|
272
|
+
record["actor"] = actor
|
|
273
|
+
record.update(details)
|
|
274
|
+
state["history"].append(record)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _validate_state(state: Any, path: Path) -> dict[str, Any]:
|
|
278
|
+
if not isinstance(state, dict):
|
|
279
|
+
raise ContractError(f"{path}: lifecycle state must be an object")
|
|
280
|
+
required = {
|
|
281
|
+
"schemaVersion",
|
|
282
|
+
"changeId",
|
|
283
|
+
"project",
|
|
284
|
+
"phase",
|
|
285
|
+
"cycle",
|
|
286
|
+
"revision",
|
|
287
|
+
"comparisonBase",
|
|
288
|
+
"contract",
|
|
289
|
+
"plan",
|
|
290
|
+
"implementationActors",
|
|
291
|
+
"verification",
|
|
292
|
+
"pendingFindings",
|
|
293
|
+
"reviewAssignment",
|
|
294
|
+
"review",
|
|
295
|
+
"completion",
|
|
296
|
+
"history",
|
|
297
|
+
}
|
|
298
|
+
missing = sorted(required - set(state))
|
|
299
|
+
extra = sorted(set(state) - required)
|
|
300
|
+
if missing or extra:
|
|
301
|
+
detail = []
|
|
302
|
+
if missing:
|
|
303
|
+
detail.append(f"missing: {', '.join(missing)}")
|
|
304
|
+
if extra:
|
|
305
|
+
detail.append(f"unknown: {', '.join(extra)}")
|
|
306
|
+
raise ContractError(f"{path}: invalid lifecycle state ({'; '.join(detail)})")
|
|
307
|
+
if state["schemaVersion"] != 2:
|
|
308
|
+
raise ContractError(f"{path}.schemaVersion: must be 2")
|
|
309
|
+
if state["phase"] not in PHASES:
|
|
310
|
+
raise ContractError(f"{path}.phase: invalid phase")
|
|
311
|
+
if not isinstance(state["cycle"], int) or state["cycle"] < 1:
|
|
312
|
+
raise ContractError(f"{path}.cycle: must be a positive integer")
|
|
313
|
+
if not isinstance(state["revision"], int) or state["revision"] < 1:
|
|
314
|
+
raise ContractError(f"{path}.revision: must be a positive integer")
|
|
315
|
+
if not isinstance(state["history"], list) or not state["history"]:
|
|
316
|
+
raise ContractError(f"{path}.history: must not be empty")
|
|
317
|
+
if not isinstance(state["pendingFindings"], list):
|
|
318
|
+
raise ContractError(f"{path}.pendingFindings: must be an array")
|
|
319
|
+
return state
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _same_finding_identity(left: dict[str, Any], right: dict[str, Any]) -> bool:
|
|
323
|
+
return all(left.get(field) == right.get(field) for field in FINDING_IDENTITY_FIELDS)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _replay_pending_findings(
|
|
327
|
+
project_root: Path, state: dict[str, Any], path: Path
|
|
328
|
+
) -> list[dict[str, Any]]:
|
|
329
|
+
pending: dict[str, dict[str, Any]] = {}
|
|
330
|
+
review_events = 0
|
|
331
|
+
for event in state.get("history", []):
|
|
332
|
+
if not isinstance(event, dict) or event.get("event") != "review-submitted":
|
|
333
|
+
continue
|
|
334
|
+
review_events += 1
|
|
335
|
+
artifact = event.get("report")
|
|
336
|
+
if not isinstance(artifact, dict):
|
|
337
|
+
raise ContractError(
|
|
338
|
+
f"{path}: cannot safely migrate review history without report artifacts"
|
|
339
|
+
)
|
|
340
|
+
report_path = _artifact_path(project_root, artifact)
|
|
341
|
+
report = read_json(report_path)
|
|
342
|
+
_validate_legacy_review(report, str(report_path))
|
|
343
|
+
for finding in report["findings"]:
|
|
344
|
+
identifier = finding["id"]
|
|
345
|
+
previous = pending.get(identifier)
|
|
346
|
+
if previous is not None and not _same_finding_identity(previous, finding):
|
|
347
|
+
raise ContractError(
|
|
348
|
+
f"{path}: cannot safely migrate finding {identifier} because its "
|
|
349
|
+
"identity changed"
|
|
350
|
+
)
|
|
351
|
+
if finding["status"] in UNRESOLVED_FINDING_STATUSES:
|
|
352
|
+
pending[identifier] = dict(finding)
|
|
353
|
+
elif previous is not None:
|
|
354
|
+
del pending[identifier]
|
|
355
|
+
existing = state.get("pendingFindings")
|
|
356
|
+
if review_events == 0 and isinstance(existing, list):
|
|
357
|
+
return [dict(finding) for finding in existing]
|
|
358
|
+
return list(pending.values())
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _migrate_state(project_root: Path, state: Any, path: Path) -> dict[str, Any]:
|
|
362
|
+
if not isinstance(state, dict) or state.get("schemaVersion") != 1:
|
|
363
|
+
return state
|
|
364
|
+
migrated = dict(state)
|
|
365
|
+
migrated["pendingFindings"] = _replay_pending_findings(project_root, state, path)
|
|
366
|
+
if migrated["pendingFindings"] and migrated.get("phase") in {
|
|
367
|
+
"approved",
|
|
368
|
+
"completed",
|
|
369
|
+
}:
|
|
370
|
+
migrated["phase"] = "changes-requested"
|
|
371
|
+
migrated["completion"] = None
|
|
372
|
+
migrated["schemaVersion"] = 2
|
|
373
|
+
return migrated
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def load_state(project_root: Path, change_id: str) -> dict[str, Any]:
|
|
377
|
+
path = _state_path(project_root, change_id)
|
|
378
|
+
state = _migrate_state(project_root, read_json(path), path)
|
|
379
|
+
return _validate_state(state, path)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _save_state(project_root: Path, state: dict[str, Any]) -> None:
|
|
383
|
+
_validate_state(state, _state_path(project_root, state["changeId"]))
|
|
384
|
+
_write_atomic(_state_path(project_root, state["changeId"]), state)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _require_phase(state: dict[str, Any], *allowed: str) -> None:
|
|
388
|
+
if state["phase"] not in allowed:
|
|
389
|
+
expected = ", ".join(allowed)
|
|
390
|
+
raise ContractError(
|
|
391
|
+
f"change {state['changeId']} is {state['phase']}; expected {expected}"
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _contract(project_root: Path, state: dict[str, Any]) -> dict[str, Any]:
|
|
396
|
+
document = read_json(_artifact_path(project_root, state["contract"]))
|
|
397
|
+
validate_change(document, "registered change")
|
|
398
|
+
return document
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _plan(project_root: Path, state: dict[str, Any]) -> dict[str, Any]:
|
|
402
|
+
if state["plan"] is None:
|
|
403
|
+
raise ContractError(f"change {state['changeId']} has no registered plan")
|
|
404
|
+
document = read_json(_artifact_path(project_root, state["plan"]))
|
|
405
|
+
validate_plan(document, "registered plan")
|
|
406
|
+
return document
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _start_change_unlocked(
|
|
410
|
+
project_root: Path,
|
|
411
|
+
project: Project,
|
|
412
|
+
contract_path: Path,
|
|
413
|
+
*,
|
|
414
|
+
actor_id: str,
|
|
415
|
+
context_id: str,
|
|
416
|
+
kind: str,
|
|
417
|
+
) -> dict[str, Any]:
|
|
418
|
+
document = read_json(contract_path)
|
|
419
|
+
validate_change(document, str(contract_path))
|
|
420
|
+
change_id = document["id"]
|
|
421
|
+
if project.identifier not in document["affectedProjects"]:
|
|
422
|
+
raise ContractError(
|
|
423
|
+
f"change {change_id} does not include project {project.identifier}"
|
|
424
|
+
)
|
|
425
|
+
missing_profiles = sorted(set(document["requiredProfiles"]) - set(project.profiles))
|
|
426
|
+
if missing_profiles:
|
|
427
|
+
raise ContractError(
|
|
428
|
+
f"change {change_id} requires undefined profiles: {', '.join(missing_profiles)}"
|
|
429
|
+
)
|
|
430
|
+
missing_baseline = sorted(
|
|
431
|
+
set(project.required_profiles) - set(document["requiredProfiles"])
|
|
432
|
+
)
|
|
433
|
+
if missing_baseline:
|
|
434
|
+
raise ContractError(
|
|
435
|
+
f"change {change_id} omits project lifecycle profiles: "
|
|
436
|
+
f"{', '.join(missing_baseline)}"
|
|
437
|
+
)
|
|
438
|
+
run_root = _run_root(project_root, change_id)
|
|
439
|
+
if run_root.exists():
|
|
440
|
+
raise ContractError(f"change {change_id} already exists")
|
|
441
|
+
actor = _actor(actor_id, context_id, kind)
|
|
442
|
+
comparison_base = _resolve_commit(project_root, document["comparisonBase"])
|
|
443
|
+
contract = _copy_document(project_root, contract_path, run_root / "contract.json")
|
|
444
|
+
now = _timestamp()
|
|
445
|
+
state: dict[str, Any] = {
|
|
446
|
+
"schemaVersion": 2,
|
|
447
|
+
"changeId": change_id,
|
|
448
|
+
"project": project.identifier,
|
|
449
|
+
"phase": "specified",
|
|
450
|
+
"cycle": 1,
|
|
451
|
+
"revision": 1,
|
|
452
|
+
"comparisonBase": comparison_base,
|
|
453
|
+
"contract": contract,
|
|
454
|
+
"plan": None,
|
|
455
|
+
"implementationActors": [],
|
|
456
|
+
"verification": [],
|
|
457
|
+
"pendingFindings": [],
|
|
458
|
+
"reviewAssignment": None,
|
|
459
|
+
"review": None,
|
|
460
|
+
"completion": None,
|
|
461
|
+
"history": [
|
|
462
|
+
{
|
|
463
|
+
"revision": 1,
|
|
464
|
+
"event": "specified",
|
|
465
|
+
"at": now,
|
|
466
|
+
"actor": actor,
|
|
467
|
+
"comparisonBaseRef": document["comparisonBase"],
|
|
468
|
+
"comparisonBase": comparison_base,
|
|
469
|
+
}
|
|
470
|
+
],
|
|
471
|
+
}
|
|
472
|
+
_save_state(project_root, state)
|
|
473
|
+
return state
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _register_plan_unlocked(
|
|
477
|
+
project_root: Path,
|
|
478
|
+
project: Project,
|
|
479
|
+
change_id: str,
|
|
480
|
+
plan_path: Path,
|
|
481
|
+
*,
|
|
482
|
+
actor_id: str,
|
|
483
|
+
context_id: str,
|
|
484
|
+
kind: str,
|
|
485
|
+
) -> dict[str, Any]:
|
|
486
|
+
state = load_state(project_root, change_id)
|
|
487
|
+
_require_phase(state, "specified")
|
|
488
|
+
contract = _contract(project_root, state)
|
|
489
|
+
if contract["signOff"]["required"] and contract["signOff"]["status"] != "approved":
|
|
490
|
+
raise ContractError(f"change {change_id} requires sign-off before planning")
|
|
491
|
+
document = read_json(plan_path)
|
|
492
|
+
validate_plan(document, str(plan_path))
|
|
493
|
+
if document["changeId"] != change_id:
|
|
494
|
+
raise ContractError(f"plan changeId does not match {change_id}")
|
|
495
|
+
if document["contractDigest"] != state["contract"]["digest"]:
|
|
496
|
+
raise ContractError("plan contractDigest does not match the registered contract")
|
|
497
|
+
if document["openDecisions"]:
|
|
498
|
+
raise ContractError("implementation plan has unresolved open decisions")
|
|
499
|
+
contract_criteria = {item["id"] for item in contract["acceptanceCriteria"]}
|
|
500
|
+
planned_criteria = {item["criterionId"] for item in document["acceptancePlan"]}
|
|
501
|
+
if contract_criteria != planned_criteria:
|
|
502
|
+
missing = sorted(contract_criteria - planned_criteria)
|
|
503
|
+
extra = sorted(planned_criteria - contract_criteria)
|
|
504
|
+
raise ContractError(
|
|
505
|
+
"acceptance plan must map the exact contract criteria"
|
|
506
|
+
+ (f"; missing: {', '.join(missing)}" if missing else "")
|
|
507
|
+
+ (f"; unknown: {', '.join(extra)}" if extra else "")
|
|
508
|
+
)
|
|
509
|
+
used_profiles = {
|
|
510
|
+
profile
|
|
511
|
+
for item in document["workItems"]
|
|
512
|
+
for profile in item["verificationProfiles"]
|
|
513
|
+
} | {
|
|
514
|
+
profile
|
|
515
|
+
for item in document["acceptancePlan"]
|
|
516
|
+
for profile in item["verificationProfiles"]
|
|
517
|
+
}
|
|
518
|
+
unknown_profiles = sorted(used_profiles - set(project.profiles))
|
|
519
|
+
if unknown_profiles:
|
|
520
|
+
raise ContractError(
|
|
521
|
+
f"plan references undefined profiles: {', '.join(unknown_profiles)}"
|
|
522
|
+
)
|
|
523
|
+
missing_required = sorted(set(contract["requiredProfiles"]) - used_profiles)
|
|
524
|
+
if missing_required:
|
|
525
|
+
raise ContractError(
|
|
526
|
+
f"plan does not use required profiles: {', '.join(missing_required)}"
|
|
527
|
+
)
|
|
528
|
+
actor = _actor(actor_id, context_id, kind)
|
|
529
|
+
artifact = _copy_document(
|
|
530
|
+
project_root, plan_path, _run_root(project_root, change_id) / "plan.json"
|
|
531
|
+
)
|
|
532
|
+
state["plan"] = artifact
|
|
533
|
+
state["phase"] = "planned"
|
|
534
|
+
_event(state, "planned", actor)
|
|
535
|
+
_save_state(project_root, state)
|
|
536
|
+
return state
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _begin_implementation_unlocked(
|
|
540
|
+
project_root: Path,
|
|
541
|
+
change_id: str,
|
|
542
|
+
*,
|
|
543
|
+
actor_id: str,
|
|
544
|
+
context_id: str,
|
|
545
|
+
kind: str,
|
|
546
|
+
) -> dict[str, Any]:
|
|
547
|
+
state = load_state(project_root, change_id)
|
|
548
|
+
_require_phase(state, "planned", "implementing", "changes-requested")
|
|
549
|
+
_contract(project_root, state)
|
|
550
|
+
_plan(project_root, state)
|
|
551
|
+
actor = _actor(actor_id, context_id, kind)
|
|
552
|
+
if state["phase"] == "changes-requested":
|
|
553
|
+
state["cycle"] += 1
|
|
554
|
+
state["implementationActors"] = []
|
|
555
|
+
state["verification"] = []
|
|
556
|
+
state["reviewAssignment"] = None
|
|
557
|
+
state["review"] = None
|
|
558
|
+
if actor not in state["implementationActors"]:
|
|
559
|
+
state["implementationActors"].append(actor)
|
|
560
|
+
state["phase"] = "implementing"
|
|
561
|
+
_event(state, "implementation-started", actor, cycle=state["cycle"])
|
|
562
|
+
_save_state(project_root, state)
|
|
563
|
+
return state
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _verify_change_unlocked(
|
|
567
|
+
project_root: Path,
|
|
568
|
+
project: Project,
|
|
569
|
+
change_id: str,
|
|
570
|
+
profile: str,
|
|
571
|
+
*,
|
|
572
|
+
actor_id: str,
|
|
573
|
+
context_id: str,
|
|
574
|
+
kind: str,
|
|
575
|
+
) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
576
|
+
state = load_state(project_root, change_id)
|
|
577
|
+
_require_phase(state, "implementing")
|
|
578
|
+
contract = _contract(project_root, state)
|
|
579
|
+
_plan(project_root, state)
|
|
580
|
+
actor = _actor(actor_id, context_id, kind)
|
|
581
|
+
if actor not in state["implementationActors"]:
|
|
582
|
+
raise ContractError("only a registered implementation actor may record verification")
|
|
583
|
+
if profile not in contract["requiredProfiles"]:
|
|
584
|
+
raise ContractError(f"profile {profile} is not required by change {change_id}")
|
|
585
|
+
require_environment_profile(project_root, project, profile=profile)
|
|
586
|
+
report = run_profile(project_root, project, profile)
|
|
587
|
+
report_path = (
|
|
588
|
+
_run_root(project_root, change_id)
|
|
589
|
+
/ "verification"
|
|
590
|
+
/ f"cycle-{state['cycle']}-{profile}.json"
|
|
591
|
+
)
|
|
592
|
+
_write_atomic(report_path, report)
|
|
593
|
+
eligible = (
|
|
594
|
+
report["status"] == "passed"
|
|
595
|
+
and report["checkpoint"] is not None
|
|
596
|
+
and report["workingTreeDirty"] is False
|
|
597
|
+
and report["workspaceFingerprint"] is not None
|
|
598
|
+
and not report["sourceChangedDuringVerification"]
|
|
599
|
+
and report["workspaceFingerprint"] == report["completedWorkspaceFingerprint"]
|
|
600
|
+
)
|
|
601
|
+
if not eligible:
|
|
602
|
+
_event(
|
|
603
|
+
state,
|
|
604
|
+
"verification-rejected",
|
|
605
|
+
actor,
|
|
606
|
+
cycle=state["cycle"],
|
|
607
|
+
profile=profile,
|
|
608
|
+
report=_relative(project_root, report_path),
|
|
609
|
+
reportDigest=_digest_file(report_path),
|
|
610
|
+
)
|
|
611
|
+
_save_state(project_root, state)
|
|
612
|
+
raise ContractError(
|
|
613
|
+
"lifecycle verification requires passing checks on a clean immutable checkpoint"
|
|
614
|
+
)
|
|
615
|
+
evidence = {
|
|
616
|
+
"profile": profile,
|
|
617
|
+
"path": _relative(project_root, report_path),
|
|
618
|
+
"digest": _digest_file(report_path),
|
|
619
|
+
"checkpoint": report["checkpoint"],
|
|
620
|
+
"workspaceFingerprint": report["workspaceFingerprint"],
|
|
621
|
+
}
|
|
622
|
+
state["verification"] = [
|
|
623
|
+
item for item in state["verification"] if item["profile"] != profile
|
|
624
|
+
]
|
|
625
|
+
state["verification"].append(evidence)
|
|
626
|
+
state["verification"].sort(key=lambda item: item["profile"])
|
|
627
|
+
current_profiles = {item["profile"] for item in state["verification"]}
|
|
628
|
+
required_profiles = set(contract["requiredProfiles"])
|
|
629
|
+
checkpoints = {item["checkpoint"] for item in state["verification"]}
|
|
630
|
+
fingerprints = {item["workspaceFingerprint"] for item in state["verification"]}
|
|
631
|
+
if required_profiles <= current_profiles and len(checkpoints) == 1 and len(fingerprints) == 1:
|
|
632
|
+
state["phase"] = "verified"
|
|
633
|
+
_event(
|
|
634
|
+
state,
|
|
635
|
+
"verification-recorded",
|
|
636
|
+
actor,
|
|
637
|
+
cycle=state["cycle"],
|
|
638
|
+
profile=profile,
|
|
639
|
+
phase=state["phase"],
|
|
640
|
+
evidence=evidence,
|
|
641
|
+
)
|
|
642
|
+
_save_state(project_root, state)
|
|
643
|
+
return state, report
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def _start_review_unlocked(
|
|
647
|
+
project_root: Path,
|
|
648
|
+
change_id: str,
|
|
649
|
+
*,
|
|
650
|
+
actor_id: str,
|
|
651
|
+
context_id: str,
|
|
652
|
+
kind: str,
|
|
653
|
+
method: str,
|
|
654
|
+
attested_by: str,
|
|
655
|
+
evidence: str,
|
|
656
|
+
) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
657
|
+
state = load_state(project_root, change_id)
|
|
658
|
+
_require_phase(state, "verified")
|
|
659
|
+
_contract(project_root, state)
|
|
660
|
+
_plan(project_root, state)
|
|
661
|
+
reviewer = _actor(actor_id, context_id, kind)
|
|
662
|
+
if method not in {"isolated-context", "separate-person"}:
|
|
663
|
+
raise ContractError("review independence method is invalid")
|
|
664
|
+
if (kind == "agent" and method != "isolated-context") or (
|
|
665
|
+
kind == "human" and method != "separate-person"
|
|
666
|
+
):
|
|
667
|
+
raise ContractError("review independence method does not match reviewer kind")
|
|
668
|
+
if not attested_by or attested_by != attested_by.strip() or len(attested_by) > 256:
|
|
669
|
+
raise ContractError("review attester must be a non-empty trimmed value")
|
|
670
|
+
if attested_by in {reviewer["actorId"], reviewer["contextId"]}:
|
|
671
|
+
raise ContractError("review independence cannot be self-attested")
|
|
672
|
+
if not evidence or evidence != evidence.strip() or len(evidence) > 2000:
|
|
673
|
+
raise ContractError("review independence evidence must be a non-empty trimmed value")
|
|
674
|
+
actor_ids = {item["actorId"] for item in state["implementationActors"]}
|
|
675
|
+
context_ids = {item["contextId"] for item in state["implementationActors"]}
|
|
676
|
+
if reviewer["actorId"] in actor_ids or reviewer["contextId"] in context_ids:
|
|
677
|
+
raise ContractError(
|
|
678
|
+
"independent review requires an actor id and context id unused by implementation"
|
|
679
|
+
)
|
|
680
|
+
source = source_state(project_root)
|
|
681
|
+
checkpoints = {item["checkpoint"] for item in state["verification"]}
|
|
682
|
+
fingerprints = {item["workspaceFingerprint"] for item in state["verification"]}
|
|
683
|
+
if (
|
|
684
|
+
source["dirty"] is not False
|
|
685
|
+
or source["checkpoint"] is None
|
|
686
|
+
or source["fingerprint"] is None
|
|
687
|
+
or checkpoints != {source["checkpoint"]}
|
|
688
|
+
or fingerprints != {source["fingerprint"]}
|
|
689
|
+
):
|
|
690
|
+
raise ContractError("review cannot start because verification evidence is stale")
|
|
691
|
+
assignment = {
|
|
692
|
+
"changeId": change_id,
|
|
693
|
+
"cycle": state["cycle"],
|
|
694
|
+
"checkpoint": source["checkpoint"],
|
|
695
|
+
"workspaceFingerprint": source["fingerprint"],
|
|
696
|
+
"comparisonBase": state["comparisonBase"],
|
|
697
|
+
"reviewer": reviewer,
|
|
698
|
+
"independence": {
|
|
699
|
+
"method": method,
|
|
700
|
+
"attestedBy": attested_by,
|
|
701
|
+
"evidence": evidence,
|
|
702
|
+
},
|
|
703
|
+
"contract": state["contract"],
|
|
704
|
+
"plan": state["plan"],
|
|
705
|
+
"verification": state["verification"],
|
|
706
|
+
"pendingFindings": state["pendingFindings"],
|
|
707
|
+
}
|
|
708
|
+
assignment_path = _run_root(project_root, change_id) / f"review-request-{state['cycle']}.json"
|
|
709
|
+
_write_atomic(assignment_path, assignment)
|
|
710
|
+
assignment["path"] = _relative(project_root, assignment_path)
|
|
711
|
+
state["reviewAssignment"] = assignment
|
|
712
|
+
state["phase"] = "review-pending"
|
|
713
|
+
_event(
|
|
714
|
+
state,
|
|
715
|
+
"review-started",
|
|
716
|
+
reviewer,
|
|
717
|
+
cycle=state["cycle"],
|
|
718
|
+
request=assignment["path"],
|
|
719
|
+
)
|
|
720
|
+
_save_state(project_root, state)
|
|
721
|
+
return state, assignment
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def _submit_review_unlocked(
|
|
725
|
+
project_root: Path,
|
|
726
|
+
change_id: str,
|
|
727
|
+
report_path: Path,
|
|
728
|
+
) -> dict[str, Any]:
|
|
729
|
+
state = load_state(project_root, change_id)
|
|
730
|
+
_require_phase(state, "review-pending")
|
|
731
|
+
assignment = state["reviewAssignment"]
|
|
732
|
+
if not isinstance(assignment, dict):
|
|
733
|
+
raise ContractError("review assignment is missing")
|
|
734
|
+
document = read_json(report_path)
|
|
735
|
+
validate_review(document, str(report_path))
|
|
736
|
+
for field in (
|
|
737
|
+
"changeId",
|
|
738
|
+
"cycle",
|
|
739
|
+
"checkpoint",
|
|
740
|
+
"workspaceFingerprint",
|
|
741
|
+
"comparisonBase",
|
|
742
|
+
"reviewer",
|
|
743
|
+
"independence",
|
|
744
|
+
):
|
|
745
|
+
if document[field] != assignment[field]:
|
|
746
|
+
raise ContractError(f"review report {field} does not match its assignment")
|
|
747
|
+
findings_by_id = {finding["id"]: finding for finding in document["findings"]}
|
|
748
|
+
for pending in state["pendingFindings"]:
|
|
749
|
+
current = findings_by_id.get(pending["id"])
|
|
750
|
+
if current is None:
|
|
751
|
+
raise ContractError(
|
|
752
|
+
f"review report must carry forward pending finding {pending['id']}"
|
|
753
|
+
)
|
|
754
|
+
changed = [
|
|
755
|
+
field
|
|
756
|
+
for field in FINDING_IDENTITY_FIELDS
|
|
757
|
+
if current[field] != pending[field]
|
|
758
|
+
]
|
|
759
|
+
if changed:
|
|
760
|
+
raise ContractError(
|
|
761
|
+
f"review finding {pending['id']} changed immutable fields: "
|
|
762
|
+
+ ", ".join(changed)
|
|
763
|
+
)
|
|
764
|
+
source = source_state(project_root)
|
|
765
|
+
if (
|
|
766
|
+
source["dirty"] is not False
|
|
767
|
+
or source["checkpoint"] != document["checkpoint"]
|
|
768
|
+
or source["fingerprint"] != document["workspaceFingerprint"]
|
|
769
|
+
):
|
|
770
|
+
raise ContractError("review report is stale for the current source")
|
|
771
|
+
destination = (
|
|
772
|
+
_run_root(project_root, change_id) / f"review-{state['cycle']}.json"
|
|
773
|
+
)
|
|
774
|
+
artifact = _copy_document(project_root, report_path, destination)
|
|
775
|
+
state["review"] = artifact
|
|
776
|
+
state["pendingFindings"] = [
|
|
777
|
+
dict(finding)
|
|
778
|
+
for finding in document["findings"]
|
|
779
|
+
if finding["status"] in UNRESOLVED_FINDING_STATUSES
|
|
780
|
+
]
|
|
781
|
+
state["phase"] = (
|
|
782
|
+
"approved" if document["verdict"] == "approved" else "changes-requested"
|
|
783
|
+
)
|
|
784
|
+
_event(
|
|
785
|
+
state,
|
|
786
|
+
"review-submitted",
|
|
787
|
+
document["reviewer"],
|
|
788
|
+
cycle=state["cycle"],
|
|
789
|
+
verdict=document["verdict"],
|
|
790
|
+
report=artifact,
|
|
791
|
+
)
|
|
792
|
+
_save_state(project_root, state)
|
|
793
|
+
return state
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def _finish_change_unlocked(
|
|
797
|
+
project_root: Path,
|
|
798
|
+
change_id: str,
|
|
799
|
+
*,
|
|
800
|
+
actor_id: str,
|
|
801
|
+
context_id: str,
|
|
802
|
+
kind: str,
|
|
803
|
+
) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
804
|
+
state = load_state(project_root, change_id)
|
|
805
|
+
_require_phase(state, "approved")
|
|
806
|
+
contract = _contract(project_root, state)
|
|
807
|
+
_plan(project_root, state)
|
|
808
|
+
if state["review"] is None:
|
|
809
|
+
raise ContractError("approved change has no review artifact")
|
|
810
|
+
if state["pendingFindings"]:
|
|
811
|
+
raise ContractError("completion requires every pending finding to be resolved")
|
|
812
|
+
review = read_json(_artifact_path(project_root, state["review"]))
|
|
813
|
+
validate_review(review, "registered review")
|
|
814
|
+
source = source_state(project_root)
|
|
815
|
+
if (
|
|
816
|
+
source["dirty"] is not False
|
|
817
|
+
or source["checkpoint"] != review["checkpoint"]
|
|
818
|
+
or source["fingerprint"] != review["workspaceFingerprint"]
|
|
819
|
+
):
|
|
820
|
+
raise ContractError("approved review is stale for the current source")
|
|
821
|
+
required = set(contract["requiredProfiles"])
|
|
822
|
+
if {item["profile"] for item in state["verification"]} != required:
|
|
823
|
+
raise ContractError("completion requires the exact required verification profiles")
|
|
824
|
+
for item in state["verification"]:
|
|
825
|
+
report = read_json(_artifact_path(project_root, item))
|
|
826
|
+
if report.get("status") != "passed":
|
|
827
|
+
raise ContractError(f"verification profile {item['profile']} is not passing")
|
|
828
|
+
if (
|
|
829
|
+
report.get("checkpoint") != review["checkpoint"]
|
|
830
|
+
or report.get("workspaceFingerprint") != review["workspaceFingerprint"]
|
|
831
|
+
):
|
|
832
|
+
raise ContractError(f"verification profile {item['profile']} is stale")
|
|
833
|
+
actor = _actor(actor_id, context_id, kind)
|
|
834
|
+
completion = {
|
|
835
|
+
"schemaVersion": 1,
|
|
836
|
+
"changeId": change_id,
|
|
837
|
+
"cycle": state["cycle"],
|
|
838
|
+
"checkpoint": review["checkpoint"],
|
|
839
|
+
"workspaceFingerprint": review["workspaceFingerprint"],
|
|
840
|
+
"comparisonBase": state["comparisonBase"],
|
|
841
|
+
"completedAt": _timestamp(),
|
|
842
|
+
"completedBy": actor,
|
|
843
|
+
"contract": state["contract"],
|
|
844
|
+
"plan": state["plan"],
|
|
845
|
+
"verification": state["verification"],
|
|
846
|
+
"review": state["review"],
|
|
847
|
+
}
|
|
848
|
+
completion_path = _run_root(project_root, change_id) / "completion.json"
|
|
849
|
+
_write_atomic(completion_path, completion)
|
|
850
|
+
state["completion"] = {
|
|
851
|
+
"path": _relative(project_root, completion_path),
|
|
852
|
+
"digest": _digest_file(completion_path),
|
|
853
|
+
}
|
|
854
|
+
state["phase"] = "completed"
|
|
855
|
+
_event(state, "completed", actor, cycle=state["cycle"])
|
|
856
|
+
_save_state(project_root, state)
|
|
857
|
+
return state, completion
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
def lifecycle_status(project_root: Path, change_id: str) -> dict[str, Any]:
|
|
861
|
+
state = load_state(project_root, change_id)
|
|
862
|
+
issues: list[str] = []
|
|
863
|
+
for name in ("contract", "plan", "review", "completion"):
|
|
864
|
+
artifact = state[name]
|
|
865
|
+
if artifact is None:
|
|
866
|
+
continue
|
|
867
|
+
try:
|
|
868
|
+
_artifact_path(project_root, artifact)
|
|
869
|
+
except ContractError as error:
|
|
870
|
+
issues.append(str(error))
|
|
871
|
+
for evidence in state["verification"]:
|
|
872
|
+
try:
|
|
873
|
+
_artifact_path(project_root, evidence)
|
|
874
|
+
except ContractError as error:
|
|
875
|
+
issues.append(str(error))
|
|
876
|
+
if state["phase"] in {"verified", "review-pending", "approved", "completed"}:
|
|
877
|
+
source = source_state(project_root)
|
|
878
|
+
checkpoints = {item["checkpoint"] for item in state["verification"]}
|
|
879
|
+
fingerprints = {item["workspaceFingerprint"] for item in state["verification"]}
|
|
880
|
+
if (
|
|
881
|
+
source["dirty"] is not False
|
|
882
|
+
or source["checkpoint"] is None
|
|
883
|
+
or source["fingerprint"] is None
|
|
884
|
+
or checkpoints != {source["checkpoint"]}
|
|
885
|
+
or fingerprints != {source["fingerprint"]}
|
|
886
|
+
):
|
|
887
|
+
issues.append("current source no longer matches lifecycle verification")
|
|
888
|
+
return {**state, "current": not issues, "issues": issues}
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
def start_change(
|
|
892
|
+
project_root: Path,
|
|
893
|
+
project: Project,
|
|
894
|
+
contract_path: Path,
|
|
895
|
+
*,
|
|
896
|
+
actor_id: str,
|
|
897
|
+
context_id: str,
|
|
898
|
+
kind: str,
|
|
899
|
+
) -> dict[str, Any]:
|
|
900
|
+
document = read_json(contract_path)
|
|
901
|
+
validate_change(document, str(contract_path))
|
|
902
|
+
change_id = document["id"]
|
|
903
|
+
with _change_lock(project_root, change_id):
|
|
904
|
+
return _start_change_unlocked(
|
|
905
|
+
project_root,
|
|
906
|
+
project,
|
|
907
|
+
contract_path,
|
|
908
|
+
actor_id=actor_id,
|
|
909
|
+
context_id=context_id,
|
|
910
|
+
kind=kind,
|
|
911
|
+
)
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
def register_plan(
|
|
915
|
+
project_root: Path,
|
|
916
|
+
project: Project,
|
|
917
|
+
change_id: str,
|
|
918
|
+
plan_path: Path,
|
|
919
|
+
*,
|
|
920
|
+
actor_id: str,
|
|
921
|
+
context_id: str,
|
|
922
|
+
kind: str,
|
|
923
|
+
) -> dict[str, Any]:
|
|
924
|
+
with _change_lock(project_root, change_id):
|
|
925
|
+
return _register_plan_unlocked(
|
|
926
|
+
project_root,
|
|
927
|
+
project,
|
|
928
|
+
change_id,
|
|
929
|
+
plan_path,
|
|
930
|
+
actor_id=actor_id,
|
|
931
|
+
context_id=context_id,
|
|
932
|
+
kind=kind,
|
|
933
|
+
)
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
def begin_implementation(
|
|
937
|
+
project_root: Path,
|
|
938
|
+
change_id: str,
|
|
939
|
+
*,
|
|
940
|
+
actor_id: str,
|
|
941
|
+
context_id: str,
|
|
942
|
+
kind: str,
|
|
943
|
+
) -> dict[str, Any]:
|
|
944
|
+
with _change_lock(project_root, change_id):
|
|
945
|
+
return _begin_implementation_unlocked(
|
|
946
|
+
project_root,
|
|
947
|
+
change_id,
|
|
948
|
+
actor_id=actor_id,
|
|
949
|
+
context_id=context_id,
|
|
950
|
+
kind=kind,
|
|
951
|
+
)
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
def verify_change(
|
|
955
|
+
project_root: Path,
|
|
956
|
+
project: Project,
|
|
957
|
+
change_id: str,
|
|
958
|
+
profile: str,
|
|
959
|
+
*,
|
|
960
|
+
actor_id: str,
|
|
961
|
+
context_id: str,
|
|
962
|
+
kind: str,
|
|
963
|
+
) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
964
|
+
with _change_lock(project_root, change_id):
|
|
965
|
+
return _verify_change_unlocked(
|
|
966
|
+
project_root,
|
|
967
|
+
project,
|
|
968
|
+
change_id,
|
|
969
|
+
profile,
|
|
970
|
+
actor_id=actor_id,
|
|
971
|
+
context_id=context_id,
|
|
972
|
+
kind=kind,
|
|
973
|
+
)
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
def start_review(
|
|
977
|
+
project_root: Path,
|
|
978
|
+
change_id: str,
|
|
979
|
+
*,
|
|
980
|
+
actor_id: str,
|
|
981
|
+
context_id: str,
|
|
982
|
+
kind: str,
|
|
983
|
+
method: str,
|
|
984
|
+
attested_by: str,
|
|
985
|
+
evidence: str,
|
|
986
|
+
) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
987
|
+
with _change_lock(project_root, change_id):
|
|
988
|
+
return _start_review_unlocked(
|
|
989
|
+
project_root,
|
|
990
|
+
change_id,
|
|
991
|
+
actor_id=actor_id,
|
|
992
|
+
context_id=context_id,
|
|
993
|
+
kind=kind,
|
|
994
|
+
method=method,
|
|
995
|
+
attested_by=attested_by,
|
|
996
|
+
evidence=evidence,
|
|
997
|
+
)
|
|
998
|
+
|
|
999
|
+
|
|
1000
|
+
def submit_review(
|
|
1001
|
+
project_root: Path,
|
|
1002
|
+
change_id: str,
|
|
1003
|
+
report_path: Path,
|
|
1004
|
+
) -> dict[str, Any]:
|
|
1005
|
+
with _change_lock(project_root, change_id):
|
|
1006
|
+
return _submit_review_unlocked(project_root, change_id, report_path)
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
def finish_change(
|
|
1010
|
+
project_root: Path,
|
|
1011
|
+
change_id: str,
|
|
1012
|
+
*,
|
|
1013
|
+
actor_id: str,
|
|
1014
|
+
context_id: str,
|
|
1015
|
+
kind: str,
|
|
1016
|
+
) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
1017
|
+
with _change_lock(project_root, change_id):
|
|
1018
|
+
return _finish_change_unlocked(
|
|
1019
|
+
project_root,
|
|
1020
|
+
change_id,
|
|
1021
|
+
actor_id=actor_id,
|
|
1022
|
+
context_id=context_id,
|
|
1023
|
+
kind=kind,
|
|
1024
|
+
)
|