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,140 @@
|
|
|
1
|
+
"""Per-invocation capture of validated adapter sources and their hash inputs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping, Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import TYPE_CHECKING, Any
|
|
9
|
+
|
|
10
|
+
from .adapter_contracts import ADAPTER_COMMANDS, AdapterError
|
|
11
|
+
from .._cli.cli_commands import adapter_command_metadata
|
|
12
|
+
from ..hashing import canonical_json_bytes, relative_path, sha256_bytes
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from .._config.config_models import PipelineConfig
|
|
16
|
+
from ..registry import PipelineRegistry
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class SourceInput:
|
|
21
|
+
kind: str
|
|
22
|
+
path: str
|
|
23
|
+
content: bytes
|
|
24
|
+
|
|
25
|
+
def record(self) -> dict[str, str]:
|
|
26
|
+
return {"kind": self.kind, "path": self.path, "sha256": sha256_bytes(self.content)}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True)
|
|
30
|
+
class AdapterSources:
|
|
31
|
+
pipelines: tuple[PipelineConfig, ...]
|
|
32
|
+
agents: str
|
|
33
|
+
skills: tuple[tuple[str, str], ...]
|
|
34
|
+
inputs: tuple[SourceInput, ...]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def capture_sources(
|
|
38
|
+
root: Path,
|
|
39
|
+
registry: PipelineRegistry,
|
|
40
|
+
command_metadata: Mapping[str, Any] | None,
|
|
41
|
+
*,
|
|
42
|
+
include_manifest_inputs: bool = True,
|
|
43
|
+
) -> AdapterSources:
|
|
44
|
+
"""Read sources once for rendering and hashing; retain no cross-call cache."""
|
|
45
|
+
|
|
46
|
+
if root != registry.repository_root:
|
|
47
|
+
raise AdapterError("registry and adapter roots differ", {"path": str(registry.path)})
|
|
48
|
+
pipelines = registry.load_all()
|
|
49
|
+
agents = _normalized_text(root / "AGENTS.md", "AGENTS.md")
|
|
50
|
+
skill_sources = _skill_sources(root)
|
|
51
|
+
if ".agents/skills/pipeline/SKILL.md" not in {path for path, _ in skill_sources}:
|
|
52
|
+
raise AdapterError(
|
|
53
|
+
"canonical pipeline orchestrator skill is missing",
|
|
54
|
+
{"path": ".agents/skills/pipeline/SKILL.md"},
|
|
55
|
+
)
|
|
56
|
+
if command_metadata is None:
|
|
57
|
+
phases = sorted({phase for pipeline in pipelines for phase in pipeline.phase_ids})
|
|
58
|
+
command_metadata = adapter_command_metadata(phases)
|
|
59
|
+
cli_commands = _known_cli_commands(command_metadata)
|
|
60
|
+
for command in ADAPTER_COMMANDS:
|
|
61
|
+
if command.cli_command not in cli_commands:
|
|
62
|
+
raise AdapterError(
|
|
63
|
+
"adapter command is absent from CLI metadata",
|
|
64
|
+
{"command": command.cli_command},
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# Validate portable frontmatter before collecting manifest dependencies, as
|
|
68
|
+
# rendering did before source capture was extracted.
|
|
69
|
+
for path, text in skill_sources:
|
|
70
|
+
if not text.splitlines() or text.splitlines()[0] != "---":
|
|
71
|
+
raise AdapterError("canonical skill has no portable frontmatter", {"path": path})
|
|
72
|
+
|
|
73
|
+
# The public render-only facade historically did not read dependency bytes.
|
|
74
|
+
if not include_manifest_inputs:
|
|
75
|
+
return AdapterSources(pipelines, agents, tuple(skill_sources), ())
|
|
76
|
+
|
|
77
|
+
inputs = [
|
|
78
|
+
SourceInput("repository-instructions", "AGENTS.md", agents.encode("utf-8")),
|
|
79
|
+
SourceInput("validated-registry", relative_path(registry.path, root),
|
|
80
|
+
registry.canonical_json.encode("utf-8")),
|
|
81
|
+
]
|
|
82
|
+
dependencies: set[str] = set()
|
|
83
|
+
for pipeline in pipelines:
|
|
84
|
+
inputs.append(SourceInput(
|
|
85
|
+
"validated-pipeline", relative_path(pipeline.path, root),
|
|
86
|
+
pipeline.canonical_json.encode("utf-8"),
|
|
87
|
+
))
|
|
88
|
+
for phase in pipeline.phases:
|
|
89
|
+
for path in (phase.output_template_path, phase.validator.resolved_path):
|
|
90
|
+
if path is not None:
|
|
91
|
+
dependencies.add(relative_path(path, root))
|
|
92
|
+
for path in sorted(dependencies):
|
|
93
|
+
inputs.append(SourceInput("pipeline-dependency", path, (root / path).read_bytes()))
|
|
94
|
+
for path, text in skill_sources:
|
|
95
|
+
inputs.append(SourceInput("canonical-skill", path, text.encode("utf-8")))
|
|
96
|
+
inputs.append(SourceInput(
|
|
97
|
+
"cli-help-metadata", "@cli-help-metadata", canonical_json_bytes(command_metadata),
|
|
98
|
+
))
|
|
99
|
+
return AdapterSources(pipelines, agents, tuple(skill_sources), tuple(inputs))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _skill_sources(root: Path) -> list[tuple[str, str]]:
|
|
103
|
+
skills_root = root / ".agents" / "skills"
|
|
104
|
+
paths = sorted(
|
|
105
|
+
(path for path in skills_root.glob("*/SKILL.md")
|
|
106
|
+
if not path.parent.name.startswith("specromancy-")),
|
|
107
|
+
key=lambda item: item.as_posix(),
|
|
108
|
+
)
|
|
109
|
+
if not paths:
|
|
110
|
+
raise AdapterError(
|
|
111
|
+
"no canonical skills found",
|
|
112
|
+
{"path": ".agents/skills/*/SKILL.md"},
|
|
113
|
+
)
|
|
114
|
+
return [
|
|
115
|
+
(relative_path(path, root), _normalized_text(path, relative_path(path, root)))
|
|
116
|
+
for path in paths
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _normalized_text(path: Path, label: str) -> str:
|
|
121
|
+
try:
|
|
122
|
+
text = path.read_text(encoding="utf-8")
|
|
123
|
+
except (OSError, UnicodeError) as exc:
|
|
124
|
+
raise AdapterError(
|
|
125
|
+
"cannot read canonical adapter source",
|
|
126
|
+
{"path": label, "error": str(exc)},
|
|
127
|
+
) from exc
|
|
128
|
+
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
|
129
|
+
return text.rstrip("\n") + "\n"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _known_cli_commands(metadata: Mapping[str, Any]) -> set[str]:
|
|
133
|
+
commands = metadata.get("commands")
|
|
134
|
+
if not isinstance(commands, Sequence) or isinstance(commands, (str, bytes)):
|
|
135
|
+
raise AdapterError("invalid CLI help metadata", {"field": "commands"})
|
|
136
|
+
known: set[str] = set()
|
|
137
|
+
for item in commands:
|
|
138
|
+
if isinstance(item, Mapping) and isinstance(item.get("name"), str):
|
|
139
|
+
known.add(item["name"])
|
|
140
|
+
return known
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Private cli implementation components; use the public facades."""
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Static CLI descriptions shared by parser construction and adapter metadata."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from ..contracts import RESERVED_COMMANDS
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class CommandDefinition:
|
|
15
|
+
name: str
|
|
16
|
+
description: str
|
|
17
|
+
arguments: list[tuple[tuple[str, ...], dict[str, Any]]]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
COMMANDS = (
|
|
21
|
+
CommandDefinition(
|
|
22
|
+
'init',
|
|
23
|
+
'create a run for DESCRIPTION',
|
|
24
|
+
[
|
|
25
|
+
(('description',), {'metavar': 'DESCRIPTION', 'nargs': '?'}),
|
|
26
|
+
(('--description-file',), {'metavar': 'PATH', 'help': 'read DESCRIPTION from a UTF-8 file'}),
|
|
27
|
+
],
|
|
28
|
+
),
|
|
29
|
+
CommandDefinition(
|
|
30
|
+
'phase',
|
|
31
|
+
'emit the action packet for PHASE',
|
|
32
|
+
[
|
|
33
|
+
(('run_id',), {'metavar': 'RUN_ID'}),
|
|
34
|
+
(('phase',), {'metavar': 'PHASE'}),
|
|
35
|
+
],
|
|
36
|
+
),
|
|
37
|
+
CommandDefinition(
|
|
38
|
+
'validate',
|
|
39
|
+
"validate a run's current or named phase",
|
|
40
|
+
[
|
|
41
|
+
(('run_id',), {'metavar': 'RUN_ID'}),
|
|
42
|
+
(('phase',), {'metavar': 'PHASE', 'nargs': '?'}),
|
|
43
|
+
(('--outcome',), {'metavar': 'OUTCOME'}),
|
|
44
|
+
],
|
|
45
|
+
),
|
|
46
|
+
CommandDefinition(
|
|
47
|
+
'approve',
|
|
48
|
+
'approve a phase artifact',
|
|
49
|
+
[
|
|
50
|
+
(('run_id',), {'metavar': 'RUN_ID'}),
|
|
51
|
+
(('phase',), {'metavar': 'PHASE'}),
|
|
52
|
+
],
|
|
53
|
+
),
|
|
54
|
+
CommandDefinition(
|
|
55
|
+
'request-approval',
|
|
56
|
+
'request approval for the current visit',
|
|
57
|
+
[
|
|
58
|
+
(('run_id',), {'metavar': 'RUN_ID'}),
|
|
59
|
+
(('--reason',), {'metavar': 'CODE'}),
|
|
60
|
+
(('--details',), {'metavar': 'TEXT'}),
|
|
61
|
+
(('--outcome',), {'metavar': 'OUTCOME'}),
|
|
62
|
+
],
|
|
63
|
+
),
|
|
64
|
+
CommandDefinition(
|
|
65
|
+
'block',
|
|
66
|
+
'mark a run blocked',
|
|
67
|
+
[
|
|
68
|
+
(('run_id',), {'metavar': 'RUN_ID'}),
|
|
69
|
+
(('--reason',), {'metavar': 'CODE'}),
|
|
70
|
+
(('--details',), {'metavar': 'TEXT'}),
|
|
71
|
+
],
|
|
72
|
+
),
|
|
73
|
+
CommandDefinition(
|
|
74
|
+
'status',
|
|
75
|
+
'show run status',
|
|
76
|
+
[
|
|
77
|
+
(('run_id',), {'metavar': 'RUN_ID'}),
|
|
78
|
+
],
|
|
79
|
+
),
|
|
80
|
+
CommandDefinition(
|
|
81
|
+
'resume',
|
|
82
|
+
'resume a run',
|
|
83
|
+
[
|
|
84
|
+
(('run_id',), {'metavar': 'RUN_ID'}),
|
|
85
|
+
],
|
|
86
|
+
),
|
|
87
|
+
CommandDefinition(
|
|
88
|
+
'run',
|
|
89
|
+
'advance a run',
|
|
90
|
+
[
|
|
91
|
+
(('run_id',), {'metavar': 'RUN_ID'}),
|
|
92
|
+
],
|
|
93
|
+
),
|
|
94
|
+
CommandDefinition(
|
|
95
|
+
'adapters',
|
|
96
|
+
'manage generated harness adapters',
|
|
97
|
+
[
|
|
98
|
+
],
|
|
99
|
+
),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
if (
|
|
103
|
+
len(COMMANDS) != len(RESERVED_COMMANDS)
|
|
104
|
+
or {command.name for command in COMMANDS} != RESERVED_COMMANDS
|
|
105
|
+
):
|
|
106
|
+
raise RuntimeError("CLI definitions and reserved commands disagree")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def command_definitions(dynamic_phases: Iterable[str] = ()) -> tuple[CommandDefinition, ...]:
|
|
110
|
+
"""Keep built-ins authoritative and aliases local to this invocation."""
|
|
111
|
+
|
|
112
|
+
commands = list(COMMANDS)
|
|
113
|
+
seen = set(RESERVED_COMMANDS)
|
|
114
|
+
for phase in dynamic_phases:
|
|
115
|
+
if phase in RESERVED_COMMANDS:
|
|
116
|
+
continue
|
|
117
|
+
if phase in seen:
|
|
118
|
+
raise argparse.ArgumentError(None, f"conflicting subparser: {phase}")
|
|
119
|
+
seen.add(phase)
|
|
120
|
+
commands.append(
|
|
121
|
+
CommandDefinition(
|
|
122
|
+
phase,
|
|
123
|
+
f"emit the action packet for the {phase} phase",
|
|
124
|
+
[(("run_id",), {"metavar": "RUN_ID"})],
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
return tuple(commands)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def adapter_command_metadata(dynamic_phases: Iterable[str] = ()) -> dict[str, Any]:
|
|
131
|
+
"""Return stable, sorted names and descriptions without inspecting argparse."""
|
|
132
|
+
|
|
133
|
+
commands = sorted(command_definitions(dynamic_phases), key=lambda command: command.name)
|
|
134
|
+
return {"commands": [
|
|
135
|
+
{"name": command.name, "help": command.description} for command in commands
|
|
136
|
+
]}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Select registered initialization graphs or an existing run's saved provenance."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .._config.config_loader import load_pipeline
|
|
9
|
+
from .._config.config_models import PipelineConfig
|
|
10
|
+
from ..errors import UsageError
|
|
11
|
+
from ..registry import PipelineRegistry
|
|
12
|
+
from ..run_store import RunStore
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def selected_pipeline(
|
|
16
|
+
arguments: argparse.Namespace, root: Path, registry: PipelineRegistry
|
|
17
|
+
) -> PipelineConfig:
|
|
18
|
+
selector = getattr(arguments, "pipeline", None)
|
|
19
|
+
if arguments.command == "init":
|
|
20
|
+
if selector is None:
|
|
21
|
+
raise UsageError("init requires --pipeline ID; there is no default pipeline")
|
|
22
|
+
return registry.load(selector)
|
|
23
|
+
|
|
24
|
+
saved = RunStore(root).load(arguments.run_id, verify_artifacts=False)["pipeline"]
|
|
25
|
+
path = root / saved["path"]
|
|
26
|
+
if selector is not None and selector != saved["id"]:
|
|
27
|
+
raise UsageError("--pipeline must match the run's recorded pipeline ID")
|
|
28
|
+
return load_pipeline(path, root)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Private config implementation components; use the public facades."""
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Stable pipeline diagnostics and JSON-safe diagnostic values."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from ..errors import SpecromancyError
|
|
10
|
+
from ..exit_codes import ExitCode
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
_MISSING = object()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PipelineConfigError(SpecromancyError):
|
|
17
|
+
"""An expected pipeline error with a stable diagnostic code."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
diagnostic_code: str,
|
|
22
|
+
message: str,
|
|
23
|
+
*,
|
|
24
|
+
path: Path,
|
|
25
|
+
phase: str | None = None,
|
|
26
|
+
field: str | None = None,
|
|
27
|
+
value: Any = _MISSING,
|
|
28
|
+
remediation: str,
|
|
29
|
+
) -> None:
|
|
30
|
+
details: dict[str, Any] = {
|
|
31
|
+
"error_code": diagnostic_code,
|
|
32
|
+
"path": str(path),
|
|
33
|
+
"remediation": remediation,
|
|
34
|
+
}
|
|
35
|
+
if phase is not None:
|
|
36
|
+
details["phase"] = phase
|
|
37
|
+
if field is not None:
|
|
38
|
+
details["field"] = field
|
|
39
|
+
if value is not _MISSING:
|
|
40
|
+
details["value"] = _json_value(value)
|
|
41
|
+
super().__init__(ExitCode.INVALID_PIPELINE, message, details)
|
|
42
|
+
self.diagnostic_code = diagnostic_code
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _json_value(value: Any) -> Any:
|
|
46
|
+
if isinstance(value, Path):
|
|
47
|
+
return str(value)
|
|
48
|
+
if value is None or isinstance(value, (str, int, float, bool)):
|
|
49
|
+
return value
|
|
50
|
+
if isinstance(value, (list, tuple, set, frozenset)):
|
|
51
|
+
return [_json_value(item) for item in value]
|
|
52
|
+
if isinstance(value, Mapping):
|
|
53
|
+
return {str(key): _json_value(item) for key, item in value.items()}
|
|
54
|
+
isoformat = getattr(value, "isoformat", None)
|
|
55
|
+
if callable(isoformat):
|
|
56
|
+
return isoformat()
|
|
57
|
+
return repr(value)
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Field diagnostics and repository-contained resource resolution for configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, NoReturn
|
|
9
|
+
|
|
10
|
+
from .config_errors import PipelineConfigError, _MISSING
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
IDENTIFIER_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class FieldContext:
|
|
17
|
+
"""Source and repository authority shared by configuration parsers."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, path: Path, repository_root: Path) -> None:
|
|
20
|
+
self.path = path
|
|
21
|
+
self.repository_root = repository_root
|
|
22
|
+
|
|
23
|
+
def fail(
|
|
24
|
+
self,
|
|
25
|
+
diagnostic_code: str,
|
|
26
|
+
message: str,
|
|
27
|
+
*,
|
|
28
|
+
phase: str | None = None,
|
|
29
|
+
field: str | None = None,
|
|
30
|
+
value: Any = _MISSING,
|
|
31
|
+
remediation: str,
|
|
32
|
+
) -> NoReturn:
|
|
33
|
+
raise PipelineConfigError(
|
|
34
|
+
diagnostic_code,
|
|
35
|
+
message,
|
|
36
|
+
path=self.path,
|
|
37
|
+
phase=phase,
|
|
38
|
+
field=field,
|
|
39
|
+
value=value,
|
|
40
|
+
remediation=remediation,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def require_mapping(
|
|
44
|
+
self, value: Any, field: str, *, phase: str | None = None
|
|
45
|
+
) -> Mapping[str, Any]:
|
|
46
|
+
if not isinstance(value, Mapping):
|
|
47
|
+
self.fail(
|
|
48
|
+
"invalid-field-type",
|
|
49
|
+
f"{field} must be a table",
|
|
50
|
+
phase=phase,
|
|
51
|
+
field=field,
|
|
52
|
+
value=value,
|
|
53
|
+
remediation="replace it with a TOML table",
|
|
54
|
+
)
|
|
55
|
+
return value
|
|
56
|
+
|
|
57
|
+
def require_string(
|
|
58
|
+
self, value: Any, field: str, *, phase: str | None = None
|
|
59
|
+
) -> str:
|
|
60
|
+
if not isinstance(value, str) or not value.strip():
|
|
61
|
+
self.fail(
|
|
62
|
+
"invalid-field-type",
|
|
63
|
+
f"{field} must be a non-empty string",
|
|
64
|
+
phase=phase,
|
|
65
|
+
field=field,
|
|
66
|
+
value=value,
|
|
67
|
+
remediation="provide a non-empty string",
|
|
68
|
+
)
|
|
69
|
+
return value
|
|
70
|
+
|
|
71
|
+
def require_identifier(
|
|
72
|
+
self, value: Any, field: str, *, phase: str | None = None
|
|
73
|
+
) -> str:
|
|
74
|
+
identifier = self.require_string(value, field, phase=phase)
|
|
75
|
+
if not IDENTIFIER_PATTERN.fullmatch(identifier):
|
|
76
|
+
self.fail(
|
|
77
|
+
"invalid-identifier",
|
|
78
|
+
f"{field} has invalid identifier {identifier!r}",
|
|
79
|
+
phase=phase,
|
|
80
|
+
field=field,
|
|
81
|
+
value=identifier,
|
|
82
|
+
remediation="use lowercase words separated by single hyphens",
|
|
83
|
+
)
|
|
84
|
+
return identifier
|
|
85
|
+
|
|
86
|
+
def require_integer(
|
|
87
|
+
self,
|
|
88
|
+
value: Any,
|
|
89
|
+
field: str,
|
|
90
|
+
*,
|
|
91
|
+
phase: str | None = None,
|
|
92
|
+
minimum: int | None = None,
|
|
93
|
+
) -> int:
|
|
94
|
+
if not isinstance(value, int) or isinstance(value, bool):
|
|
95
|
+
self.fail(
|
|
96
|
+
"invalid-field-type",
|
|
97
|
+
f"{field} must be an integer",
|
|
98
|
+
phase=phase,
|
|
99
|
+
field=field,
|
|
100
|
+
value=value,
|
|
101
|
+
remediation="provide an integer value",
|
|
102
|
+
)
|
|
103
|
+
if minimum is not None and value < minimum:
|
|
104
|
+
self.fail(
|
|
105
|
+
"invalid-bound",
|
|
106
|
+
f"{field} must be at least {minimum}",
|
|
107
|
+
phase=phase,
|
|
108
|
+
field=field,
|
|
109
|
+
value=value,
|
|
110
|
+
remediation=f"set {field} to {minimum} or greater",
|
|
111
|
+
)
|
|
112
|
+
return value
|
|
113
|
+
|
|
114
|
+
def string_list(
|
|
115
|
+
self,
|
|
116
|
+
value: Any,
|
|
117
|
+
field: str,
|
|
118
|
+
*,
|
|
119
|
+
phase: str | None = None,
|
|
120
|
+
allow_empty: bool = True,
|
|
121
|
+
identifiers: bool = False,
|
|
122
|
+
) -> tuple[str, ...]:
|
|
123
|
+
if not isinstance(value, list):
|
|
124
|
+
self.fail(
|
|
125
|
+
"invalid-field-type",
|
|
126
|
+
f"{field} must be a list of strings",
|
|
127
|
+
phase=phase,
|
|
128
|
+
field=field,
|
|
129
|
+
value=value,
|
|
130
|
+
remediation="provide a TOML array of strings",
|
|
131
|
+
)
|
|
132
|
+
parsed: list[str] = []
|
|
133
|
+
for item in value:
|
|
134
|
+
parsed.append(
|
|
135
|
+
self.require_identifier(item, field, phase=phase)
|
|
136
|
+
if identifiers
|
|
137
|
+
else self.require_string(item, field, phase=phase)
|
|
138
|
+
)
|
|
139
|
+
if not parsed and not allow_empty:
|
|
140
|
+
self.fail(
|
|
141
|
+
"empty-field",
|
|
142
|
+
f"{field} must contain at least one value",
|
|
143
|
+
phase=phase,
|
|
144
|
+
field=field,
|
|
145
|
+
value=[],
|
|
146
|
+
remediation="add at least one entry",
|
|
147
|
+
)
|
|
148
|
+
if len(set(parsed)) != len(parsed):
|
|
149
|
+
self.fail(
|
|
150
|
+
"duplicate-value",
|
|
151
|
+
f"{field} contains duplicate values",
|
|
152
|
+
phase=phase,
|
|
153
|
+
field=field,
|
|
154
|
+
value=parsed,
|
|
155
|
+
remediation="remove duplicate entries",
|
|
156
|
+
)
|
|
157
|
+
return tuple(parsed)
|
|
158
|
+
|
|
159
|
+
def existing_path(
|
|
160
|
+
self,
|
|
161
|
+
declared: str,
|
|
162
|
+
field: str,
|
|
163
|
+
*,
|
|
164
|
+
phase: str | None = None,
|
|
165
|
+
base: Path | None = None,
|
|
166
|
+
) -> Path:
|
|
167
|
+
if not _safe_declared_path(declared, allow_parent=True):
|
|
168
|
+
self.fail(
|
|
169
|
+
"unsafe-path",
|
|
170
|
+
f"{field} must be a safe repository-relative path",
|
|
171
|
+
phase=phase,
|
|
172
|
+
field=field,
|
|
173
|
+
value=declared,
|
|
174
|
+
remediation="use a relative path that resolves inside the repository",
|
|
175
|
+
)
|
|
176
|
+
candidate = ((base or self.path.parent) / declared).resolve()
|
|
177
|
+
if not candidate.is_relative_to(self.repository_root):
|
|
178
|
+
self.fail(
|
|
179
|
+
"unsafe-path",
|
|
180
|
+
f"{field} resolves outside the repository",
|
|
181
|
+
phase=phase,
|
|
182
|
+
field=field,
|
|
183
|
+
value=declared,
|
|
184
|
+
remediation="choose a file inside the repository",
|
|
185
|
+
)
|
|
186
|
+
if not candidate.is_file():
|
|
187
|
+
self.fail(
|
|
188
|
+
"missing-file",
|
|
189
|
+
f"referenced file does not exist: {declared}",
|
|
190
|
+
phase=phase,
|
|
191
|
+
field=field,
|
|
192
|
+
value=declared,
|
|
193
|
+
remediation="create the referenced file or correct the path",
|
|
194
|
+
)
|
|
195
|
+
return candidate
|
|
196
|
+
|
|
197
|
+
def check_unknown(
|
|
198
|
+
self,
|
|
199
|
+
raw: Mapping[str, Any],
|
|
200
|
+
allowed: set[str],
|
|
201
|
+
*,
|
|
202
|
+
phase: str | None = None,
|
|
203
|
+
) -> None:
|
|
204
|
+
unknown = sorted(set(raw) - allowed)
|
|
205
|
+
if unknown:
|
|
206
|
+
self.fail(
|
|
207
|
+
"unknown-field",
|
|
208
|
+
f"unknown configuration field: {unknown[0]}",
|
|
209
|
+
phase=phase,
|
|
210
|
+
field=unknown[0],
|
|
211
|
+
value=raw[unknown[0]],
|
|
212
|
+
remediation="remove the field or use a supported field name",
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _safe_declared_path(
|
|
217
|
+
value: str, *, allow_glob: bool = False, allow_parent: bool = False
|
|
218
|
+
) -> bool:
|
|
219
|
+
if not value or "\\" in value or value.startswith("/"):
|
|
220
|
+
return False
|
|
221
|
+
if re.match(r"^[A-Za-z]:", value):
|
|
222
|
+
return False
|
|
223
|
+
parts = Path(value).parts
|
|
224
|
+
if (".." in parts and not allow_parent) or "." in parts:
|
|
225
|
+
return False
|
|
226
|
+
if not allow_glob and any(character in value for character in "*?["):
|
|
227
|
+
return False
|
|
228
|
+
return True
|