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,56 @@
1
+ """foundry-testing-actor — run a headless Claude Code black-box test-authoring session against one
2
+ capability's own testing repo.
3
+
4
+ An actor, for one use, with a `papeete-actor` underneath. The capability it serves is supplied by
5
+ a sidecar (`actor-agentic-context.yaml`, `foundry-testing-actor/agentic-context/v1`), never by this
6
+ package: a second capability instantiates the same actor by writing that file and nothing else.
7
+
8
+ Two doors. `propose-acceptance` is this actor's half of the three amigos round — a query, answered
9
+ read-only, before anything is built (ADR-FTA-0002). `test-task` authors and extends the black-box
10
+ suite, commits it, and publishes one runnable test image per component it touched.
11
+
12
+ Wiring one up is four lines:
13
+
14
+ from foundry_testing_actor import CapabilityConfig, ClaudeCodeTesterEngine, make_test_task
15
+
16
+ config = CapabilityConfig.load(".")
17
+ actor = Actor.from_card(".", mailbox=mailbox,
18
+ engines={config.engine: ClaudeCodeTesterEngine(config)},
19
+ actions={"test-task": make_test_task(config)})
20
+
21
+ `correlation` is exported too — an entrypoint installs its filter on the root logger's handlers
22
+ after configuring observability, so every record the process emits carries this request's ids.
23
+
24
+ Those four lines are for embedding. A use that just wants the actor writes no Python at all: the
25
+ image this package publishes renders the cards from the sidecar and runs `serve`, which is those
26
+ four lines plus the observability wiring that used to be copied into every repo (ADR-FTA-0001).
27
+ """
28
+ from .config import (CapabilityConfig, Component, ConfigError, Grounding, Report,
29
+ cards_path, lint, runner_path, version)
30
+ from .engine import ClaudeCodeTesterEngine
31
+ from .handler import HandlerError, make_test_task
32
+ from .instance import render_cards
33
+ from .serve import ServeError, serve
34
+ from . import conformance, correlation, grounding, instance
35
+
36
+ __all__ = [
37
+ "CapabilityConfig",
38
+ "ClaudeCodeTesterEngine",
39
+ "Component",
40
+ "ConfigError",
41
+ "Grounding",
42
+ "HandlerError",
43
+ "Report",
44
+ "cards_path",
45
+ "conformance",
46
+ "correlation",
47
+ "grounding",
48
+ "instance",
49
+ "lint",
50
+ "make_test_task",
51
+ "render_cards",
52
+ "runner_path",
53
+ "serve",
54
+ "ServeError",
55
+ "version",
56
+ ]
@@ -0,0 +1,68 @@
1
+ # WHAT DATA EXISTS. `papeete-actor-data/v0`, owned by `papeete-actor-message` — a named, minimally
2
+ # typed dictionary. Every field here belongs to the ACTOR, not to any capability: a task id, the
3
+ # caller's statement of the task, what was proposed, what was agreed, and what came back. Nothing
4
+ # names a capability or a knowledge tool, because none of those are known until a sidecar supplies
5
+ # them. `components` is a list of NAMES; which tests root each one maps to is the sidecar's.
6
+ data: papeete-actor-data/v0
7
+ items:
8
+ - name: task_id
9
+ type: string
10
+ description: the TASK-NNN card id this actor was asked about (e.g. "TASK-009")
11
+ - name: title
12
+ type: string
13
+ description: the task's short title/summary, supplied by the caller — no card lookup is performed here
14
+ - name: context
15
+ type: string
16
+ description: free-text elaboration beyond the title — background, constraints, links; optional
17
+ - name: definition_of_done
18
+ type: list
19
+ description: the acceptance criteria the tests must assert against, as the caller states them
20
+ - name: components
21
+ type: list
22
+ description: >-
23
+ the component name(s) the task concerns (e.g. ["backend"]), as the caller names them — each
24
+ one a component this use's sidecar declares a tests root for
25
+ - name: remediation_context
26
+ type: string
27
+ description: >-
28
+ on a retry, the prior attempt's failing test criteria — the session judges whether the fault
29
+ is in the test itself (fix it here) or in the implementation (leave the test as-is); optional,
30
+ absent on a first attempt
31
+ - name: acceptance_surface
32
+ type: list
33
+ description: >-
34
+ the agreed observable expectations this increment must satisfy — proposed at this actor's
35
+ own propose-acceptance door, then accepted, with commitments, by the implementation actor,
36
+ before anything was built. Each entry is an object with at least `id`, `statement` and
37
+ `handle`; extra keys (an attached `commitments` list, for one) are allowed. The dictionary
38
+ types it as a list and stops there: the entry shape is written in the doors' own means
39
+ - name: expectations
40
+ type: list
41
+ description: >-
42
+ what this actor proposes to assert black-box once the increment is built, each an object
43
+ with a stable `id` (short, `E1` or kebab-case, unique within the proposal — a later verdict
44
+ names it), a `statement` of what must hold, a `handle` saying exactly how a black-box test
45
+ reaches it, and optionally the `component` it belongs to. Proposed BEFORE anything is built,
46
+ so a concrete value in a handle is a proposal the implementer commits to or objects to, never
47
+ a report of what was found
48
+ - name: open_questions
49
+ type: list
50
+ description: >-
51
+ what the task and the capability's standing context do not determine, and no proposed value
52
+ can settle — each a string, or an object with `about` and `question`. Not a failure: a
53
+ non-empty list stops the round and is meant to reach a human; empty when nothing is open
54
+ - name: accepted
55
+ type: boolean
56
+ description: whether the test-task request was accepted, pushed, and its test images published
57
+ - name: because
58
+ type: string
59
+ description: why a request was refused (containment violation, nothing staged, an undeclared component, ...)
60
+ - name: branch
61
+ type: string
62
+ description: the branch the tests were pushed to (test/TASK-NNN)
63
+ - name: images
64
+ type: list
65
+ description: >-
66
+ the full ref of each touched component's published test image,
67
+ `<registry>/<capability path>/<component>/tests:<version>`, by convention — the pass/fail
68
+ verdict is rendered by whichever actor runs it, never reported here
@@ -0,0 +1,32 @@
1
+ # WHAT MESSAGES EXIST. `papeete-actor-message/v1`, owned by `papeete-actor-message` — named
2
+ # messages, each a pure regrouping of references into the data dictionary, under an intent.
3
+ # Wire-agnostic by that package's own design: WHAT a message is, never HOW it is carried.
4
+ message: papeete-actor-message/v1
5
+ messages:
6
+ - name: propose-acceptance-cmd
7
+ intent: >-
8
+ ask this actor what it will assert, black-box, about a TASK-NNN card once it is built — and
9
+ what the task leaves undetermined — BEFORE any of it is built
10
+ references: [task_id, title, definition_of_done, components, context]
11
+ optional: [context]
12
+
13
+ - name: acceptance-proposed-result
14
+ intent: >-
15
+ answer with the proposed expectations, each with a stable id, a statement and a handle, and
16
+ the questions the task leaves open
17
+ references: [expectations, open_questions]
18
+ optional: [open_questions]
19
+
20
+ - name: test-task-cmd
21
+ intent: ask this actor to author black-box tests for the published image(s) of a TASK-NNN card
22
+ references: [task_id, title, definition_of_done, components, context, remediation_context,
23
+ acceptance_surface]
24
+ optional: [context, remediation_context, acceptance_surface]
25
+
26
+ - name: test-task-result
27
+ intent: report that the tests were written, pushed, and their touched components' test images published
28
+ references: [accepted, branch, images]
29
+
30
+ - name: test-task-refused-result
31
+ intent: report that the task was not tested, and why
32
+ references: [accepted, because]
@@ -0,0 +1,59 @@
1
+ # THE DOORS. `synchronous-messaging-doors/v1` — one action that authors, one query that only
2
+ # proposes.
3
+ #
4
+ # `engine: claude-code` is this actor kind's declared engine, on BOTH doors. A use's sidecar
5
+ # repeats it in its own `engine:` field, and the entrypoint registers the engine under exactly that
6
+ # key — so a use whose sidecar names a different engine is caught by its own card rather than at
7
+ # the first request. One engine instance serves both: `Actor.judge()` hands it the door id, and it
8
+ # dispatches on that.
9
+ #
10
+ # WHY `propose-acceptance` IS A QUERY. An action is a promise to try; a query is a promise to
11
+ # answer, from this actor's own state and nothing invented. Proposing what will be asserted changes
12
+ # nothing — no branch, no commit, no image — and placing it under `queries:` makes that structural
13
+ # rather than merely promised. It is also why it registers no handler: with an engine and no
14
+ # handler, `Actor.receive()` returns the judged dict as the reply. See ADR-FTA-0002, and
15
+ # ADR-FIA-0004 for the round both halves belong to.
16
+ manifest: synchronous-messaging-doors/v1
17
+ actions:
18
+ - id: test-task
19
+ means: >-
20
+ the door for "author black-box tests for TASK-NNN of the capability I serve, now that its
21
+ increment is built". Send it here with the task id, title, definition of done, components,
22
+ and (optionally) context, remediation_context and the agreed `acceptance_surface` — the
23
+ caller supplies everything, no task card is looked up here. I clone my capability's testing
24
+ repository into my own private copy, and read-only clone its implementation repository at
25
+ impl/TASK-NNN solely to recompute, by convention, the image refs published there (never
26
+ passed to me, and never shown to my session); ground myself in that capability's own
27
+ standing context; and extend the persistent suite under each named component's declared
28
+ tests root — asserting every agreed expectation by its id, through its handle, where a
29
+ surface was sent. I never bring up the images, hit a live endpoint, or render a verdict.
30
+ Then I commit and push to test/TASK-NNN, and build each touched component's test image in
31
+ the cluster's shared buildkit and push it to the registry, named and versioned by
32
+ convention. I never open a pull request — an orchestrating actor runs that image and does.
33
+ completion: whether I accepted, pushed, and published test images (with the branch and image refs), or refused, and why.
34
+ door_schema: test-task-cmd
35
+ completion_schema: [test-task-result, test-task-refused-result]
36
+ engine: claude-code
37
+
38
+ queries:
39
+ - id: propose-acceptance
40
+ means: >-
41
+ the door for "what will you assert about TASK-NNN once it is built?", asked BEFORE anything
42
+ is built. Send the task id, title, definition of done, the component names the task
43
+ concerns, and optional context. I clone my capability's testing repository and its
44
+ implementation repository's DEFAULT BRANCH read-only — never impl/TASK-NNN, because
45
+ assertions derived from what was built can only confirm the build — ground myself in the
46
+ capability's own standing context, and propose the acceptance surface. I write nothing, build
47
+ nothing, and publish nothing.
48
+ completion: >-
49
+ the expectations I will assert, each an object with a stable, short `id` unique within the
50
+ proposal, a `statement` of what must hold, a `handle` saying exactly how a black-box test
51
+ reaches it (the component, the endpoint, the event routing key, the environment variable its
52
+ base URL arrives in — with a concrete value proposed wherever a test needs one the task does
53
+ not name, for the implementer to commit to or object to), and optionally its `component`;
54
+ and the open questions the task and the standing context leave undetermined, each a string
55
+ or an `about`/`question` object, never an invented answer. Any open question is meant to
56
+ stop the round and reach a human rather than a session.
57
+ door_schema: propose-acceptance-cmd
58
+ completion_schema: acceptance-proposed-result
59
+ engine: claude-code
@@ -0,0 +1,19 @@
1
+ # WHO. The actor this package defines — `papeete-actor-manifest/v0`, owned by `papeete-actor`.
2
+ #
3
+ # THIS FOLDER IS THE DEFINITION, NOT A USE. The four cards here say what a foundry testing actor
4
+ # IS: the data it knows, the messages it exchanges, and the two doors it answers. They name no
5
+ # capability, because which capability an instance serves is not part of what the actor is — that
6
+ # is `actor-agentic-context.yaml`, supplied per use.
7
+ #
8
+ # A use of this actor is a folder carrying that sidecar. Its four cards are rendered from these at
9
+ # `docker build` time (`foundry-testing-actor render-cards`), with `name:` set to the use's own.
10
+ manifest: papeete-actor-manifest/v0
11
+ name: foundry-testing-actor
12
+ description: >-
13
+ Authors black-box tests for one TASK-NNN card of the business capability its sidecar names.
14
+ Before anything is built, proposes what it will assert, read-only, with each expectation's id,
15
+ statement and handle, and names what the task leaves undetermined. Once the increment is built,
16
+ extends that capability's persistent test suite in a private clone of its own testing
17
+ repository, writing only under the tests roots the sidecar declares — then commits, pushes a
18
+ branch, and publishes one runnable test image per component the task touched. Never runs the
19
+ tests, renders a verdict, or opens a pull request: an orchestrating actor does.
@@ -0,0 +1,181 @@
1
+ """`foundry-testing-actor` — the gate, and the derivation table.
2
+
3
+ Four subcommands. `lint` is what CI runs against a sidecar; `show` prints every rendering
4
+ `config.py` derives from the two fields that are actually written down, so an operator can check
5
+ the test image ref an actor WILL publish, and the image ref it will recompute for the component
6
+ under test, before either exists — the hand-written actor this replaces could only be checked by
7
+ reading a running actor's logs after the fact.
8
+
9
+ `render-cards` and `serve` are what make a use's whole repository one sidecar (ADR-FTA-0001): the
10
+ first writes the actor's four cards beside it from the definition in this wheel, the second boots
11
+ the thing. Both run inside the image this package publishes, which is where a use meets them; both
12
+ work in a plain venv too, which is where this repo's own gates run them.
13
+
14
+ House rule: every published package in this ecosystem ships a CLI named exactly the package.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ from . import conformance
23
+ from .config import CapabilityConfig, ConfigError, lint, runner_path, version
24
+ from .instance import render_cards
25
+ from .serve import DEFAULT_PORT, ServeError, serve
26
+
27
+ _REGISTRY_PLACEHOLDER = "<registry>"
28
+
29
+
30
+ def _cmd_lint(args: argparse.Namespace) -> int:
31
+ # Two gates, one command. The sidecar says which capability this use serves; the cards say
32
+ # which actor it claims to be. A use can be wrong about either independently, and the second
33
+ # check is the only thing standing between a hand-copied card set and a caller being refused
34
+ # at a door — see `conformance.py`.
35
+ report = lint(Path(args.folder))
36
+ conformance_report = conformance.check(Path(args.folder))
37
+ report.oks.extend(conformance_report.oks)
38
+ report.warns.extend(conformance_report.warns)
39
+ report.errors.extend(conformance_report.errors)
40
+ for warning in report.warns:
41
+ print(f" ! {warning}")
42
+ for error in report.errors:
43
+ print(f" FAIL {error}")
44
+ if not report.ok:
45
+ print(f"✗ {len(report.errors)} error(s)")
46
+ return 1
47
+ for line in report.oks:
48
+ print(f" ok {line}")
49
+ checked_cards = any((Path(args.folder) / name).exists() for name in conformance.CARD_FILES)
50
+ print("✓ sidecar and cards conform" if checked_cards else "✓ sidecar conforms")
51
+ return 0
52
+
53
+
54
+ def _cmd_show(args: argparse.Namespace) -> int:
55
+ try:
56
+ config = CapabilityConfig.load(Path(args.folder))
57
+ except ConfigError as e:
58
+ print(f" FAIL {e}")
59
+ return 2
60
+ registry = args.registry or _REGISTRY_PLACEHOLDER
61
+
62
+ rows = [
63
+ ("capability", config.capability),
64
+ ("source_repo", config.source_repo),
65
+ ("implementation_repo", config.implementation_repo
66
+ + ("" if config.declared_implementation_repo else " (derived)")),
67
+ ("registry_repo", config.registry_repo),
68
+ ("engine", config.engine),
69
+ ("actor name / git author", config.git_author_name),
70
+ ("git author email", config.git_author_email),
71
+ ("clone prefix", config.clone_prefix("TASK-NNN")),
72
+ ("code clone prefix", config.code_clone_prefix("TASK-NNN")),
73
+ ("registry path", config.capability_path),
74
+ ("writes only under", ", ".join(config.writes_only_under)),
75
+ ]
76
+ width = max(len(label) for label, _ in rows)
77
+ for label, value in rows:
78
+ print(f" {label:<{width}} {value}")
79
+
80
+ print("\n components")
81
+ for component in config.components:
82
+ print(f" {component.name}")
83
+ print(f" tests {component.tests}")
84
+ print(f" runner "
85
+ f"{component.runner or f'(default) {runner_path()}'}")
86
+ print(f" test image name {config.test_image_name(component.name)}")
87
+ print(f" test image ref "
88
+ f"{config.test_image_ref(registry, component.name, '<version>')}")
89
+ print(f" under test "
90
+ f"{config.image_ref(registry, component.name, '<version>')}")
91
+
92
+ print("\n ground_in")
93
+ for entry in config.ground_in:
94
+ print(f" {entry.name} [{entry.load}] → {entry.into}")
95
+ print(f" answers {entry.answers}")
96
+ print(f" fetch {' '.join(config.expand(entry.fetch))}")
97
+ return 0
98
+
99
+
100
+ def _cmd_render_cards(args: argparse.Namespace) -> int:
101
+ # The step that deletes the hand copy. Run at `docker build` time, right after the sidecar is
102
+ # COPY'd in: from then on the cards in that folder came from a version, not from someone's
103
+ # editor, and the next version's cards arrive with the next `FROM`.
104
+ try:
105
+ config = CapabilityConfig.load(Path(args.folder))
106
+ except ConfigError as e:
107
+ print(f" FAIL {e}")
108
+ return 2
109
+ try:
110
+ written = render_cards(config, Path(args.folder))
111
+ except FileNotFoundError as e:
112
+ print(f" FAIL {e}")
113
+ return 2
114
+ for path in written:
115
+ print(f" ok rendered {path}")
116
+ print(f"✓ {config.actor_name} — four cards from the definition in "
117
+ f"foundry-testing-actor=={version()}")
118
+ return 0
119
+
120
+
121
+ def _cmd_serve(args: argparse.Namespace) -> int:
122
+ # No try/except around the boot itself. A misconfigured actor that starts anyway and refuses
123
+ # every caller at the door is strictly worse than a pod that crash-loops with the reason on
124
+ # stdout, which is what an uncaught ConfigError produces here.
125
+ try:
126
+ serve(Path(args.folder), port=args.port)
127
+ except (ConfigError, ServeError) as e:
128
+ print(f" FAIL {e}", file=sys.stderr)
129
+ return 2
130
+ return 0
131
+
132
+
133
+ def build_parser() -> argparse.ArgumentParser:
134
+ parser = argparse.ArgumentParser(
135
+ prog="foundry-testing-actor",
136
+ description="Inspect and validate one capability's agentic-context sidecar.",
137
+ )
138
+ sub = parser.add_subparsers(dest="command", required=True)
139
+
140
+ lint_parser = sub.add_parser(
141
+ "lint", help="validate a sidecar against foundry-testing-actor/agentic-context/v1")
142
+ lint_parser.add_argument(
143
+ "folder", nargs="?", default=".",
144
+ help="the actor's folder, or the sidecar file itself (default: .)")
145
+ lint_parser.set_defaults(func=_cmd_lint)
146
+
147
+ show_parser = sub.add_parser(
148
+ "show", help="print every identifier derived from the sidecar's capability and repo")
149
+ show_parser.add_argument("folder", nargs="?", default=".")
150
+ show_parser.add_argument(
151
+ "--registry", help=f"render image refs against this registry (default: {_REGISTRY_PLACEHOLDER})")
152
+ show_parser.set_defaults(func=_cmd_show)
153
+
154
+ render_parser = sub.add_parser(
155
+ "render-cards",
156
+ help="write the actor's four cards into a use's folder, from the definition in this wheel")
157
+ render_parser.add_argument(
158
+ "folder", nargs="?", default=".",
159
+ help="the folder holding this use's actor-agentic-context.yaml (default: .)")
160
+ render_parser.set_defaults(func=_cmd_render_cards)
161
+
162
+ serve_parser = sub.add_parser(
163
+ "serve", help="boot this actor and answer its doors (needs the `serve` extra)")
164
+ serve_parser.add_argument(
165
+ "folder", nargs="?", default=".",
166
+ help="the folder holding this use's sidecar and cards (default: .)")
167
+ serve_parser.add_argument(
168
+ "--port", type=int, default=None,
169
+ help=f"override $PORT (default: $PORT, else {DEFAULT_PORT})")
170
+ serve_parser.set_defaults(func=_cmd_serve)
171
+
172
+ return parser
173
+
174
+
175
+ def main(argv: list[str] | None = None) -> int:
176
+ args = build_parser().parse_args(argv)
177
+ return args.func(args)
178
+
179
+
180
+ if __name__ == "__main__":
181
+ sys.exit(main())