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,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