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.
Files changed (63) hide show
  1. specromancy_engine/__init__.py +32 -0
  2. specromancy_engine/__main__.py +8 -0
  3. specromancy_engine/_adapters/__init__.py +1 -0
  4. specromancy_engine/_adapters/adapter_contracts.py +81 -0
  5. specromancy_engine/_adapters/adapter_ownership.py +302 -0
  6. specromancy_engine/_adapters/adapter_rendering.py +218 -0
  7. specromancy_engine/_adapters/adapter_sources.py +140 -0
  8. specromancy_engine/_cli/__init__.py +1 -0
  9. specromancy_engine/_cli/cli_commands.py +136 -0
  10. specromancy_engine/_cli/pipeline_selection.py +28 -0
  11. specromancy_engine/_config/__init__.py +1 -0
  12. specromancy_engine/_config/config_errors.py +57 -0
  13. specromancy_engine/_config/config_fields.py +228 -0
  14. specromancy_engine/_config/config_loader.py +246 -0
  15. specromancy_engine/_config/config_models.py +97 -0
  16. specromancy_engine/_config/config_phase_parser.py +497 -0
  17. specromancy_engine/_config/config_serialization.py +76 -0
  18. specromancy_engine/_engine/__init__.py +1 -0
  19. specromancy_engine/_engine/approval_service.py +278 -0
  20. specromancy_engine/_engine/approvals.py +248 -0
  21. specromancy_engine/_engine/command_decisions.py +81 -0
  22. specromancy_engine/_engine/engine_errors.py +22 -0
  23. specromancy_engine/_engine/provenance.py +58 -0
  24. specromancy_engine/_engine/responses.py +129 -0
  25. specromancy_engine/_engine/validation_service.py +112 -0
  26. specromancy_engine/_runs/__init__.py +1 -0
  27. specromancy_engine/_runs/run_errors.py +51 -0
  28. specromancy_engine/_runs/run_identity.py +66 -0
  29. specromancy_engine/_runs/run_persistence.py +303 -0
  30. specromancy_engine/_runs/run_records.py +159 -0
  31. specromancy_engine/_runs/run_validation.py +241 -0
  32. specromancy_engine/_runs/visit_preparation.py +61 -0
  33. specromancy_engine/_runs/visit_transitions.py +311 -0
  34. specromancy_engine/_version.py +18 -0
  35. specromancy_engine/actions.py +118 -0
  36. specromancy_engine/adapters.py +97 -0
  37. specromancy_engine/artifacts.py +303 -0
  38. specromancy_engine/cli.py +338 -0
  39. specromancy_engine/commands.py +140 -0
  40. specromancy_engine/config.py +63 -0
  41. specromancy_engine/contracts.py +20 -0
  42. specromancy_engine/engine.py +423 -0
  43. specromancy_engine/errors.py +42 -0
  44. specromancy_engine/exit_codes.py +53 -0
  45. specromancy_engine/git.py +264 -0
  46. specromancy_engine/graph.py +321 -0
  47. specromancy_engine/hashing.py +95 -0
  48. specromancy_engine/locking.py +107 -0
  49. specromancy_engine/registry.py +134 -0
  50. specromancy_engine/run_store.py +643 -0
  51. specromancy_engine/schema_validation.py +178 -0
  52. specromancy_engine/schemas/event.schema.json +34 -0
  53. specromancy_engine/schemas/pipeline.schema.json +154 -0
  54. specromancy_engine/schemas/pipelines.schema.json +38 -0
  55. specromancy_engine/schemas/run.schema.json +152 -0
  56. specromancy_engine/status.py +106 -0
  57. specromancy_engine/validation.py +241 -0
  58. specromancy_engine-0.1.3.dist-info/METADATA +122 -0
  59. specromancy_engine-0.1.3.dist-info/RECORD +63 -0
  60. specromancy_engine-0.1.3.dist-info/WHEEL +5 -0
  61. specromancy_engine-0.1.3.dist-info/entry_points.txt +2 -0
  62. specromancy_engine-0.1.3.dist-info/licenses/LICENSE +21 -0
  63. 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
+ )