foundry-testing-actor 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- foundry_testing_actor/__init__.py +56 -0
- foundry_testing_actor/cards/actor-data.yaml +68 -0
- foundry_testing_actor/cards/actor-message.yaml +32 -0
- foundry_testing_actor/cards/actor-synchronous-messaging.yaml +59 -0
- foundry_testing_actor/cards/actor.yaml +19 -0
- foundry_testing_actor/cli.py +181 -0
- foundry_testing_actor/config.py +525 -0
- foundry_testing_actor/conformance.py +133 -0
- foundry_testing_actor/correlation.py +193 -0
- foundry_testing_actor/engine.py +889 -0
- foundry_testing_actor/grounding.py +206 -0
- foundry_testing_actor/handler.py +248 -0
- foundry_testing_actor/instance.py +129 -0
- foundry_testing_actor/runner/Dockerfile +32 -0
- foundry_testing_actor/schemas/agentic-context.schema.yaml +168 -0
- foundry_testing_actor/serve.py +129 -0
- foundry_testing_actor-0.1.0.dist-info/METADATA +258 -0
- foundry_testing_actor-0.1.0.dist-info/RECORD +20 -0
- foundry_testing_actor-0.1.0.dist-info/WHEEL +4 -0
- foundry_testing_actor-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
"""`CapabilityConfig` — one capability id and one testing repo, every other rendering derived.
|
|
2
|
+
|
|
3
|
+
WHY THIS FILE EXISTS. The hand-written testing actor this package replaces spelled its capability
|
|
4
|
+
in more places than its implementation sibling ever did: the dotted id, its own `<owner>/<repo>`,
|
|
5
|
+
the implementation repo it read from, the knowledge registry, the registry path with `CAP` dropped,
|
|
6
|
+
the test image name, the image name of the component under test, three tempdir prefixes, the git
|
|
7
|
+
`user.name` and `user.email`, and the write boundary — once in the sidecar and once more in the
|
|
8
|
+
module that enforced it. Every one of those was correct only for as long as someone remembered to
|
|
9
|
+
change all of them together.
|
|
10
|
+
|
|
11
|
+
They are now derivations of two fields. Nothing in this package spells a capability.
|
|
12
|
+
|
|
13
|
+
capability ACME.PARTS.CAP.SUP.007.WID ← the only id anyone writes
|
|
14
|
+
source_repo <owner>/ACME.PARTS.CAP.SUP.007.WID-testing ← and the only repo
|
|
15
|
+
|
|
16
|
+
implementation_repo <owner>/<capability>-implementation (declared only when it differs)
|
|
17
|
+
actor_name <repo half of source_repo>
|
|
18
|
+
actor_slug same, lowercased, dots to hyphens
|
|
19
|
+
git_author_name actor_name
|
|
20
|
+
git_author_email {actor_slug}@users.noreply.github.com
|
|
21
|
+
clone_prefix(t) {actor_slug}-{t}-
|
|
22
|
+
code_clone_prefix(t) {actor_slug}-code-{t}-
|
|
23
|
+
capability_path the id lowercased, split AT its `cap` segment: head / tail
|
|
24
|
+
test_image_name(c) {capability lowercased}-{c}-tests
|
|
25
|
+
test_image_ref(r,c,v) {r}/{capability_path}/{c}/tests:{v}
|
|
26
|
+
image_name(c) {capability lowercased}-{c} ← what implementation versions
|
|
27
|
+
image_ref(r,c,v) {r}/{capability_path}/{c}:{v} ← what implementation published
|
|
28
|
+
|
|
29
|
+
THE IMAGE REFS ARE A THREE-WAY CONTRACT. `image_ref` must stay byte-identical to what the
|
|
30
|
+
implementation actor publishes, because this actor recomputes it rather than being told it — and
|
|
31
|
+
`test_image_ref` is what the orchestrating actor parses back apart to run. A component's tests are
|
|
32
|
+
a CHILD path of the component (`<component>/tests`), not a `-tests` sibling: unambiguous, and it
|
|
33
|
+
makes retention one rule. Both are derivation output or nothing; no tag scheme is invented here.
|
|
34
|
+
`test_the_image_refs_are_the_three_way_contract` in the test suite is what pins them.
|
|
35
|
+
|
|
36
|
+
WHAT IS DERIVED AND WHAT IS DECLARED. Anything recoverable from the id or the repo is derived.
|
|
37
|
+
Anything genuinely additional — which components exist, where each one's tests live, which runner
|
|
38
|
+
builds them, what grounds the session — is declared in the sidecar, once. The write boundary in
|
|
39
|
+
particular is `components[].tests` and nothing else: it used to be declared in the sidecar AND
|
|
40
|
+
hardcoded in the module that actually enforced it, which is how a write boundary comes to be stated
|
|
41
|
+
twice and eventually stated differently.
|
|
42
|
+
"""
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
import re
|
|
46
|
+
from dataclasses import dataclass
|
|
47
|
+
from importlib import metadata
|
|
48
|
+
from pathlib import Path
|
|
49
|
+
|
|
50
|
+
import yaml
|
|
51
|
+
|
|
52
|
+
CONTRACT = "foundry-testing-actor/agentic-context/v1"
|
|
53
|
+
SIDECAR = "actor-agentic-context.yaml"
|
|
54
|
+
|
|
55
|
+
_SCHEMA_PATH = Path(__file__).resolve().parent / "schemas" / "agentic-context.schema.yaml"
|
|
56
|
+
_CARDS_PATH = Path(__file__).resolve().parent / "cards"
|
|
57
|
+
_RUNNER_PATH = Path(__file__).resolve().parent / "runner"
|
|
58
|
+
|
|
59
|
+
# The segment a capability id carries to say "capability". It is dropped from the registry path
|
|
60
|
+
# because the path position already says it — every other token of the id survives, across
|
|
61
|
+
# segments rather than concatenated.
|
|
62
|
+
_CAPABILITY_SEGMENT = "cap"
|
|
63
|
+
|
|
64
|
+
# The suffix the implementation repo carries when it is not declared. The same `<id>-<kind>`
|
|
65
|
+
# naming that makes this repo `<id>-testing` (ADR-ECO-0022) — so a use that follows the
|
|
66
|
+
# convention never writes the field at all, and one that does not says so in one line.
|
|
67
|
+
_IMPLEMENTATION_SUFFIX = "-implementation"
|
|
68
|
+
|
|
69
|
+
# What an unsubstituted placeholder looks like: a bare lowercase word in braces, and nothing else.
|
|
70
|
+
# Narrow on purpose — see `CapabilityConfig.expand`.
|
|
71
|
+
_PLACEHOLDER = re.compile(r"\{([a-z_]+)\}")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def version() -> str:
|
|
75
|
+
"""This package's own version, as installed.
|
|
76
|
+
|
|
77
|
+
Read from the installed distribution rather than written down a second time here: a literal in
|
|
78
|
+
the source and a version in `pyproject.toml` are two facts that can disagree, and the one a
|
|
79
|
+
rendered card would carry is the one nobody checks. In a source checkout with nothing
|
|
80
|
+
installed there is no distribution to ask, and `unknown` is the honest answer — a banner is
|
|
81
|
+
provenance, not a gate, and no behaviour turns on it.
|
|
82
|
+
"""
|
|
83
|
+
try:
|
|
84
|
+
return metadata.version("foundry-testing-actor")
|
|
85
|
+
except metadata.PackageNotFoundError:
|
|
86
|
+
return "unknown"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def cards_path() -> Path:
|
|
90
|
+
"""The folder holding this actor's own four cards — its definition, shipped in the wheel.
|
|
91
|
+
|
|
92
|
+
The cards say what a foundry testing actor IS: its data dictionary, its message catalog, and
|
|
93
|
+
the two doors it answers. They name no capability, because which capability an instance serves
|
|
94
|
+
is not part of what the actor is — that is the sidecar, supplied per use.
|
|
95
|
+
|
|
96
|
+
This path is what `render-cards` renders a use from, and what
|
|
97
|
+
`papeete-actor-synchronous-messaging lint-card` is pointed at to check that the `-actor` suffix
|
|
98
|
+
in this package's name is a claim it actually honours (ADR-ECO-0022).
|
|
99
|
+
"""
|
|
100
|
+
return _CARDS_PATH
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def runner_path() -> Path:
|
|
104
|
+
"""The folder holding the default test-runner Dockerfile, shipped in the wheel.
|
|
105
|
+
|
|
106
|
+
`buildctl --local dockerfile=` takes a directory on the machine running `buildctl`, not a path
|
|
107
|
+
inside the build context — so a runner that ships with the actor is addressable exactly as one
|
|
108
|
+
committed in the testing repo is, with no copy step. A component that declares no `runner:` is
|
|
109
|
+
built with this one (see the schema for why that is the default rather than a requirement).
|
|
110
|
+
"""
|
|
111
|
+
return _RUNNER_PATH
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def load_schema() -> dict:
|
|
115
|
+
"""The contract, as committed source inside this package.
|
|
116
|
+
|
|
117
|
+
The path is the same in a source checkout and in an installed wheel, so there is no fallback
|
|
118
|
+
and no second location to reason about. A wheel that lost it is a gate with nothing to
|
|
119
|
+
enforce, which is worth failing loudly over rather than degrading past.
|
|
120
|
+
"""
|
|
121
|
+
if not _SCHEMA_PATH.exists():
|
|
122
|
+
raise FileNotFoundError(
|
|
123
|
+
f"{_SCHEMA_PATH.name} not found in {_SCHEMA_PATH.parent}.\n"
|
|
124
|
+
" The contract is committed source in this package, so this should be unreachable.\n"
|
|
125
|
+
" In a source checkout: the file was deleted — restore it from git.\n"
|
|
126
|
+
" In an installed wheel: the build shipped without its contract. Report it against "
|
|
127
|
+
"the release."
|
|
128
|
+
)
|
|
129
|
+
return yaml.safe_load(_SCHEMA_PATH.read_text())
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class ConfigError(ValueError):
|
|
133
|
+
"""The sidecar is unusable — missing, malformed, or internally inconsistent."""
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass(frozen=True)
|
|
137
|
+
class Component:
|
|
138
|
+
"""One component this actor writes tests for and publishes a test image of."""
|
|
139
|
+
|
|
140
|
+
name: str
|
|
141
|
+
tests: str
|
|
142
|
+
runner: str | None = None # None: the runner this package ships (`runner_path()`)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass(frozen=True)
|
|
146
|
+
class Grounding:
|
|
147
|
+
"""One knowledge source the session is grounded in before its first turn."""
|
|
148
|
+
|
|
149
|
+
name: str
|
|
150
|
+
answers: str
|
|
151
|
+
fetch: tuple[str, ...]
|
|
152
|
+
into: str
|
|
153
|
+
load: str
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def eager(self) -> bool:
|
|
157
|
+
return self.load == "eager"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@dataclass(frozen=True)
|
|
161
|
+
class CapabilityConfig:
|
|
162
|
+
"""Everything this actor needs to know about the capability it serves."""
|
|
163
|
+
|
|
164
|
+
capability: str
|
|
165
|
+
source_repo: str
|
|
166
|
+
registry_repo: str
|
|
167
|
+
engine: str
|
|
168
|
+
components: tuple[Component, ...]
|
|
169
|
+
ground_in: tuple[Grounding, ...]
|
|
170
|
+
# None: derived — see `implementation_repo`. Kept as the DECLARED value so `show` can say
|
|
171
|
+
# which of the two it is.
|
|
172
|
+
declared_implementation_repo: str | None = None
|
|
173
|
+
|
|
174
|
+
# ── loading ─────────────────────────────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
@classmethod
|
|
177
|
+
def load(cls, folder: str | Path = ".") -> CapabilityConfig:
|
|
178
|
+
"""Read the sidecar from `folder` (or from the file itself, if a file is given).
|
|
179
|
+
|
|
180
|
+
Raises `ConfigError` for anything that would otherwise surface much later — a missing
|
|
181
|
+
file, a contract this package does not implement, an absent required key, a component
|
|
182
|
+
whose `tests` root does not end in `/`. Every one of those is cheaper here than
|
|
183
|
+
mid-session.
|
|
184
|
+
"""
|
|
185
|
+
path = Path(folder)
|
|
186
|
+
if path.is_dir():
|
|
187
|
+
path = path / SIDECAR
|
|
188
|
+
try:
|
|
189
|
+
raw = yaml.safe_load(path.read_text())
|
|
190
|
+
except OSError as e:
|
|
191
|
+
raise ConfigError(f"{path}: cannot be read: {e}") from e
|
|
192
|
+
except yaml.YAMLError as e:
|
|
193
|
+
raise ConfigError(f"{path}: does not parse: {e}") from e
|
|
194
|
+
return cls.from_dict(raw, source=str(path))
|
|
195
|
+
|
|
196
|
+
@classmethod
|
|
197
|
+
def from_dict(cls, raw: object, *, source: str = "<dict>") -> CapabilityConfig:
|
|
198
|
+
if not isinstance(raw, dict):
|
|
199
|
+
raise ConfigError(f"{source}: not a mapping")
|
|
200
|
+
if raw.get("context") != CONTRACT:
|
|
201
|
+
raise ConfigError(
|
|
202
|
+
f"{source}: declares context '{raw.get('context')}', not {CONTRACT}"
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
for key in load_schema()["required"]:
|
|
206
|
+
if key not in raw:
|
|
207
|
+
raise ConfigError(f"{source}: missing required key '{key}'")
|
|
208
|
+
|
|
209
|
+
components = tuple(_component(entry, source, i)
|
|
210
|
+
for i, entry in enumerate(raw["components"] or ()))
|
|
211
|
+
if not components:
|
|
212
|
+
raise ConfigError(f"{source}: `components` is empty — nothing this actor may write to")
|
|
213
|
+
names = [c.name for c in components]
|
|
214
|
+
duplicates = sorted({n for n in names if names.count(n) > 1})
|
|
215
|
+
if duplicates:
|
|
216
|
+
# A caller names components by name at both doors. Two entries answering to one name
|
|
217
|
+
# would make "which tests root" a question with two answers.
|
|
218
|
+
raise ConfigError(f"{source}: component name(s) declared twice: {duplicates}")
|
|
219
|
+
|
|
220
|
+
ground_in = tuple(_grounding(entry, source, i)
|
|
221
|
+
for i, entry in enumerate(raw["ground_in"] or ()))
|
|
222
|
+
|
|
223
|
+
implementation_repo = raw.get("implementation_repo")
|
|
224
|
+
config = cls(
|
|
225
|
+
capability=str(raw["capability"]),
|
|
226
|
+
source_repo=str(raw["source_repo"]),
|
|
227
|
+
registry_repo=str(raw["registry_repo"]),
|
|
228
|
+
engine=str(raw["engine"]),
|
|
229
|
+
components=components,
|
|
230
|
+
ground_in=ground_in,
|
|
231
|
+
declared_implementation_repo=(str(implementation_repo)
|
|
232
|
+
if implementation_repo else None),
|
|
233
|
+
)
|
|
234
|
+
# Force the derivations that can fail, here rather than at the first request that needs
|
|
235
|
+
# one. A capability id with no `cap` segment is a typo, and it should not survive startup.
|
|
236
|
+
_ = config.capability_path, config.actor_name, config.implementation_repo
|
|
237
|
+
return config
|
|
238
|
+
|
|
239
|
+
# ── the derived renderings ──────────────────────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
@property
|
|
242
|
+
def actor_name(self) -> str:
|
|
243
|
+
"""The repo half of `source_repo` — this actor's own name, and its git author name."""
|
|
244
|
+
return _split_repo(self.source_repo, "source_repo")[1]
|
|
245
|
+
|
|
246
|
+
@property
|
|
247
|
+
def actor_slug(self) -> str:
|
|
248
|
+
"""The actor name as a path/address-safe token: lowercased, dots to hyphens."""
|
|
249
|
+
return self.actor_name.lower().replace(".", "-")
|
|
250
|
+
|
|
251
|
+
@property
|
|
252
|
+
def implementation_repo(self) -> str:
|
|
253
|
+
"""`<owner>/<repo>` of the repository the implementation actor pushes to.
|
|
254
|
+
|
|
255
|
+
Declared when it differs; otherwise the same owner as `source_repo`, and the repo named
|
|
256
|
+
`<capability>-implementation` — the convention that already names this repo
|
|
257
|
+
`<capability>-testing`. Read, never written: this actor clones it and pushes nothing back.
|
|
258
|
+
"""
|
|
259
|
+
if self.declared_implementation_repo:
|
|
260
|
+
_split_repo(self.declared_implementation_repo, "implementation_repo")
|
|
261
|
+
return self.declared_implementation_repo
|
|
262
|
+
owner, _ = _split_repo(self.source_repo, "source_repo")
|
|
263
|
+
return f"{owner}/{self.capability}{_IMPLEMENTATION_SUFFIX}"
|
|
264
|
+
|
|
265
|
+
@property
|
|
266
|
+
def git_author_name(self) -> str:
|
|
267
|
+
return self.actor_name
|
|
268
|
+
|
|
269
|
+
@property
|
|
270
|
+
def git_author_email(self) -> str:
|
|
271
|
+
return f"{self.actor_slug}@users.noreply.github.com"
|
|
272
|
+
|
|
273
|
+
def clone_prefix(self, task_id: str) -> str:
|
|
274
|
+
"""`tempfile.mkdtemp` prefix for one task's private clone of the testing repo."""
|
|
275
|
+
return f"{self.actor_slug}-{task_id}-"
|
|
276
|
+
|
|
277
|
+
def code_clone_prefix(self, task_id: str) -> str:
|
|
278
|
+
"""`tempfile.mkdtemp` prefix for one task's read-only clone of the implementation repo."""
|
|
279
|
+
return f"{self.actor_slug}-code-{task_id}-"
|
|
280
|
+
|
|
281
|
+
@property
|
|
282
|
+
def capability_path(self) -> str:
|
|
283
|
+
"""The registry path form: the id lowercased and split AT its `cap` segment.
|
|
284
|
+
|
|
285
|
+
`<ENT>.<DOMAIN>.CAP.<TYPE>.<NNN>.<CODE>` becomes `<ent>.<domain>/<type>.<nnn>.<code>`.
|
|
286
|
+
`CAP` itself is dropped — the path position already says "capability". Nothing else is
|
|
287
|
+
shortened or abbreviated: every remaining token survives, across segments rather than
|
|
288
|
+
concatenated.
|
|
289
|
+
"""
|
|
290
|
+
segments = self.capability.lower().split(".")
|
|
291
|
+
if _CAPABILITY_SEGMENT not in segments:
|
|
292
|
+
raise ConfigError(
|
|
293
|
+
f"capability '{self.capability}' has no '{_CAPABILITY_SEGMENT.upper()}' segment — "
|
|
294
|
+
"the registry path is derived by splitting the id there, so an id without one "
|
|
295
|
+
"cannot be placed"
|
|
296
|
+
)
|
|
297
|
+
cut = segments.index(_CAPABILITY_SEGMENT)
|
|
298
|
+
head, tail = segments[:cut], segments[cut + 1:]
|
|
299
|
+
if not head or not tail:
|
|
300
|
+
raise ConfigError(
|
|
301
|
+
f"capability '{self.capability}': nothing on "
|
|
302
|
+
f"{'the left of' if not head else 'the right of'} its "
|
|
303
|
+
f"'{_CAPABILITY_SEGMENT.upper()}' segment"
|
|
304
|
+
)
|
|
305
|
+
return f"{'.'.join(head)}/{'.'.join(tail)}"
|
|
306
|
+
|
|
307
|
+
def image_name(self, component: str) -> str:
|
|
308
|
+
"""The name `papeete_version.compute` versions the COMPONENT UNDER TEST under.
|
|
309
|
+
|
|
310
|
+
The implementation actor's own derivation, recomputed rather than asked for. A divergence
|
|
311
|
+
does not raise — it silently names an image that was never built.
|
|
312
|
+
"""
|
|
313
|
+
return f"{self.capability.lower()}-{component}"
|
|
314
|
+
|
|
315
|
+
def image_ref(self, registry: str, component: str, version: str) -> str:
|
|
316
|
+
"""The ref the implementation actor published. A three-way contract — see the docstring."""
|
|
317
|
+
return f"{registry.rstrip('/')}/{self.capability_path}/{component}:{version}"
|
|
318
|
+
|
|
319
|
+
def test_image_name(self, component: str) -> str:
|
|
320
|
+
"""The name `papeete_version.compute` versions this component's TEST image under."""
|
|
321
|
+
return f"{self.capability.lower()}-{component}-tests"
|
|
322
|
+
|
|
323
|
+
def test_image_ref(self, registry: str, component: str, version: str) -> str:
|
|
324
|
+
"""The test image this actor publishes. A three-way contract — see the docstring."""
|
|
325
|
+
return f"{registry.rstrip('/')}/{self.capability_path}/{component}/tests:{version}"
|
|
326
|
+
|
|
327
|
+
# ── components ──────────────────────────────────────────────────────────────────────────
|
|
328
|
+
|
|
329
|
+
@property
|
|
330
|
+
def writes_only_under(self) -> tuple[str, ...]:
|
|
331
|
+
"""The write boundary: the union of the components' own tests roots, and nothing else."""
|
|
332
|
+
return tuple(c.tests for c in self.components)
|
|
333
|
+
|
|
334
|
+
def component(self, name: str) -> Component | None:
|
|
335
|
+
"""The declared component answering to `name`, or None."""
|
|
336
|
+
return next((c for c in self.components if c.name == name), None)
|
|
337
|
+
|
|
338
|
+
def component_for(self, path: str) -> Component | None:
|
|
339
|
+
"""The component a repo-relative path belongs to, by LONGEST matching tests root.
|
|
340
|
+
|
|
341
|
+
Not the path's first segment. That shortcut is correct only while every tests root sits
|
|
342
|
+
one segment under a folder named after its component, and silently reports the wrong
|
|
343
|
+
component the day one of them is `src/gateway/tests/`.
|
|
344
|
+
"""
|
|
345
|
+
matches = [c for c in self.components if path.startswith(c.tests)]
|
|
346
|
+
return max(matches, key=lambda c: len(c.tests)) if matches else None
|
|
347
|
+
|
|
348
|
+
def components_for(self, paths: list[str]) -> list[str]:
|
|
349
|
+
"""The names of the components a set of staged paths touched, sorted."""
|
|
350
|
+
names = set()
|
|
351
|
+
for path in paths:
|
|
352
|
+
component = self.component_for(path)
|
|
353
|
+
if component is not None:
|
|
354
|
+
names.add(component.name)
|
|
355
|
+
return sorted(names)
|
|
356
|
+
|
|
357
|
+
# ── grounding ───────────────────────────────────────────────────────────────────────────
|
|
358
|
+
|
|
359
|
+
def expand(self, argv: tuple[str, ...] | list[str]) -> list[str]:
|
|
360
|
+
"""Substitute this capability's own fields into a `ground_in` entry's `fetch:` argv.
|
|
361
|
+
|
|
362
|
+
LITERAL REPLACEMENT, NOT `str.format`. A fetch argv is somebody else's command line, and
|
|
363
|
+
braces are ordinary characters in one — a jq filter or a JSON literal passed as an
|
|
364
|
+
argument would raise or, worse, be silently mangled by `format`. Only the names below are
|
|
365
|
+
substituted; every other brace passes through untouched.
|
|
366
|
+
|
|
367
|
+
A leftover `{bare_word}` IS still refused, because that is what a typo'd placeholder looks
|
|
368
|
+
like and passing it through would hand a knowledge tool a literal `{registryrepo}` to fail
|
|
369
|
+
on somewhere far from here. The pattern is deliberately narrow (lowercase and underscores
|
|
370
|
+
only) so a real brace-bearing argument does not trip it.
|
|
371
|
+
"""
|
|
372
|
+
values = {
|
|
373
|
+
"capability": self.capability,
|
|
374
|
+
"implementation_repo": self.implementation_repo,
|
|
375
|
+
"registry_repo": self.registry_repo,
|
|
376
|
+
"source_repo": self.source_repo,
|
|
377
|
+
}
|
|
378
|
+
out = []
|
|
379
|
+
for arg in argv:
|
|
380
|
+
rendered = arg
|
|
381
|
+
for key, value in values.items():
|
|
382
|
+
rendered = rendered.replace("{" + key + "}", value)
|
|
383
|
+
leftover = _PLACEHOLDER.search(rendered)
|
|
384
|
+
if leftover:
|
|
385
|
+
raise ConfigError(
|
|
386
|
+
f"fetch argument {arg!r} names a placeholder this config cannot supply "
|
|
387
|
+
f"({leftover.group(0)}); available: "
|
|
388
|
+
+ ", ".join("{" + k + "}" for k in sorted(values))
|
|
389
|
+
)
|
|
390
|
+
out.append(rendered)
|
|
391
|
+
return out
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _split_repo(value: str, field: str) -> tuple[str, str]:
|
|
395
|
+
owner, _, repo = value.partition("/")
|
|
396
|
+
if not owner or not repo or "/" in repo:
|
|
397
|
+
raise ConfigError(f"{field} '{value}' is not '<owner>/<repo>'")
|
|
398
|
+
return owner, repo
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _relative_inside(value: str) -> bool:
|
|
402
|
+
return not value.startswith("/") and ".." not in Path(value).parts
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _component(entry: object, source: str, index: int) -> Component:
|
|
406
|
+
where = f"{source}: components[{index}]"
|
|
407
|
+
if not isinstance(entry, dict):
|
|
408
|
+
raise ConfigError(f"{where}: not a mapping")
|
|
409
|
+
# The one copy of this list. `load_schema()` states the same fact for a reader, and this reads
|
|
410
|
+
# it from there rather than restating it — FIA carried a hardcoded tuple beside its schema's
|
|
411
|
+
# `items.required`, which is the shape a boundary stated twice takes (ADR-FIA-0002).
|
|
412
|
+
for key in load_schema()["fields"]["components"]["items"]["required"]:
|
|
413
|
+
if not entry.get(key):
|
|
414
|
+
raise ConfigError(f"{where}: missing required key '{key}'")
|
|
415
|
+
tests = str(entry["tests"])
|
|
416
|
+
if not tests.endswith("/"):
|
|
417
|
+
# Containment is a `startswith` test. Without the trailing slash, a root at
|
|
418
|
+
# `backend/tests` would also claim `backend/tests_scratch.py`.
|
|
419
|
+
raise ConfigError(
|
|
420
|
+
f"{where}: tests '{tests}' must end in '/' — it is matched as a string prefix, and "
|
|
421
|
+
f"without the slash it would also match a sibling whose name merely starts with it"
|
|
422
|
+
)
|
|
423
|
+
if not _relative_inside(tests):
|
|
424
|
+
raise ConfigError(f"{where}: tests '{tests}' must be a relative path inside the repo")
|
|
425
|
+
runner = entry.get("runner")
|
|
426
|
+
if runner is not None:
|
|
427
|
+
runner = str(runner)
|
|
428
|
+
if not runner or not _relative_inside(runner):
|
|
429
|
+
# A declared runner is a directory in the TESTING repo's clone. The default runner is
|
|
430
|
+
# not declared at all — an absolute path here would be a path on whichever machine
|
|
431
|
+
# happened to run the actor, which no sidecar can know.
|
|
432
|
+
raise ConfigError(
|
|
433
|
+
f"{where}: runner '{runner}' must be a relative directory inside the testing "
|
|
434
|
+
f"repo — omit it to use the runner this package ships"
|
|
435
|
+
)
|
|
436
|
+
return Component(name=str(entry["name"]), tests=tests, runner=runner)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _grounding(entry: object, source: str, index: int) -> Grounding:
|
|
440
|
+
where = f"{source}: ground_in[{index}]"
|
|
441
|
+
if not isinstance(entry, dict):
|
|
442
|
+
raise ConfigError(f"{where}: not a mapping")
|
|
443
|
+
for key in ("name", "answers", "fetch", "into", "load"):
|
|
444
|
+
if not entry.get(key):
|
|
445
|
+
raise ConfigError(f"{where}: missing required key '{key}'")
|
|
446
|
+
fetch = entry["fetch"]
|
|
447
|
+
if not isinstance(fetch, list) or not all(isinstance(a, str) for a in fetch):
|
|
448
|
+
raise ConfigError(f"{where}: `fetch` must be a list of strings (argv), not a shell string")
|
|
449
|
+
load = str(entry["load"])
|
|
450
|
+
if load not in ("eager", "on-demand"):
|
|
451
|
+
raise ConfigError(f"{where}: load '{load}' is not one of eager, on-demand")
|
|
452
|
+
into = str(entry["into"])
|
|
453
|
+
if not _relative_inside(into):
|
|
454
|
+
# `into` is written inside a clone this actor then commits from. A path that escapes it
|
|
455
|
+
# would write outside the boundary the whole containment check exists to hold.
|
|
456
|
+
raise ConfigError(f"{where}: into '{into}' must be a relative path inside the clone")
|
|
457
|
+
return Grounding(name=str(entry["name"]), answers=str(entry["answers"]),
|
|
458
|
+
fetch=tuple(fetch), into=into, load=load)
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
# ── the gate ────────────────────────────────────────────────────────────────────────────────
|
|
462
|
+
|
|
463
|
+
@dataclass
|
|
464
|
+
class Report:
|
|
465
|
+
"""What `lint` found. Errors fail; warnings are read and not acted on."""
|
|
466
|
+
|
|
467
|
+
oks: list[str]
|
|
468
|
+
warns: list[str]
|
|
469
|
+
errors: list[str]
|
|
470
|
+
|
|
471
|
+
@property
|
|
472
|
+
def ok(self) -> bool:
|
|
473
|
+
return not self.errors
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def lint(folder: str | Path = ".") -> Report:
|
|
477
|
+
"""Validate one sidecar against `foundry-testing-actor/agentic-context/v1`.
|
|
478
|
+
|
|
479
|
+
A sidecar declaring some other `context:` is read, warned, and not checked further — the same
|
|
480
|
+
discipline papeete-actor applies to a card: UNMIGRATED is not non-conformant, and migrating is
|
|
481
|
+
the owning pair's own act.
|
|
482
|
+
"""
|
|
483
|
+
path = Path(folder)
|
|
484
|
+
if path.is_dir():
|
|
485
|
+
path = path / SIDECAR
|
|
486
|
+
report = Report(oks=[], warns=[], errors=[])
|
|
487
|
+
|
|
488
|
+
if not path.exists():
|
|
489
|
+
report.errors.append(f"{path}: no such file")
|
|
490
|
+
return report
|
|
491
|
+
try:
|
|
492
|
+
raw = yaml.safe_load(path.read_text())
|
|
493
|
+
except (OSError, yaml.YAMLError) as e:
|
|
494
|
+
report.errors.append(f"{path}: does not parse or cannot be read: {e}")
|
|
495
|
+
return report
|
|
496
|
+
if not isinstance(raw, dict):
|
|
497
|
+
report.errors.append(f"{path}: not a mapping")
|
|
498
|
+
return report
|
|
499
|
+
if raw.get("context") != CONTRACT:
|
|
500
|
+
report.warns.append(
|
|
501
|
+
f"{path}: declares '{raw.get('context')}' — UNMIGRATED, not checked against {CONTRACT}"
|
|
502
|
+
)
|
|
503
|
+
return report
|
|
504
|
+
|
|
505
|
+
try:
|
|
506
|
+
config = CapabilityConfig.from_dict(raw, source=str(path))
|
|
507
|
+
except ConfigError as e:
|
|
508
|
+
report.errors.append(str(e))
|
|
509
|
+
return report
|
|
510
|
+
|
|
511
|
+
report.oks.append(f"{path} conforms to {CONTRACT}")
|
|
512
|
+
report.oks.append(f"capability {config.capability}")
|
|
513
|
+
report.oks.append(f"actor {config.actor_name}")
|
|
514
|
+
report.oks.append(f"tests against {config.implementation_repo}")
|
|
515
|
+
report.oks.append(f"registry path {config.capability_path}")
|
|
516
|
+
report.oks.append(f"writes only under {', '.join(config.writes_only_under)}")
|
|
517
|
+
eager = [g.name for g in config.ground_in if g.eager]
|
|
518
|
+
if not eager and config.ground_in:
|
|
519
|
+
# Not an error: an actor may legitimately want everything on demand. But it is the shape
|
|
520
|
+
# that silently un-grounds a session, so it is said out loud.
|
|
521
|
+
report.warns.append(
|
|
522
|
+
f"{path}: no `load: eager` source — the session starts with only the on-demand list, "
|
|
523
|
+
f"and whether it reads any of them is its own choice"
|
|
524
|
+
)
|
|
525
|
+
return report
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Does this use still answer the same doors as the actor it claims to be?
|
|
2
|
+
|
|
3
|
+
WHY THIS EXISTS. This package ships the actor's DEFINITION — four cards under `cards/` saying what
|
|
4
|
+
a foundry testing actor is. A USE is one capability's own folder, carrying those four beside
|
|
5
|
+
its sidecar, named for the capability it serves.
|
|
6
|
+
|
|
7
|
+
A USE DOES NOT WRITE THEM (ADR-FTA-0001, after ADR-FIA-0005). `instance.render_cards` produces
|
|
8
|
+
them from the definition at `docker build` time, and cards that are generated from the thing they
|
|
9
|
+
are compared against cannot disagree with it. This check remains for the copies that predate that — a use still
|
|
10
|
+
carrying a hand copy, or one pinned to an older image — because those are exactly the ones that can
|
|
11
|
+
be wrong, and because a gate that has become cheap to pass is not a reason to remove it.
|
|
12
|
+
|
|
13
|
+
A hand copy drifts. Both folders pass `lint-card` independently — each is a conformant actor — and
|
|
14
|
+
neither gate has any opinion about the other. So the day the definition gains a field, renames a
|
|
15
|
+
message, or changes a door's completion set, a use that was not updated keeps linting green and
|
|
16
|
+
starts refusing callers at runtime, with the rejection surfacing at the door rather than here.
|
|
17
|
+
|
|
18
|
+
WHAT IS COMPARED, AND WHAT IS NOT. Only what a caller can observe: the set of doors, and for each
|
|
19
|
+
one its derived `request_schema`, its `completion_schema`, and the `engine` key it resolves
|
|
20
|
+
through. Those derivations already fold in the data dictionary and the message catalog — a renamed
|
|
21
|
+
data item, a changed type, a reference added to a message, all of it lands in the schema a caller is
|
|
22
|
+
validated against — so comparing them separately would be comparing the same fact twice.
|
|
23
|
+
|
|
24
|
+
PROSE IS NOT COMPARED, ON PURPOSE. A use's `means:` should name its real capability and its real
|
|
25
|
+
peer actors; the definition's cannot, because it serves no capability and its cards are grepped for
|
|
26
|
+
exactly such literals. The use's wording is the better one for anyone reading `describe`, and
|
|
27
|
+
flattening it to the definition's would delete information. Nor is `actor.yaml`'s `name:`: that is
|
|
28
|
+
the use's own identity, and it is REQUIRED to differ.
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
from papeete_actor_synchronous_messaging import card as pas_card
|
|
35
|
+
|
|
36
|
+
from .config import Report, cards_path
|
|
37
|
+
|
|
38
|
+
CARD_FILES = ("actor.yaml", "actor-data.yaml", "actor-message.yaml",
|
|
39
|
+
"actor-synchronous-messaging.yaml")
|
|
40
|
+
|
|
41
|
+
# Slicing the plural gets "action" from "actions" and "querie" from "queries". These messages are
|
|
42
|
+
# what a use is told to act on, so the word they name the drift with has to be a word. The bug was
|
|
43
|
+
# unreachable in the sibling package until its definition declared its first query (ADR-FIA-0004),
|
|
44
|
+
# which is how it survived that long; this definition has had a query from its first version.
|
|
45
|
+
_NOUN = {"actions": "action", "queries": "query"}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def check(folder: str | Path = ".") -> Report:
|
|
49
|
+
"""Compare one use's cards against the definition this package ships."""
|
|
50
|
+
use = Path(folder)
|
|
51
|
+
report = Report(oks=[], warns=[], errors=[])
|
|
52
|
+
|
|
53
|
+
present = [name for name in CARD_FILES if (use / name).exists()]
|
|
54
|
+
if not present:
|
|
55
|
+
# THE NORMAL CASE, not a shortfall. A use carries a sidecar; its cards
|
|
56
|
+
# are rendered from the definition at `docker build` time, and a folder checked before that
|
|
57
|
+
# step — in a source checkout, or in this package's own gates — has none to compare. There
|
|
58
|
+
# is nothing to report against a set of cards that will be generated from the very
|
|
59
|
+
# definition this check compares against.
|
|
60
|
+
report.oks.append(
|
|
61
|
+
f"{use}: no cards to compare — they are rendered from the definition "
|
|
62
|
+
f"(`foundry-testing-actor render-cards`), so they cannot drift from it"
|
|
63
|
+
)
|
|
64
|
+
return report
|
|
65
|
+
if len(present) != len(CARD_FILES):
|
|
66
|
+
missing = [name for name in CARD_FILES if name not in present]
|
|
67
|
+
report.errors.append(
|
|
68
|
+
f"{use}: an incomplete card set — missing {', '.join(missing)}. `Actor.from_card` "
|
|
69
|
+
f"opens exactly these four and never globs, so a use missing one does not boot."
|
|
70
|
+
)
|
|
71
|
+
return report
|
|
72
|
+
|
|
73
|
+
definition = cards_path()
|
|
74
|
+
if any(not (definition / name).exists() for name in CARD_FILES):
|
|
75
|
+
# A wheel that lost its cards. `lint-card` in CI and in the release workflow exists to stop
|
|
76
|
+
# that reaching PyPI; this turns the leftover case into a report rather than a traceback
|
|
77
|
+
# from inside a gate a consumer is running.
|
|
78
|
+
report.errors.append(
|
|
79
|
+
f"{definition}: this package's own cards are missing, so there is nothing to compare "
|
|
80
|
+
f"against. The build shipped without them — report it against the release."
|
|
81
|
+
)
|
|
82
|
+
return report
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
theirs = pas_card.load(use)
|
|
86
|
+
except ValueError as e:
|
|
87
|
+
report.errors.append(f"{use}: its own cards do not pass the restriction, so they cannot "
|
|
88
|
+
f"be compared: {e}")
|
|
89
|
+
return report
|
|
90
|
+
ours = pas_card.load(definition)
|
|
91
|
+
|
|
92
|
+
report.errors.extend(_compare(ours, theirs, "actions", use))
|
|
93
|
+
report.errors.extend(_compare(ours, theirs, "queries", use))
|
|
94
|
+
if not report.errors:
|
|
95
|
+
doors = ", ".join(sorted(theirs.actions) + sorted(theirs.queries))
|
|
96
|
+
report.oks.append(f"{use} answers the definition's doors, unchanged: {doors}")
|
|
97
|
+
return report
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _compare(ours, theirs, kind: str, use: Path) -> list[str]:
|
|
101
|
+
"""The differences in one door family, as messages. Empty means they agree."""
|
|
102
|
+
defined, used = getattr(ours, kind), getattr(theirs, kind)
|
|
103
|
+
noun = _NOUN[kind]
|
|
104
|
+
errors = []
|
|
105
|
+
|
|
106
|
+
for extra in sorted(set(used) - set(defined)):
|
|
107
|
+
errors.append(f"{use}: {noun} '{extra}' is not a door the definition declares — a use "
|
|
108
|
+
f"answers the actor's doors, it does not add its own")
|
|
109
|
+
for absent in sorted(set(defined) - set(used)):
|
|
110
|
+
errors.append(f"{use}: {noun} '{absent}' is missing — the definition declares it, so a "
|
|
111
|
+
f"caller addressing this actor may send it")
|
|
112
|
+
|
|
113
|
+
for door in sorted(set(defined) & set(used)):
|
|
114
|
+
mine, yours = defined[door], used[door]
|
|
115
|
+
if mine.request_schema != yours.request_schema:
|
|
116
|
+
errors.append(
|
|
117
|
+
f"{use}: {noun} '{door}' accepts a different payload than the definition. "
|
|
118
|
+
f"Its `door_schema` message and the data items that message references are what "
|
|
119
|
+
f"derive this, so one of the two drifted:\n"
|
|
120
|
+
f" definition {mine.request_schema}\n"
|
|
121
|
+
f" this use {yours.request_schema}")
|
|
122
|
+
if mine.completion_schema != yours.completion_schema:
|
|
123
|
+
errors.append(
|
|
124
|
+
f"{use}: {noun} '{door}' replies against a different completion set than the "
|
|
125
|
+
f"definition:\n"
|
|
126
|
+
f" definition {mine.completion_schema}\n"
|
|
127
|
+
f" this use {yours.completion_schema}")
|
|
128
|
+
if mine.engine != yours.engine:
|
|
129
|
+
errors.append(
|
|
130
|
+
f"{use}: {noun} '{door}' resolves through engine '{yours.engine}', the "
|
|
131
|
+
f"definition through '{mine.engine}' — the entrypoint registers the engine under "
|
|
132
|
+
f"the sidecar's `engine:` key, so these three must agree")
|
|
133
|
+
return errors
|