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.
@@ -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