specromancy-engine 0.1.3__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.
- specromancy_engine/__init__.py +32 -0
- specromancy_engine/__main__.py +8 -0
- specromancy_engine/_adapters/__init__.py +1 -0
- specromancy_engine/_adapters/adapter_contracts.py +81 -0
- specromancy_engine/_adapters/adapter_ownership.py +302 -0
- specromancy_engine/_adapters/adapter_rendering.py +218 -0
- specromancy_engine/_adapters/adapter_sources.py +140 -0
- specromancy_engine/_cli/__init__.py +1 -0
- specromancy_engine/_cli/cli_commands.py +136 -0
- specromancy_engine/_cli/pipeline_selection.py +28 -0
- specromancy_engine/_config/__init__.py +1 -0
- specromancy_engine/_config/config_errors.py +57 -0
- specromancy_engine/_config/config_fields.py +228 -0
- specromancy_engine/_config/config_loader.py +246 -0
- specromancy_engine/_config/config_models.py +97 -0
- specromancy_engine/_config/config_phase_parser.py +497 -0
- specromancy_engine/_config/config_serialization.py +76 -0
- specromancy_engine/_engine/__init__.py +1 -0
- specromancy_engine/_engine/approval_service.py +278 -0
- specromancy_engine/_engine/approvals.py +248 -0
- specromancy_engine/_engine/command_decisions.py +81 -0
- specromancy_engine/_engine/engine_errors.py +22 -0
- specromancy_engine/_engine/provenance.py +58 -0
- specromancy_engine/_engine/responses.py +129 -0
- specromancy_engine/_engine/validation_service.py +112 -0
- specromancy_engine/_runs/__init__.py +1 -0
- specromancy_engine/_runs/run_errors.py +51 -0
- specromancy_engine/_runs/run_identity.py +66 -0
- specromancy_engine/_runs/run_persistence.py +303 -0
- specromancy_engine/_runs/run_records.py +159 -0
- specromancy_engine/_runs/run_validation.py +241 -0
- specromancy_engine/_runs/visit_preparation.py +61 -0
- specromancy_engine/_runs/visit_transitions.py +311 -0
- specromancy_engine/_version.py +18 -0
- specromancy_engine/actions.py +118 -0
- specromancy_engine/adapters.py +97 -0
- specromancy_engine/artifacts.py +303 -0
- specromancy_engine/cli.py +338 -0
- specromancy_engine/commands.py +140 -0
- specromancy_engine/config.py +63 -0
- specromancy_engine/contracts.py +20 -0
- specromancy_engine/engine.py +423 -0
- specromancy_engine/errors.py +42 -0
- specromancy_engine/exit_codes.py +53 -0
- specromancy_engine/git.py +264 -0
- specromancy_engine/graph.py +321 -0
- specromancy_engine/hashing.py +95 -0
- specromancy_engine/locking.py +107 -0
- specromancy_engine/registry.py +134 -0
- specromancy_engine/run_store.py +643 -0
- specromancy_engine/schema_validation.py +178 -0
- specromancy_engine/schemas/event.schema.json +34 -0
- specromancy_engine/schemas/pipeline.schema.json +154 -0
- specromancy_engine/schemas/pipelines.schema.json +38 -0
- specromancy_engine/schemas/run.schema.json +152 -0
- specromancy_engine/status.py +106 -0
- specromancy_engine/validation.py +241 -0
- specromancy_engine-0.1.3.dist-info/METADATA +122 -0
- specromancy_engine-0.1.3.dist-info/RECORD +63 -0
- specromancy_engine-0.1.3.dist-info/WHEEL +5 -0
- specromancy_engine-0.1.3.dist-info/entry_points.txt +2 -0
- specromancy_engine-0.1.3.dist-info/licenses/LICENSE +21 -0
- specromancy_engine-0.1.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Stable process exit codes for the public CLI contract."""
|
|
2
|
+
|
|
3
|
+
from enum import IntEnum
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ExitCode(IntEnum):
|
|
7
|
+
"""Exit status returned by a Specromancy command."""
|
|
8
|
+
|
|
9
|
+
SUCCESS = 0
|
|
10
|
+
USAGE_ERROR = 2
|
|
11
|
+
INVALID_PIPELINE = 3
|
|
12
|
+
NOT_FOUND = 4
|
|
13
|
+
ILLEGAL_TRANSITION = 5
|
|
14
|
+
VALIDATION_FAILED = 6
|
|
15
|
+
APPROVAL_REQUIRED = 7
|
|
16
|
+
AGENT_ACTION_REQUIRED = 8
|
|
17
|
+
RUN_BLOCKED = 9
|
|
18
|
+
LOCK_HELD = 10
|
|
19
|
+
ADAPTER_DRIFT = 11
|
|
20
|
+
INTERNAL_ERROR = 12
|
|
21
|
+
RUN_PAUSED = 13
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
EXIT_CODE_DESCRIPTIONS = {
|
|
25
|
+
ExitCode.SUCCESS: "Command completed successfully",
|
|
26
|
+
ExitCode.USAGE_ERROR: "CLI usage error",
|
|
27
|
+
ExitCode.INVALID_PIPELINE: "Invalid pipeline configuration",
|
|
28
|
+
ExitCode.NOT_FOUND: "Run or artifact not found",
|
|
29
|
+
ExitCode.ILLEGAL_TRANSITION: "Illegal state transition",
|
|
30
|
+
ExitCode.VALIDATION_FAILED: "Validation failed",
|
|
31
|
+
ExitCode.APPROVAL_REQUIRED: "Approval required",
|
|
32
|
+
ExitCode.AGENT_ACTION_REQUIRED: "Agent action required",
|
|
33
|
+
ExitCode.RUN_BLOCKED: "Run blocked or stopped",
|
|
34
|
+
ExitCode.LOCK_HELD: "Concurrent run lock held",
|
|
35
|
+
ExitCode.ADAPTER_DRIFT: "Adapter drift detected",
|
|
36
|
+
ExitCode.INTERNAL_ERROR: "Internal or corrupt-state error",
|
|
37
|
+
ExitCode.RUN_PAUSED: "Run paused at a configured checkpoint",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
# Named integer constants are kept for callers that do not want to import the enum.
|
|
41
|
+
SUCCESS = int(ExitCode.SUCCESS)
|
|
42
|
+
USAGE_ERROR = int(ExitCode.USAGE_ERROR)
|
|
43
|
+
INVALID_PIPELINE = int(ExitCode.INVALID_PIPELINE)
|
|
44
|
+
NOT_FOUND = int(ExitCode.NOT_FOUND)
|
|
45
|
+
ILLEGAL_TRANSITION = int(ExitCode.ILLEGAL_TRANSITION)
|
|
46
|
+
VALIDATION_FAILED = int(ExitCode.VALIDATION_FAILED)
|
|
47
|
+
APPROVAL_REQUIRED = int(ExitCode.APPROVAL_REQUIRED)
|
|
48
|
+
AGENT_ACTION_REQUIRED = int(ExitCode.AGENT_ACTION_REQUIRED)
|
|
49
|
+
RUN_BLOCKED = int(ExitCode.RUN_BLOCKED)
|
|
50
|
+
LOCK_HELD = int(ExitCode.LOCK_HELD)
|
|
51
|
+
ADAPTER_DRIFT = int(ExitCode.ADAPTER_DRIFT)
|
|
52
|
+
INTERNAL_ERROR = int(ExitCode.INTERNAL_ERROR)
|
|
53
|
+
RUN_PAUSED = int(ExitCode.RUN_PAUSED)
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""Content-level Git repository snapshots and mutation-policy checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fnmatch
|
|
6
|
+
import hashlib
|
|
7
|
+
import os
|
|
8
|
+
import stat
|
|
9
|
+
import subprocess
|
|
10
|
+
from pathlib import Path, PurePosixPath
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class GitError(RuntimeError):
|
|
15
|
+
"""Git metadata cannot be captured under the pipeline's policy."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, diagnostic_code: str, message: str, **details: Any) -> None:
|
|
18
|
+
super().__init__(message)
|
|
19
|
+
self.diagnostic_code = diagnostic_code
|
|
20
|
+
self.message = message
|
|
21
|
+
self.details = details
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def capture_repository_snapshot(
|
|
25
|
+
root: Path, *, allow_non_git: bool = False
|
|
26
|
+
) -> dict[str, Any]:
|
|
27
|
+
"""Capture HEAD, branch, and content/mode for tracked and visible files."""
|
|
28
|
+
|
|
29
|
+
root = root.resolve(strict=True)
|
|
30
|
+
try:
|
|
31
|
+
inside = _git(root, "rev-parse", "--is-inside-work-tree", text=True)
|
|
32
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
33
|
+
if allow_non_git:
|
|
34
|
+
return _non_git_snapshot(root)
|
|
35
|
+
raise GitError("git-unavailable", "Git is unavailable", error=str(exc)) from exc
|
|
36
|
+
if inside.returncode != 0 or inside.stdout.strip() != "true":
|
|
37
|
+
if allow_non_git:
|
|
38
|
+
return _non_git_snapshot(root)
|
|
39
|
+
raise GitError(
|
|
40
|
+
"git-worktree-required",
|
|
41
|
+
"the pipeline requires a Git worktree",
|
|
42
|
+
stderr=inside.stderr.strip(),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
head_result = _git(root, "rev-parse", "HEAD", text=True)
|
|
47
|
+
branch_result = _git(
|
|
48
|
+
root, "symbolic-ref", "--quiet", "--short", "HEAD", text=True
|
|
49
|
+
)
|
|
50
|
+
listed = _git(
|
|
51
|
+
root, "ls-files", "-co", "--exclude-standard", "-z", text=False
|
|
52
|
+
)
|
|
53
|
+
index = _git(root, "ls-files", "--stage", "-z", text=False)
|
|
54
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
55
|
+
raise GitError(
|
|
56
|
+
"git-snapshot-failed", "Git metadata capture failed", error=str(exc)
|
|
57
|
+
) from exc
|
|
58
|
+
if listed.returncode != 0:
|
|
59
|
+
raise GitError(
|
|
60
|
+
"git-snapshot-failed",
|
|
61
|
+
"Git could not enumerate repository files",
|
|
62
|
+
stderr=listed.stderr.decode("utf-8", "replace").strip(),
|
|
63
|
+
)
|
|
64
|
+
if index.returncode != 0:
|
|
65
|
+
raise GitError(
|
|
66
|
+
"git-snapshot-failed",
|
|
67
|
+
"Git could not inspect the repository index",
|
|
68
|
+
stderr=index.stderr.decode("utf-8", "replace").strip(),
|
|
69
|
+
)
|
|
70
|
+
files: dict[str, dict[str, Any]] = {}
|
|
71
|
+
for raw in listed.stdout.split(b"\0"):
|
|
72
|
+
if not raw:
|
|
73
|
+
continue
|
|
74
|
+
path = os.fsdecode(raw)
|
|
75
|
+
normalized = PurePosixPath(path).as_posix()
|
|
76
|
+
if normalized == ".git" or normalized.startswith(".git/"):
|
|
77
|
+
continue
|
|
78
|
+
if normalized == ".specromancy" or normalized.startswith(".specromancy/"):
|
|
79
|
+
continue
|
|
80
|
+
if not os.path.lexists(root / normalized):
|
|
81
|
+
# Deleted tracked files are represented by their absence and are
|
|
82
|
+
# therefore discovered by snapshot comparison.
|
|
83
|
+
continue
|
|
84
|
+
files[normalized] = _path_record(root, normalized)
|
|
85
|
+
return {
|
|
86
|
+
"is_worktree": True,
|
|
87
|
+
"head": head_result.stdout.strip() if head_result.returncode == 0 else None,
|
|
88
|
+
"branch": branch_result.stdout.strip() if branch_result.returncode == 0 else None,
|
|
89
|
+
"index_sha256": hashlib.sha256(index.stdout).hexdigest(),
|
|
90
|
+
"files": dict(sorted(files.items())),
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def compare_snapshots(
|
|
95
|
+
before: dict[str, Any], after: dict[str, Any]
|
|
96
|
+
) -> dict[str, Any]:
|
|
97
|
+
"""Return added/changed/deleted/mode/rename details for two snapshots."""
|
|
98
|
+
|
|
99
|
+
old = before.get("files", {})
|
|
100
|
+
new = after.get("files", {})
|
|
101
|
+
old_paths = set(old)
|
|
102
|
+
new_paths = set(new)
|
|
103
|
+
added = set(new_paths - old_paths)
|
|
104
|
+
deleted = set(old_paths - new_paths)
|
|
105
|
+
changed: set[str] = set()
|
|
106
|
+
mode_changed: set[str] = set()
|
|
107
|
+
for path in old_paths & new_paths:
|
|
108
|
+
if old[path].get("sha256") != new[path].get("sha256") or old[path].get(
|
|
109
|
+
"kind"
|
|
110
|
+
) != new[path].get("kind"):
|
|
111
|
+
changed.add(path)
|
|
112
|
+
elif old[path].get("mode") != new[path].get("mode"):
|
|
113
|
+
mode_changed.add(path)
|
|
114
|
+
|
|
115
|
+
renames: list[dict[str, str]] = []
|
|
116
|
+
for source in sorted(tuple(deleted)):
|
|
117
|
+
source_record = old[source]
|
|
118
|
+
destination = next(
|
|
119
|
+
(
|
|
120
|
+
candidate
|
|
121
|
+
for candidate in sorted(added)
|
|
122
|
+
if new[candidate].get("sha256") == source_record.get("sha256")
|
|
123
|
+
and new[candidate].get("kind") == source_record.get("kind")
|
|
124
|
+
),
|
|
125
|
+
None,
|
|
126
|
+
)
|
|
127
|
+
if destination is not None:
|
|
128
|
+
deleted.remove(source)
|
|
129
|
+
added.remove(destination)
|
|
130
|
+
renames.append({"from": source, "to": destination})
|
|
131
|
+
paths = set(added) | set(deleted) | changed | mode_changed
|
|
132
|
+
for rename in renames:
|
|
133
|
+
paths.update(rename.values())
|
|
134
|
+
metadata_changed = [
|
|
135
|
+
name
|
|
136
|
+
for name in ("head", "branch", "index_sha256")
|
|
137
|
+
if before.get(name) != after.get(name)
|
|
138
|
+
]
|
|
139
|
+
return {
|
|
140
|
+
"added": sorted(added),
|
|
141
|
+
"changed": sorted(changed),
|
|
142
|
+
"deleted": sorted(deleted),
|
|
143
|
+
"mode_changed": sorted(mode_changed),
|
|
144
|
+
"renamed": renames,
|
|
145
|
+
"paths": sorted(paths),
|
|
146
|
+
"metadata_changed": metadata_changed,
|
|
147
|
+
"before_head": before.get("head"),
|
|
148
|
+
"after_head": after.get("head"),
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def enforce_mutation_policy(
|
|
153
|
+
before: dict[str, Any],
|
|
154
|
+
after: dict[str, Any],
|
|
155
|
+
policy: str,
|
|
156
|
+
allowlist: tuple[str, ...] | list[str] = (),
|
|
157
|
+
) -> dict[str, Any]:
|
|
158
|
+
"""Compare snapshots and raise when changed paths violate the policy."""
|
|
159
|
+
|
|
160
|
+
comparison = compare_snapshots(before, after)
|
|
161
|
+
comparison["policy"] = policy
|
|
162
|
+
comparison["status"] = "passed"
|
|
163
|
+
comparison["head"] = {
|
|
164
|
+
"is_worktree": after.get("is_worktree", False),
|
|
165
|
+
"head": after.get("head"),
|
|
166
|
+
"branch": after.get("branch"),
|
|
167
|
+
}
|
|
168
|
+
metadata_violations = [
|
|
169
|
+
f"@git/{name.removesuffix('_sha256')}"
|
|
170
|
+
for name in comparison["metadata_changed"]
|
|
171
|
+
]
|
|
172
|
+
violations: list[str] = []
|
|
173
|
+
if policy == "read-only":
|
|
174
|
+
violations = [*comparison["paths"], *metadata_violations]
|
|
175
|
+
elif policy == "allowlist":
|
|
176
|
+
violations = [
|
|
177
|
+
path
|
|
178
|
+
for path in comparison["paths"]
|
|
179
|
+
if not any(_glob_matches(path, pattern) for pattern in allowlist)
|
|
180
|
+
] + metadata_violations
|
|
181
|
+
elif policy != "repository-write":
|
|
182
|
+
raise ValueError(f"unsupported mutation policy: {policy!r}")
|
|
183
|
+
if violations:
|
|
184
|
+
comparison["status"] = "failed"
|
|
185
|
+
comparison["violations"] = violations
|
|
186
|
+
raise GitError(
|
|
187
|
+
"mutation-policy-violation",
|
|
188
|
+
f"repository changes violate the {policy!r} mutation policy",
|
|
189
|
+
mutation_result=comparison,
|
|
190
|
+
paths=violations,
|
|
191
|
+
)
|
|
192
|
+
comparison["violations"] = []
|
|
193
|
+
return comparison
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _path_record(root: Path, relative: str) -> dict[str, Any]:
|
|
197
|
+
path = root / relative
|
|
198
|
+
try:
|
|
199
|
+
metadata = path.lstat()
|
|
200
|
+
except OSError as exc:
|
|
201
|
+
raise GitError(
|
|
202
|
+
"git-snapshot-failed", "an enumerated path cannot be inspected", path=relative
|
|
203
|
+
) from exc
|
|
204
|
+
mode = stat.S_IMODE(metadata.st_mode)
|
|
205
|
+
if stat.S_ISLNK(metadata.st_mode):
|
|
206
|
+
target = os.readlink(path)
|
|
207
|
+
content = os.fsencode(target)
|
|
208
|
+
kind = "symlink"
|
|
209
|
+
elif stat.S_ISREG(metadata.st_mode):
|
|
210
|
+
try:
|
|
211
|
+
content = path.read_bytes()
|
|
212
|
+
except OSError as exc:
|
|
213
|
+
raise GitError(
|
|
214
|
+
"git-snapshot-failed", "an enumerated file cannot be read", path=relative
|
|
215
|
+
) from exc
|
|
216
|
+
kind = "file"
|
|
217
|
+
else:
|
|
218
|
+
raise GitError(
|
|
219
|
+
"git-snapshot-failed",
|
|
220
|
+
"an enumerated path is neither a regular file nor a symlink",
|
|
221
|
+
path=relative,
|
|
222
|
+
)
|
|
223
|
+
return {"sha256": hashlib.sha256(content).hexdigest(), "mode": mode, "kind": kind}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _git(root: Path, *arguments: str, text: bool) -> subprocess.CompletedProcess[Any]:
|
|
227
|
+
return subprocess.run(
|
|
228
|
+
["git", "-C", str(root), *arguments],
|
|
229
|
+
check=False,
|
|
230
|
+
capture_output=True,
|
|
231
|
+
text=text,
|
|
232
|
+
timeout=15,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _non_git_snapshot(root: Path) -> dict[str, Any]:
|
|
237
|
+
files: dict[str, dict[str, Any]] = {}
|
|
238
|
+
for path in sorted(root.rglob("*")):
|
|
239
|
+
relative = path.relative_to(root).as_posix()
|
|
240
|
+
if relative == ".git" or relative.startswith(".git/"):
|
|
241
|
+
continue
|
|
242
|
+
if relative == ".specromancy" or relative.startswith(".specromancy/"):
|
|
243
|
+
continue
|
|
244
|
+
if path.is_symlink() or path.is_file():
|
|
245
|
+
files[relative] = _path_record(root, relative)
|
|
246
|
+
return {
|
|
247
|
+
"is_worktree": False,
|
|
248
|
+
"head": None,
|
|
249
|
+
"branch": None,
|
|
250
|
+
"index_sha256": None,
|
|
251
|
+
"files": files,
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _glob_matches(path: str, pattern: str) -> bool:
|
|
256
|
+
if fnmatch.fnmatchcase(path, pattern):
|
|
257
|
+
return True
|
|
258
|
+
if pattern.endswith("/**") and path == pattern[:-3].rstrip("/"):
|
|
259
|
+
return True
|
|
260
|
+
return False
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# A concise public alias matches the terminology used in action records.
|
|
264
|
+
snapshot_repository = capture_repository_snapshot
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""Generic directed-graph validation for configured pipelines."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import deque
|
|
6
|
+
from collections.abc import Callable, Iterable, Sequence
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
GraphFailure = Callable[..., None]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def reachable_from(start: str, edges: dict[str, set[str]]) -> frozenset[str]:
|
|
14
|
+
"""Return all vertices reachable from *start*, including *start*."""
|
|
15
|
+
|
|
16
|
+
seen: set[str] = set()
|
|
17
|
+
pending = [start]
|
|
18
|
+
while pending:
|
|
19
|
+
current = pending.pop()
|
|
20
|
+
if current in seen:
|
|
21
|
+
continue
|
|
22
|
+
seen.add(current)
|
|
23
|
+
pending.extend(edges.get(current, set()) - seen)
|
|
24
|
+
return frozenset(seen)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def strongly_connected_components(
|
|
28
|
+
vertices: Iterable[str], edges: dict[str, set[str]]
|
|
29
|
+
) -> tuple[frozenset[str], ...]:
|
|
30
|
+
"""Return Tarjan strongly connected components in deterministic order."""
|
|
31
|
+
|
|
32
|
+
index = 0
|
|
33
|
+
indices: dict[str, int] = {}
|
|
34
|
+
lowlinks: dict[str, int] = {}
|
|
35
|
+
stack: list[str] = []
|
|
36
|
+
on_stack: set[str] = set()
|
|
37
|
+
components: list[frozenset[str]] = []
|
|
38
|
+
|
|
39
|
+
def visit(vertex: str) -> None:
|
|
40
|
+
nonlocal index
|
|
41
|
+
indices[vertex] = index
|
|
42
|
+
lowlinks[vertex] = index
|
|
43
|
+
index += 1
|
|
44
|
+
stack.append(vertex)
|
|
45
|
+
on_stack.add(vertex)
|
|
46
|
+
|
|
47
|
+
for target in sorted(edges.get(vertex, set())):
|
|
48
|
+
if target not in indices:
|
|
49
|
+
visit(target)
|
|
50
|
+
lowlinks[vertex] = min(lowlinks[vertex], lowlinks[target])
|
|
51
|
+
elif target in on_stack:
|
|
52
|
+
lowlinks[vertex] = min(lowlinks[vertex], indices[target])
|
|
53
|
+
|
|
54
|
+
if lowlinks[vertex] == indices[vertex]:
|
|
55
|
+
component: set[str] = set()
|
|
56
|
+
while True:
|
|
57
|
+
member = stack.pop()
|
|
58
|
+
on_stack.remove(member)
|
|
59
|
+
component.add(member)
|
|
60
|
+
if member == vertex:
|
|
61
|
+
break
|
|
62
|
+
components.append(frozenset(component))
|
|
63
|
+
|
|
64
|
+
for vertex in sorted(vertices):
|
|
65
|
+
if vertex not in indices:
|
|
66
|
+
visit(vertex)
|
|
67
|
+
return tuple(components)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _cyclic_components(
|
|
71
|
+
vertices: Iterable[str], edges: dict[str, set[str]]
|
|
72
|
+
) -> tuple[frozenset[str], ...]:
|
|
73
|
+
return tuple(
|
|
74
|
+
component
|
|
75
|
+
for component in strongly_connected_components(vertices, edges)
|
|
76
|
+
if len(component) > 1
|
|
77
|
+
or any(vertex in edges.get(vertex, set()) for vertex in component)
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _producer_is_guaranteed(
|
|
82
|
+
start: str,
|
|
83
|
+
consumer: str,
|
|
84
|
+
producer: str,
|
|
85
|
+
edges: dict[str, set[str]],
|
|
86
|
+
) -> bool:
|
|
87
|
+
"""Whether every first route completes *producer* before *consumer*."""
|
|
88
|
+
|
|
89
|
+
# Search for a counterexample route while treating arrival at the producer
|
|
90
|
+
# as completion only when traversing an edge away from it. An immediate
|
|
91
|
+
# arrival at a consumer cannot consume that visit's not-yet-written output.
|
|
92
|
+
pending = [start]
|
|
93
|
+
seen: set[str] = set()
|
|
94
|
+
while pending:
|
|
95
|
+
current = pending.pop()
|
|
96
|
+
if current in seen:
|
|
97
|
+
continue
|
|
98
|
+
seen.add(current)
|
|
99
|
+
if current == consumer:
|
|
100
|
+
return False
|
|
101
|
+
if current == producer:
|
|
102
|
+
continue
|
|
103
|
+
pending.extend(edges.get(current, set()))
|
|
104
|
+
return True
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _visit_is_guaranteed(
|
|
108
|
+
start: str, consumer: str, ordinal: int, edges: dict[str, set[str]]
|
|
109
|
+
) -> bool:
|
|
110
|
+
"""Whether at least *ordinal* outputs exist on every route to *consumer*."""
|
|
111
|
+
|
|
112
|
+
pending = deque([(start, 0)])
|
|
113
|
+
seen: set[str] = set()
|
|
114
|
+
while pending:
|
|
115
|
+
current, completed = pending.popleft()
|
|
116
|
+
if current in seen:
|
|
117
|
+
continue
|
|
118
|
+
seen.add(current)
|
|
119
|
+
if current == consumer:
|
|
120
|
+
return completed >= ordinal
|
|
121
|
+
after_current = completed + 1
|
|
122
|
+
pending.extend(
|
|
123
|
+
(target, after_current)
|
|
124
|
+
for target in sorted(edges.get(current, set()))
|
|
125
|
+
)
|
|
126
|
+
return True
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def validate_graph(
|
|
130
|
+
phases: Sequence[Any],
|
|
131
|
+
start: str,
|
|
132
|
+
terminal_outcomes: frozenset[str] | set[str],
|
|
133
|
+
fail: GraphFailure,
|
|
134
|
+
) -> None:
|
|
135
|
+
"""Validate phase connectivity, bounded cycles, and input producers.
|
|
136
|
+
|
|
137
|
+
``fail`` is supplied by the configuration layer so this module remains
|
|
138
|
+
independent of diagnostic and domain-model implementation details.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
phase_by_id: dict[str, Any] = {}
|
|
142
|
+
for phase in phases:
|
|
143
|
+
if phase.id in phase_by_id:
|
|
144
|
+
fail(
|
|
145
|
+
"duplicate-phase",
|
|
146
|
+
f"phase id {phase.id!r} is declared more than once",
|
|
147
|
+
phase=phase.id,
|
|
148
|
+
field="id",
|
|
149
|
+
value=phase.id,
|
|
150
|
+
remediation="give every phase a unique id",
|
|
151
|
+
)
|
|
152
|
+
phase_by_id[phase.id] = phase
|
|
153
|
+
|
|
154
|
+
if start not in phase_by_id:
|
|
155
|
+
fail(
|
|
156
|
+
"missing-start-phase",
|
|
157
|
+
f"start phase {start!r} is not declared",
|
|
158
|
+
field="start",
|
|
159
|
+
value=start,
|
|
160
|
+
remediation="set start to the id of a declared phase",
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
edges: dict[str, set[str]] = {phase.id: set() for phase in phases}
|
|
164
|
+
bounded_edges: set[tuple[str, str]] = set()
|
|
165
|
+
unbounded_edges: set[tuple[str, str]] = set()
|
|
166
|
+
for phase in phases:
|
|
167
|
+
seen_outcomes: set[str] = set()
|
|
168
|
+
if not phase.transitions:
|
|
169
|
+
fail(
|
|
170
|
+
"missing-transition",
|
|
171
|
+
f"phase {phase.id!r} has no possible outcome",
|
|
172
|
+
phase=phase.id,
|
|
173
|
+
field="transitions",
|
|
174
|
+
value=[],
|
|
175
|
+
remediation="declare at least one phase or terminal transition",
|
|
176
|
+
)
|
|
177
|
+
for transition in phase.transitions:
|
|
178
|
+
if transition.outcome in seen_outcomes:
|
|
179
|
+
fail(
|
|
180
|
+
"duplicate-outcome",
|
|
181
|
+
f"phase {phase.id!r} declares outcome {transition.outcome!r} more than once",
|
|
182
|
+
phase=phase.id,
|
|
183
|
+
field="transitions.outcome",
|
|
184
|
+
value=transition.outcome,
|
|
185
|
+
remediation="make transition outcomes unique within the phase",
|
|
186
|
+
)
|
|
187
|
+
seen_outcomes.add(transition.outcome)
|
|
188
|
+
is_terminal = transition.outcome in terminal_outcomes
|
|
189
|
+
if is_terminal:
|
|
190
|
+
if transition.pause:
|
|
191
|
+
fail(
|
|
192
|
+
"terminal-transition-pause",
|
|
193
|
+
f"terminal outcome {transition.outcome!r} cannot pause",
|
|
194
|
+
phase=phase.id,
|
|
195
|
+
field="transitions.pause",
|
|
196
|
+
value=True,
|
|
197
|
+
remediation="remove pause or target a non-terminal phase",
|
|
198
|
+
)
|
|
199
|
+
if transition.target is not None:
|
|
200
|
+
fail(
|
|
201
|
+
"terminal-outcome-target",
|
|
202
|
+
f"terminal outcome {transition.outcome!r} cannot target a phase",
|
|
203
|
+
phase=phase.id,
|
|
204
|
+
field="transitions.target",
|
|
205
|
+
value=transition.target,
|
|
206
|
+
remediation="remove target from the terminal transition",
|
|
207
|
+
)
|
|
208
|
+
continue
|
|
209
|
+
if transition.target is None:
|
|
210
|
+
fail(
|
|
211
|
+
"missing-transition-target",
|
|
212
|
+
f"non-terminal outcome {transition.outcome!r} needs a target",
|
|
213
|
+
phase=phase.id,
|
|
214
|
+
field="transitions.target",
|
|
215
|
+
value=None,
|
|
216
|
+
remediation="name a declared phase or use a terminal outcome",
|
|
217
|
+
)
|
|
218
|
+
if transition.target not in phase_by_id:
|
|
219
|
+
fail(
|
|
220
|
+
"unknown-transition-target",
|
|
221
|
+
f"outcome {transition.outcome!r} targets unknown phase {transition.target!r}",
|
|
222
|
+
phase=phase.id,
|
|
223
|
+
field="transitions.target",
|
|
224
|
+
value=transition.target,
|
|
225
|
+
remediation="target a declared phase",
|
|
226
|
+
)
|
|
227
|
+
edges[phase.id].add(transition.target)
|
|
228
|
+
if transition.max_traversals is not None:
|
|
229
|
+
bounded_edges.add((phase.id, transition.target))
|
|
230
|
+
else:
|
|
231
|
+
unbounded_edges.add((phase.id, transition.target))
|
|
232
|
+
|
|
233
|
+
# Parallel outcomes are distinct runtime edges. Collapsing them for graph
|
|
234
|
+
# traversal is safe only when every outcome between the pair is bounded.
|
|
235
|
+
bounded_edges -= unbounded_edges
|
|
236
|
+
|
|
237
|
+
reachable = reachable_from(start, edges)
|
|
238
|
+
unreachable = sorted(set(phase_by_id) - reachable)
|
|
239
|
+
if unreachable:
|
|
240
|
+
fail(
|
|
241
|
+
"unreachable-phase",
|
|
242
|
+
f"phases are unreachable from {start!r}: {', '.join(unreachable)}",
|
|
243
|
+
field="phases",
|
|
244
|
+
value=unreachable,
|
|
245
|
+
remediation="add transitions from the reachable graph or remove the phases",
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
# A collection of bounds is mechanically sufficient only if removing the
|
|
249
|
+
# bounded phases and edges leaves no cycle. This is stronger than merely
|
|
250
|
+
# finding one bound somewhere in a large strongly connected component.
|
|
251
|
+
unbounded_vertices = {
|
|
252
|
+
phase.id for phase in phases if phase.max_visits is None
|
|
253
|
+
}
|
|
254
|
+
unbounded_edges = {
|
|
255
|
+
source: {
|
|
256
|
+
target
|
|
257
|
+
for target in targets
|
|
258
|
+
if target in unbounded_vertices
|
|
259
|
+
and (source, target) not in bounded_edges
|
|
260
|
+
}
|
|
261
|
+
for source, targets in edges.items()
|
|
262
|
+
if source in unbounded_vertices
|
|
263
|
+
}
|
|
264
|
+
unbounded_cycles = _cyclic_components(unbounded_vertices, unbounded_edges)
|
|
265
|
+
if unbounded_cycles:
|
|
266
|
+
members = sorted(set().union(*unbounded_cycles))
|
|
267
|
+
fail(
|
|
268
|
+
"unbounded-cycle",
|
|
269
|
+
f"cycle has no finite visit or traversal bound: {', '.join(members)}",
|
|
270
|
+
field="phases.transitions",
|
|
271
|
+
value=members,
|
|
272
|
+
remediation="set max_visits on a phase or max_traversals on an edge in every cycle",
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
for phase in phases:
|
|
276
|
+
for reference in phase.inputs:
|
|
277
|
+
if reference == "request":
|
|
278
|
+
continue
|
|
279
|
+
if reference.startswith("visit:"):
|
|
280
|
+
ordinal = int(reference.removeprefix("visit:"))
|
|
281
|
+
if not _visit_is_guaranteed(start, phase.id, ordinal, edges):
|
|
282
|
+
fail(
|
|
283
|
+
"unavailable-input",
|
|
284
|
+
f"input {reference!r} cannot exist before phase {phase.id!r}",
|
|
285
|
+
phase=phase.id,
|
|
286
|
+
field="inputs",
|
|
287
|
+
value=reference,
|
|
288
|
+
remediation="reference an earlier visit or change the incoming graph",
|
|
289
|
+
)
|
|
290
|
+
continue
|
|
291
|
+
if not reference.startswith("latest:"):
|
|
292
|
+
fail(
|
|
293
|
+
"invalid-input-reference",
|
|
294
|
+
f"phase {phase.id!r} has invalid input reference {reference!r}",
|
|
295
|
+
phase=phase.id,
|
|
296
|
+
field="inputs",
|
|
297
|
+
value=reference,
|
|
298
|
+
remediation="use request, latest:<phase>, or visit:<positive-number>",
|
|
299
|
+
)
|
|
300
|
+
producer = reference.removeprefix("latest:")
|
|
301
|
+
if producer not in phase_by_id:
|
|
302
|
+
fail(
|
|
303
|
+
"unknown-input-producer",
|
|
304
|
+
f"input {reference!r} names unknown phase {producer!r}",
|
|
305
|
+
phase=phase.id,
|
|
306
|
+
field="inputs",
|
|
307
|
+
value=reference,
|
|
308
|
+
remediation="name a phase that can produce an earlier artifact",
|
|
309
|
+
)
|
|
310
|
+
if not _producer_is_guaranteed(start, phase.id, producer, edges):
|
|
311
|
+
fail(
|
|
312
|
+
"unavailable-input",
|
|
313
|
+
f"input {reference!r} is not produced on every route to phase {phase.id!r}",
|
|
314
|
+
phase=phase.id,
|
|
315
|
+
field="inputs",
|
|
316
|
+
value=reference,
|
|
317
|
+
remediation=(
|
|
318
|
+
"make the producer precede every incoming route or "
|
|
319
|
+
"remove the input"
|
|
320
|
+
),
|
|
321
|
+
)
|