From 643ad25d9b2b00861c36bc7b65f567b592c3087e Mon Sep 17 00:00:00 2001 From: Romain Gautier Date: Fri, 3 Jul 2026 01:46:50 +0200 Subject: [PATCH] add dagger & symfony skills --- .agents/skills/dagger-chores/SKILL.md | 51 + .../skills/dagger-design-proposals/SKILL.md | 169 +++ .agents/skills/engine-debugging/SKILL.md | 351 +++++ .../scripts/dagql-cache-analyzer.go | 724 +++++++++ .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 191 bytes .../aggregate_benchmark.cpython-314.pyc | Bin 0 -> 19719 bytes .../iteration-1/benchmark.json | 178 +++ .../iteration-1/benchmark.md | 13 + .../eval_metadata.json | 11 + .../with_skill/run-1/grading.json | 57 + .../with_skill/run-1/outputs/metrics.json | 13 + .../with_skill/run-1/outputs/review.md | 64 + .../with_skill/run-1/timing.json | 6 + .../eval_metadata.json | 11 + .../with_skill/run-1/grading.json | 57 + .../with_skill/run-1/outputs/debugging.md | 49 + .../with_skill/run-1/outputs/metrics.json | 13 + .../with_skill/run-1/timing.json | 6 + .../eval_metadata.json | 13 + .../with_skill/run-1/grading.json | 67 + .../with_skill/run-1/outputs/metrics.json | 13 + .../with_skill/run-1/outputs/notes.md | 7 + .../run-1/outputs/rust-postgres.yml | 52 + .../with_skill/run-1/timing.json | 6 + .../spindle-workspace/iteration-1/review.html | 1325 +++++++++++++++++ .agents/skills/spindle/SKILL.md | 192 +++ .agents/skills/spindle/evals/evals.json | 26 + .../references/spindles-quick-reference.md | 133 ++ .agents/skills/symfony-brainstorming/SKILL.md | 38 + .../symfony-config-env-parameters/SKILL.md | 42 + .../reference.md | 373 +++++ .../symfony-controller-cleanup/SKILL.md | 39 + .../symfony-controller-cleanup/reference.md | 420 ++++++ .../skills/symfony-cqrs-and-handlers/SKILL.md | 39 + .../symfony-cqrs-and-handlers/reference.md | 432 ++++++ .../skills/symfony-daily-workflow/SKILL.md | 39 + .../symfony-daily-workflow/reference.md | 331 ++++ .../SKILL.md | 42 + .../reference.md | 306 ++++ .../skills/symfony-doctrine-events/SKILL.md | 44 + .../symfony-doctrine-events/reference.md | 252 ++++ .../symfony-doctrine-fetch-modes/SKILL.md | 42 + .../symfony-doctrine-fetch-modes/reference.md | 308 ++++ .../SKILL.md | 42 + .../reference.md | 424 ++++++ .../symfony-doctrine-migrations/SKILL.md | 41 + .../symfony-doctrine-migrations/reference.md | 150 ++ .../symfony-doctrine-relations/SKILL.md | 41 + .../symfony-doctrine-relations/reference.md | 265 ++++ .../symfony-doctrine-transactions/SKILL.md | 42 + .../reference.md | 370 +++++ .../symfony-e2e-panther-playwright/SKILL.md | 39 + .../reference.md | 342 +++++ .../skills/symfony-effective-context/SKILL.md | 39 + .../symfony-effective-context/reference.md | 237 +++ .../skills/symfony-executing-plans/SKILL.md | 42 + .../symfony-executing-plans/reference.md | 291 ++++ .../symfony-form-types-validation/SKILL.md | 42 + .../reference.md | 455 ++++++ .../skills/symfony-functional-tests/SKILL.md | 41 + .../symfony-functional-tests/reference.md | 260 ++++ .../SKILL.md | 39 + .../reference.md | 433 ++++++ .../symfony-messenger-retry-failures/SKILL.md | 42 + .../reference.md | 357 +++++ .../symfony-ports-and-adapters/SKILL.md | 39 + .../symfony-ports-and-adapters/reference.md | 422 ++++++ .agents/skills/symfony-rate-limiting/SKILL.md | 42 + .../skills/symfony-rate-limiting/reference.md | 401 +++++ .../skills/symfony-runner-selection/SKILL.md | 38 + .../skills/symfony-strategy-pattern/SKILL.md | 39 + .../symfony-strategy-pattern/reference.md | 390 +++++ .agents/skills/symfony-symfony-cache/SKILL.md | 42 + .../skills/symfony-symfony-cache/reference.md | 402 +++++ .../skills/symfony-symfony-messenger/SKILL.md | 41 + .../symfony-symfony-messenger/reference.md | 240 +++ .../skills/symfony-symfony-scheduler/SKILL.md | 42 + .../symfony-symfony-scheduler/reference.md | 352 +++++ .../skills/symfony-symfony-voters/SKILL.md | 41 + .../symfony-symfony-voters/reference.md | 154 ++ .../skills/symfony-tdd-with-phpunit/SKILL.md | 41 + .../symfony-tdd-with-phpunit/reference.md | 222 +++ .../symfony-test-doubles-mocking/SKILL.md | 39 + .../symfony-test-doubles-mocking/reference.md | 323 ++++ .../skills/symfony-twig-components/SKILL.md | 39 + .../symfony-twig-components/reference.md | 443 ++++++ .../SKILL.md | 38 + .../symfony-value-objects-and-dtos/SKILL.md | 39 + .../reference.md | 447 ++++++ .agents/skills/symfony-writing-plans/SKILL.md | 39 + .../skills/symfony-writing-plans/reference.md | 227 +++ .agents/skills/telemetry-capture/SKILL.md | 56 + .agents/skills/tui-qa/SKILL.md | 159 ++ .agents/skills/tui-qa/agents/openai.yaml | 4 + .agents/skills/tui-qa/scripts/tui_qa.py | 954 ++++++++++++ skills-lock.json | 233 +++ 96 files changed, 16376 insertions(+) create mode 100644 .agents/skills/dagger-chores/SKILL.md create mode 100644 .agents/skills/dagger-design-proposals/SKILL.md create mode 100644 .agents/skills/engine-debugging/SKILL.md create mode 100644 .agents/skills/engine-debugging/scripts/dagql-cache-analyzer.go create mode 100644 .agents/skills/skill-creator/scripts/__pycache__/__init__.cpython-314.pyc create mode 100644 .agents/skills/skill-creator/scripts/__pycache__/aggregate_benchmark.cpython-314.pyc create mode 100644 .agents/skills/spindle-workspace/iteration-1/benchmark.json create mode 100644 .agents/skills/spindle-workspace/iteration-1/benchmark.md create mode 100644 .agents/skills/spindle-workspace/iteration-1/eval-0-review-existing-workflows/eval_metadata.json create mode 100644 .agents/skills/spindle-workspace/iteration-1/eval-0-review-existing-workflows/with_skill/run-1/grading.json create mode 100644 .agents/skills/spindle-workspace/iteration-1/eval-0-review-existing-workflows/with_skill/run-1/outputs/metrics.json create mode 100644 .agents/skills/spindle-workspace/iteration-1/eval-0-review-existing-workflows/with_skill/run-1/outputs/review.md create mode 100644 .agents/skills/spindle-workspace/iteration-1/eval-0-review-existing-workflows/with_skill/run-1/timing.json create mode 100644 .agents/skills/spindle-workspace/iteration-1/eval-1-debug-push-log-command/eval_metadata.json create mode 100644 .agents/skills/spindle-workspace/iteration-1/eval-1-debug-push-log-command/with_skill/run-1/grading.json create mode 100644 .agents/skills/spindle-workspace/iteration-1/eval-1-debug-push-log-command/with_skill/run-1/outputs/debugging.md create mode 100644 .agents/skills/spindle-workspace/iteration-1/eval-1-debug-push-log-command/with_skill/run-1/outputs/metrics.json create mode 100644 .agents/skills/spindle-workspace/iteration-1/eval-1-debug-push-log-command/with_skill/run-1/timing.json create mode 100644 .agents/skills/spindle-workspace/iteration-1/eval-2-create-rust-postgres-workflow/eval_metadata.json create mode 100644 .agents/skills/spindle-workspace/iteration-1/eval-2-create-rust-postgres-workflow/with_skill/run-1/grading.json create mode 100644 .agents/skills/spindle-workspace/iteration-1/eval-2-create-rust-postgres-workflow/with_skill/run-1/outputs/metrics.json create mode 100644 .agents/skills/spindle-workspace/iteration-1/eval-2-create-rust-postgres-workflow/with_skill/run-1/outputs/notes.md create mode 100644 .agents/skills/spindle-workspace/iteration-1/eval-2-create-rust-postgres-workflow/with_skill/run-1/outputs/rust-postgres.yml create mode 100644 .agents/skills/spindle-workspace/iteration-1/eval-2-create-rust-postgres-workflow/with_skill/run-1/timing.json create mode 100644 .agents/skills/spindle-workspace/iteration-1/review.html create mode 100644 .agents/skills/spindle/SKILL.md create mode 100644 .agents/skills/spindle/evals/evals.json create mode 100644 .agents/skills/spindle/references/spindles-quick-reference.md create mode 100644 .agents/skills/symfony-brainstorming/SKILL.md create mode 100644 .agents/skills/symfony-config-env-parameters/SKILL.md create mode 100644 .agents/skills/symfony-config-env-parameters/reference.md create mode 100644 .agents/skills/symfony-controller-cleanup/SKILL.md create mode 100644 .agents/skills/symfony-controller-cleanup/reference.md create mode 100644 .agents/skills/symfony-cqrs-and-handlers/SKILL.md create mode 100644 .agents/skills/symfony-cqrs-and-handlers/reference.md create mode 100644 .agents/skills/symfony-daily-workflow/SKILL.md create mode 100644 .agents/skills/symfony-daily-workflow/reference.md create mode 100644 .agents/skills/symfony-doctrine-batch-processing/SKILL.md create mode 100644 .agents/skills/symfony-doctrine-batch-processing/reference.md create mode 100644 .agents/skills/symfony-doctrine-events/SKILL.md create mode 100644 .agents/skills/symfony-doctrine-events/reference.md create mode 100644 .agents/skills/symfony-doctrine-fetch-modes/SKILL.md create mode 100644 .agents/skills/symfony-doctrine-fetch-modes/reference.md create mode 100644 .agents/skills/symfony-doctrine-fixtures-foundry/SKILL.md create mode 100644 .agents/skills/symfony-doctrine-fixtures-foundry/reference.md create mode 100644 .agents/skills/symfony-doctrine-migrations/SKILL.md create mode 100644 .agents/skills/symfony-doctrine-migrations/reference.md create mode 100644 .agents/skills/symfony-doctrine-relations/SKILL.md create mode 100644 .agents/skills/symfony-doctrine-relations/reference.md create mode 100644 .agents/skills/symfony-doctrine-transactions/SKILL.md create mode 100644 .agents/skills/symfony-doctrine-transactions/reference.md create mode 100644 .agents/skills/symfony-e2e-panther-playwright/SKILL.md create mode 100644 .agents/skills/symfony-e2e-panther-playwright/reference.md create mode 100644 .agents/skills/symfony-effective-context/SKILL.md create mode 100644 .agents/skills/symfony-effective-context/reference.md create mode 100644 .agents/skills/symfony-executing-plans/SKILL.md create mode 100644 .agents/skills/symfony-executing-plans/reference.md create mode 100644 .agents/skills/symfony-form-types-validation/SKILL.md create mode 100644 .agents/skills/symfony-form-types-validation/reference.md create mode 100644 .agents/skills/symfony-functional-tests/SKILL.md create mode 100644 .agents/skills/symfony-functional-tests/reference.md create mode 100644 .agents/skills/symfony-interfaces-and-autowiring/SKILL.md create mode 100644 .agents/skills/symfony-interfaces-and-autowiring/reference.md create mode 100644 .agents/skills/symfony-messenger-retry-failures/SKILL.md create mode 100644 .agents/skills/symfony-messenger-retry-failures/reference.md create mode 100644 .agents/skills/symfony-ports-and-adapters/SKILL.md create mode 100644 .agents/skills/symfony-ports-and-adapters/reference.md create mode 100644 .agents/skills/symfony-rate-limiting/SKILL.md create mode 100644 .agents/skills/symfony-rate-limiting/reference.md create mode 100644 .agents/skills/symfony-runner-selection/SKILL.md create mode 100644 .agents/skills/symfony-strategy-pattern/SKILL.md create mode 100644 .agents/skills/symfony-strategy-pattern/reference.md create mode 100644 .agents/skills/symfony-symfony-cache/SKILL.md create mode 100644 .agents/skills/symfony-symfony-cache/reference.md create mode 100644 .agents/skills/symfony-symfony-messenger/SKILL.md create mode 100644 .agents/skills/symfony-symfony-messenger/reference.md create mode 100644 .agents/skills/symfony-symfony-scheduler/SKILL.md create mode 100644 .agents/skills/symfony-symfony-scheduler/reference.md create mode 100644 .agents/skills/symfony-symfony-voters/SKILL.md create mode 100644 .agents/skills/symfony-symfony-voters/reference.md create mode 100644 .agents/skills/symfony-tdd-with-phpunit/SKILL.md create mode 100644 .agents/skills/symfony-tdd-with-phpunit/reference.md create mode 100644 .agents/skills/symfony-test-doubles-mocking/SKILL.md create mode 100644 .agents/skills/symfony-test-doubles-mocking/reference.md create mode 100644 .agents/skills/symfony-twig-components/SKILL.md create mode 100644 .agents/skills/symfony-twig-components/reference.md create mode 100644 .agents/skills/symfony-using-symfony-superpowers/SKILL.md create mode 100644 .agents/skills/symfony-value-objects-and-dtos/SKILL.md create mode 100644 .agents/skills/symfony-value-objects-and-dtos/reference.md create mode 100644 .agents/skills/symfony-writing-plans/SKILL.md create mode 100644 .agents/skills/symfony-writing-plans/reference.md create mode 100644 .agents/skills/telemetry-capture/SKILL.md create mode 100644 .agents/skills/tui-qa/SKILL.md create mode 100644 .agents/skills/tui-qa/agents/openai.yaml create mode 100755 .agents/skills/tui-qa/scripts/tui_qa.py diff --git a/.agents/skills/dagger-chores/SKILL.md b/.agents/skills/dagger-chores/SKILL.md new file mode 100644 index 0000000..03a46cd --- /dev/null +++ b/.agents/skills/dagger-chores/SKILL.md @@ -0,0 +1,51 @@ +--- +name: dagger-chores +description: Handle quick, repeatable Dagger repository maintenance chores. Use when the user asks for small operational changes and wants the same established edits and commit style applied quickly. +--- + +# Dagger Chores + +## Go Version Bump + +Use this checklist when asked to bump Go. + +1. Update the Go version string in `engine/distconsts/consts.go`: + +- `GolangVersion = "X.Y.Z"` + +1. Update the Go default version string in `toolchains/go/main.go`: + +- `// +default="X.Y.Z"` on the `version` argument in `New(...)` + +1. Use a short commit message in this format: + +- `chore: bump to go ` +- Example: `chore: bump to go 1.26` + +1. Create a signed commit: + +- `git commit -s -m "chore: bump to go "` + +1. Tell the user to double-check whether new Go version locations have been introduced since the last bump, and mention they can ask the agent for help finding them. + +- Suggested wording: `Please double-check if any additional Go version strings were added in new files; these locations can change over time. If helpful, I can also help search for those locations.` + +## Regenerate Generated Files + +Use this checklist when asked to regenerate generated files. + +1. From the Dagger repo root, create a temp file for command output and store its path in `tmp_log`. + +1. Run generation and redirect all output to the temp file: + +- `dagger --progress=plain call generate layer export --path . >"$tmp_log" 2>&1` + +1. Search the temp file as needed instead of printing full output. + +1. Delete the temp file when done. + +## Regenerate Golden Tests + +Use this checklist when asked to regenerate telemetry golden tests. + +1. From the Dagger repo root, run `dagger -c 'engine-dev | test-telemetry --update | export .'` diff --git a/.agents/skills/dagger-design-proposals/SKILL.md b/.agents/skills/dagger-design-proposals/SKILL.md new file mode 100644 index 0000000..47c0419 --- /dev/null +++ b/.agents/skills/dagger-design-proposals/SKILL.md @@ -0,0 +1,169 @@ +--- +name: dagger-design-proposals +description: Write design proposals for Dagger features. Use when asked to draft, review, or iterate on Dagger design documents, RFCs, or proposals. +--- + +# Dagger Design Proposals + +Guidelines for writing design proposals for Dagger features. + +## Before Writing + +**Always research first:** + +1. Check relevant internal docs, such as `internal-docs/dagger-codegen.md`, for context +2. Look at related code in the Dagger codebase: + - GraphQL schema: `core/schema/*.go` + - CLI commands: `cmd/dagger/*.go` + - Core types: `core/*.go` +3. For public API or workspace proposals, read `internal-docs/version-gating.md` +4. Understand existing patterns before proposing new ones + +## Structure + +````markdown +# Part N: Title + +*Builds on [Part N-1: Title](link)* + +## Table of Contents +- [Problem](#problem) +- [Solution](#solution) +- [Core Concept](#core-concept) +- [CLI](#cli) +- [Status](#status) + +## Problem + +Numbered, concise limitations: + +1. **Short title** - One sentence explanation. +2. **Short title** - One sentence explanation. + +## Solution + +One paragraph summary. + +## Core Concept + +GraphQL type definitions with inline docstrings: + +```graphql +""" +Type description here. +""" +type Example { + """Method description.""" + method(arg: String!): Result! +} +``` + +Go for implementation examples: + +```go +func New(ws dagger.Workspace) *Example { + // ... +} +``` + +## CLI + +Real command examples: + +```bash +$ dagger command --flag +OUTPUT +``` + +## Status + +One line. + +--- + +- Previous: [Part N-1](link) +- Next: [Part N+1](link) or "Part N+1: Title (coming soon)" + +```` + +## Style + +- **Concise** - Trust the reader. Remove fluff. +- **Tables** - Use for comparisons. +- **GraphQL for APIs** - Type definitions, not Go interfaces. +- **Go for implementation** - Examples showing how modules use the API. +- **Real examples** - go-toolchain, node-toolchain, not abstract Foo/Bar. +- **Less is more** - Remove sections when challenged. + +## What to Avoid + +- Separate "Methods" sections (use GraphQL docstrings) +- "Design Rationale" sections unless specifically valuable +- "Open Questions" that aren't real blockers +- Go for type definitions (use GraphQL) +- Layout examples that might confuse +- Over-explaining + +## Process + +1. **Gists as source of truth** - Publish early, iterate in gist +2. **Link parts together** - Previous/Next at bottom, "Builds on" at top +3. **Each part stands alone** - But builds on previous +4. **Iterate quickly** - User feedback drives changes + +## Iterating with User + +When you have clarifying questions or notes: + +1. **List them first** - Present a high-level numbered list of all questions/notes +2. **One at a time** - Walk through each item individually, waiting for user response +3. **Don't dump** - Never present all questions with full details at once + +## Codebase References + +When writing proposals, reference actual Dagger code: + +| Topic | Location | +|-------|----------| +| GraphQL schema definitions | `core/schema/*.go` | +| CLI commands | `cmd/dagger/*.go` | +| Core types (Directory, File, etc.) | `core/*.go` | +| Engine internals | `engine/*.go` | +| SDK codegen | `internal-docs/dagger-codegen.md`, `cmd/codegen/*.go` | +| API version gates | `internal-docs/version-gating.md`, `core/schema/*.go` | + +Example: To understand how `Host.findUp` works before proposing `Workspace.findUp`: + +```bash +# Find the schema definition +grep -r "findUp" core/schema/host.go + +# Find the implementation +grep -r "FindUp" core/host.go +``` + +## Publishing + +```bash +# Create new gist +gh gist create file.md --desc "Dagger Design: Part N - Title" --public + +# Update existing gist +gh gist edit GIST_ID file.md + +# Post changelog comment (always do this after updates) +gh api --method POST /gists/GIST_ID/comments -f body="## Changelog +- Change 1 +- Change 2" +``` + +**Always post a changelog comment** after updating a gist with significant changes. + +## Related Skills + +Check for other Dagger skills that may help with research: + +- `engine-debugging` - Engine debugging workflows, trace replay, cache snapshots +- `internal-docs/dagger-codegen.md` - SDK codegen, templates, bindings +- `internal-docs/version-gating.md` - schema views and public API version gates +- `internal-docs/` - Cache and engine implementation references diff --git a/.agents/skills/engine-debugging/SKILL.md b/.agents/skills/engine-debugging/SKILL.md new file mode 100644 index 0000000..9b81c98 --- /dev/null +++ b/.agents/skills/engine-debugging/SKILL.md @@ -0,0 +1,351 @@ +--- +name: engine-debugging +description: Run Dagger repo tests and debug Dagger engine, core, dagql, filesync, cache, CI trace, panic, hang, leak, and performance issues. Use whenever an agent needs to run tests, choose a test command, or interpret test output in this repository, even before a failure is diagnosed; also use for engine-dev tests, Dagger Cloud trace replay, debug endpoints or pprof, goroutine dumps, panics, hangs, leaks, performance issues, and /debug/dagql/cache snapshots. +--- + +# Engine Debugging + +This is the default guide for running Dagger engine/core tests and for debugging +the failures those tests expose. + +Start from evidence, not broad guesses. + +## Core Loop + +1. Write down the expected flow through the subsystem being debugged. +2. Log actual values at each boundary. +3. Find the first divergence. +4. Decide whether the bug is in identity construction, lookup, lifecycle, + compatibility behavior, or an external integration boundary. + +Use focused repros, recorded traces, and small log windows. Avoid dumping full +test output into the conversation. + +Prefer small, high-signal log lines over broad dumps. Good debug logs identify +the boundary being checked and include the relevant stable IDs, digests, keys, +hit path, or lifecycle state needed to compare expected and actual behavior. + +## Repro First + +Use a tight test repro before adding logs. + +Recommended integration command format: + +```bash +dagger --progress=plain call engine-dev test --pkg ./core/integration --run='/' +``` + +This command rebuilds the dev engine, runs it as an ephemeral service, and then +runs tests against it. Output includes: + +- dev engine build output +- test runner output +- engine logs/printlns +- test logs, such as `t.Logf` + +Capture output to a file under `/tmp` to avoid overwhelming terminal context: + +```bash +dagger --progress=plain call engine-dev test --pkg ./core/integration --run='/' > /tmp/engine-debug.log 2>&1 +rg -n "panic:|--- FAIL:|^FAIL\s" /tmp/engine-debug.log +``` + +During long runs, periodically grep for panics. If the engine panics, tests may +hang indefinitely: + +```bash +rg -n "panic:|fatal error:|SIGSEGV|stack trace" /tmp/engine-debug.log +``` + +If a test appears hung, capture a goroutine dump from the inner dev engine +process with `SIGQUIT`. Follow this closely so SIGQUIT is not sent to the wrong +process: + +```bash +engine_ctr="$(docker ps --format '{{.Names}}' | rg '^dagger-engine-v' | head -n1)" +docker exec "$engine_ctr" sh -lc ' +for p in /proc/[0-9]*; do + pid=${p#/proc/} + [ "$pid" = "1" ] && continue + cmd="$(tr "\0" " " < "$p/cmdline" 2>/dev/null || true)" + case "$cmd" in + *"/usr/local/bin/dagger-engine"*) + echo "sending SIGQUIT to inner dagger-engine pid=$pid" >&2 + kill -QUIT "$pid" + exit 0 + ;; + esac +done +echo "no inner dagger-engine process found" >&2 +exit 1 +' +``` + +Then inspect the same run log for the dump: + +```bash +rg -n "goroutine [0-9]+|fatal error:|SIGQUIT|chan receive|chan send|semacquire|sync\\.Mutex|deadlock" /tmp/engine-debug.log +``` + +After sending SIGQUIT, the tests may hang. Once you confirm the log has SIGQUIT +stack traces, you are done and do not need to wait for the test hang to end. + +To compare behavior against an engine from another git ref: + +```bash +dagger --progress=plain call engine-dev --source 'https://github.com/dagger/dagger#main' test --pkg ./core/integration --run='TestSomeSuite/TestSomeSubtestYouWant' +``` + +Do not run multiple suites in parallel unless necessary. Each suite is CPU-heavy +and concurrent runs significantly degrade performance. + +Do not use broad `./...` when running tests during engine-debug loops. You can +accidentally capture integration tests or other tests you did not mean to run. + +`./core/integration`, `./dagql/idtui`, and `./dagql/idtui/multiprefixw` are +integration-style test packages, not quick unit loops. Avoid running them during +tight debug cycles unless you explicitly need those integration paths. + +## CI Trace Replay + +When a failure happens in CI, start from the trace if one is available. The user +may provide either a raw trace ID or a command copied from the web UI, such as: + +```bash +dagger trace +``` + +Replay that trace locally with plain progress and capture it to a temp file: + +```bash +dagger --progress=plain trace > /tmp/ci-trace-.log 2>&1 +``` + +This does not rerun the CI job. It fetches and prints the recorded trace in the +same style as local `--progress=plain` output. Keep the full output in `/tmp`, +inspect it with `rg`, and avoid dumping the whole trace into the conversation. + +### Finding Trace IDs From GitHub PR Checks + +If the user gives a GitHub PR URL instead of a trace ID, first inspect the PR's +commit statuses and collect the Dagger Cloud target URLs for the checks of +interest. With GitHub CLI this usually looks like: + +```bash +pr_url='https://github.com/dagger/dagger/pull/13119' +head_sha="$(gh pr view "$pr_url" --json headRefOid --jq .headRefOid)" +gh api "repos/dagger/dagger/commits/$head_sha/status" \ + --jq '.statuses[] | select(.target_url | startswith("https://dagger.cloud/")) | [.state, .context, .target_url] | @tsv' +``` + +For failed checks, add `select(.state != "success")`. A Dagger status target URL +has this shape: + +```text +https://dagger.cloud/{org}/checks/{moduleRef}@{moduleVersion}?check={checkName} +``` + +For public repos, the Cloud GraphQL API can map that URL data to check IDs and +trace IDs without rerunning anything: + +```bash +curl -sS -X POST https://api.dagger.cloud/query \ + -H 'Content-Type: application/json' \ + --data '{ + "query": "query($org:String!,$moduleRef:String!,$moduleVersion:String!){ org(name:$org){ moduleChecks(moduleRef:$moduleRef,moduleVersion:$moduleVersion){ commitSHA checks { id name status traceId spanId moduleRef moduleVersion } } } }", + "variables": { + "org": "dagger", + "moduleRef": "github.com/dagger/dagger", + "moduleVersion": "e7600fda40142627a4206ec04de3a5f702be5a45" + } + }' > /tmp/ci-checks.json + +jq -r --arg check 'test-split:test-base' \ + '.data.org.moduleChecks[].checks[] + | select(.name == $check) + | [.status, .name, .id, .traceId] + | @tsv' /tmp/ci-checks.json +``` + +If the Dagger Cloud URL contains `run=`, prefer that exact check ID. +Current GitHub status URLs often only include `check=`, so the lookup is +"latest matching check for this org/module/version/name"; be careful after +reruns and prefer the non-success/latest row that matches the status being +debugged. + +Once you have the trace ID, replay it with `dagger --progress=plain trace ...` +and capture output to `/tmp` as described above. + +Start with the usual failure scan: + +```bash +rg -n "panic:|fatal error:|SIGSEGV|--- FAIL:|^FAIL\s|Error:|error:" /tmp/ci-trace-.log +``` + +Then inspect around the interesting spans: + +```bash +rg -n "TestName|FieldName|module name|command text" /tmp/ci-trace-.log +sed -n ',p' /tmp/ci-trace-.log +``` + +Use the replayed trace to identify the exact failing call, subtest, generated +command, or engine error. Once the failing surface is clear, decide whether to +reproduce it locally with a tight `dagger --progress=plain call engine-dev ...` +command or debug directly from the recorded CI trace. + +## Performance Debugging With Persistent Dev Engine + +For most testing/debugging flows, prefer ephemeral engines via: + +```bash +dagger --progress=plain call engine-dev ... +``` + +For performance debugging, such as pprof snapshots, repeated profiling loops, or +endpoint inspection, use a persistent dev engine running in Docker. + +### Start Persistent Dev Engine + +```bash +docker rm -fv dagger-engine.dev +docker volume rm dagger-engine.dev +./hack/dev +``` + +Notes: + +- The container is named `dagger-engine.dev`. +- This engine persists across commands/runs, so it is better for iterative perf + investigation. +- A clean reset is often desirable for consistent baselines, but is not always + required; it depends on whether cache/warm state is part of what you are + measuring. + +### Run Commands Against Persistent Engine + +Use `./hack/with-dev` to target the running `dagger-engine.dev`: + +```bash +./hack/with-dev go test -v -count=1 -run='TestWorkspace/TestWorkspaceContentAddressed/storing_a_Directory' ./core/integration/ +``` + +You can also run Dagger commands through the same wrapper: + +```bash +./hack/with-dev ./bin/dagger ... +``` + +Important CLI gotcha: + +- If you do `./hack/with-dev bash -c 'dagger ...'`, you may accidentally pick + up a non-dev `dagger` binary from `PATH`. +- In shell-wrapped commands, explicitly use `./bin/dagger` to avoid ambiguity. + +### Docker-Level Debugging + +Because the engine is a normal Docker container, you can use standard Docker +tools: + +- `docker logs dagger-engine.dev` +- `docker exec -it dagger-engine.dev sh` +- `docker kill -s dagger-engine.dev` + +### pprof and Debug Endpoints + +The dev engine exposes debug endpoints on `localhost:6060`. + +- Current routes are defined in `cmd/engine/debug.go`. +- Use whichever endpoint/tooling fits the question: point-in-time snapshots, + time-window captures, pprof profiles, or debug endpoint snapshots. + +Example heap profile capture over 15 seconds: + +```bash +curl 'http://localhost:6060/debug/pprof/heap?seconds=15' > /tmp/heap.pprof +``` + +Then inspect with: + +```bash +go tool pprof /tmp/heap.pprof +``` + +General profiling guidance: + +- Choose profile type and capture window based on the symptom. +- For long-running or phase-specific regressions, align profile capture timing + with the relevant test phase. +- Keep artifacts organized by run so diffs/comparisons are straightforward. + +## Metrics-First Leak Triage + +When debugging leaked dagql cache refs, start with Prometheus metrics before +adding deep logs. + +Enable metrics on the target engine: + +```bash +_EXPERIMENTAL_DAGGER_METRICS_ADDR=0.0.0.0:9090 +_EXPERIMENTAL_DAGGER_METRICS_CACHE_UPDATE_INTERVAL=1s +``` + +Current high-signal metrics: + +- `dagger_connected_clients` +- `dagger_dagql_cache_entries` + +Interpretation: + +1. If `dagger_connected_clients` is `0` but `dagger_dagql_cache_entries` stays + above the warmed baseline, refs may still be retained. +2. `dagger_dagql_cache_entries` is an index-entry count, not a unique-result + count. The same shared result may appear in multiple indexes. + +Practical scrape tip for nested-engine integration tests: + +- Prefer scraping via a container bound to the engine service, such as + `curl http://dev-engine:9090/metrics`. +- Scraping from the test process via endpoint hostname may fail DNS resolution + in some test networks. + +Useful correlation log during session teardown: + +- `engine/server/session.go` logs `released dagql cache refs for session` with + `beforeEntries` and `afterEntries`. +- If `afterEntries` trends upward across completed sessions, session close may + not be releasing all refs. + +## Internal Docs + +Detailed implementation docs live in `../../internal-docs/`. These docs are +useful when debugging a specific subsystem and needing the current mental model: + +- `cachebasics.md`: result model, `GetOrInitCall`, dependencies, public cache APIs +- `egraph.md`: symbolic equivalence, terms, eq-classes, hit selection +- `cache_persistence.md`: startup/shutdown persistence model +- `cache_pruning.md`: retention roots, persisted-edge pruning, size accounting +- `lazy_evaluation.md`: lazy result evaluation and object materialization +- `session_resources.md`: secret/socket handle model and session-compatible hits +- `filesync.md`: host/engine sync protocol and mirror/change-cache behavior +- `mutablecache.md`: mutable-backed objects such as HTTP, git mirrors, filesync mirrors, cache volumes +- `typedefs.md`: typedef identity and caching hot paths +- `dynamicinputs.md`: dynamic inputs and implicit cache scoping +- `dagqltypes.md`: nullable/list cache behavior +- `writingcoreapis.md`: practical guide for cache-aware core/schema APIs +- `version-gating.md`: schema views, `engineVersion` gates, workspace v1 test fixtures + +Treat internal docs as context, not authority over the code. If you are changing +the implementation, your edits may make the docs stale; verify behavior against +the current code and tests. + +## Cache Snapshot Analyzer + +Use the bundled analyzer for streamed `/debug/dagql/cache` snapshots: + +```bash +go run ./skills/engine-debugging/scripts/dagql-cache-analyzer.go /tmp/dagql.cache.1 +``` + +It summarizes retained roots, result categories, and approximate cumulative +closures so large cache snapshots can be inspected offline. diff --git a/.agents/skills/engine-debugging/scripts/dagql-cache-analyzer.go b/.agents/skills/engine-debugging/scripts/dagql-cache-analyzer.go new file mode 100644 index 0000000..5d3a0a9 --- /dev/null +++ b/.agents/skills/engine-debugging/scripts/dagql-cache-analyzer.go @@ -0,0 +1,724 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "sort" + "strings" + "time" +) + +type resultNode struct { + ID uint64 `json:"shared_result_id"` + OutputEqClassIDs []uint64 `json:"output_eq_class_ids"` + RecordType string `json:"record_type"` + Description string `json:"description"` + TypeName string `json:"type_name"` + RefCount int64 `json:"ref_count"` + HasValue bool `json:"has_value"` + PayloadState string `json:"payload_state"` + DepOfPersistedResult bool `json:"dep_of_persisted_result"` + ExplicitDeps []uint64 `json:"explicit_dep_ids"` + HeldDependencyResults int `json:"held_dependency_results_count"` + SafeToPersistCache bool `json:"safe_to_persist_cache"` + ExpiresAtUnix int64 `json:"expires_at_unix"` + SizeEstimateBytes int64 `json:"size_estimate_bytes"` + UsageIdentity string `json:"usage_identity"` + + Deps []uint64 `json:"-"` +} + +type termNode struct { + ID uint64 `json:"term_id"` + InputEqIDs []uint64 `json:"input_eq_ids"` +} + +type resultTerm struct { + ResultID uint64 `json:"shared_result_id"` + TermID uint64 `json:"term_id"` + InputProvenance []resultInputProvenance `json:"input_provenance"` + resultBackedSlot []int `json:"-"` +} + +type resultInputProvenance struct { + Kind string `json:"kind"` +} + +type categoryKey struct { + TypeName string + RecordType string +} + +type graphSummary struct { + NodeCount int + KnownBytes int64 + KnownByteNodes int + ByType map[string]int + ByRecord map[string]int +} + +type analyzer struct { + path string + + results map[uint64]*resultNode + terms map[uint64]*termNode + resultOps map[uint64][]resultTerm + + resultsCount int + resultDigestIndexesCount int + termsCount int + resultTermsCount int + digestsCount int + eqClassesCount int + + edgeCountExplicit int + edgeCountStructural int + edgeCountStructuralMissing int +} + +func main() { + var ( + topN = flag.Int("top", 15, "number of top categories to print") + liveGroupLimit = flag.Int("live-group-limit", 12, "number of live root groups to compute cumulative closures for") + persistedRootLimit = flag.Int("persisted-root-limit", 20, "number of persisted roots to print by cumulative size") + ) + flag.Usage = func() { + fmt.Fprintf(flag.CommandLine.Output(), "Usage: go run ./skills/engine-debugging/scripts/dagql-cache-analyzer.go [flags] \n") + flag.PrintDefaults() + } + flag.Parse() + if flag.NArg() != 1 { + flag.Usage() + os.Exit(2) + } + + start := time.Now() + a := &analyzer{ + path: flag.Arg(0), + results: make(map[uint64]*resultNode), + terms: make(map[uint64]*termNode), + resultOps: make(map[uint64][]resultTerm), + } + if err := a.load(); err != nil { + fmt.Fprintf(os.Stderr, "load snapshot: %v\n", err) + os.Exit(1) + } + a.buildGraph() + + liveRoots, persistedRoots, orphanZeroRefRoots := a.classifyRoots() + a.printOverview(start, liveRoots, persistedRoots, orphanZeroRefRoots) + a.printResultCounters(*topN) + a.printRootCounters("live roots", liveRoots, *topN) + a.printRootCounters("persisted roots", persistedRoots, *topN) + + liveGroups := a.topRootGroups(liveRoots, *liveGroupLimit) + a.printGroupedClosures("live root groups", liveGroups) + + persistedSummaries := a.rootClosures(persistedRoots) + a.printTopRootClosures("persisted roots", persistedSummaries, *persistedRootLimit) +} + +func (a *analyzer) load() error { + f, err := os.Open(a.path) + if err != nil { + return err + } + defer f.Close() + + dec := json.NewDecoder(f) + tok, err := dec.Token() + if err != nil { + return err + } + delim, ok := tok.(json.Delim) + if !ok || delim != '{' { + return fmt.Errorf("expected top-level object") + } + + for dec.More() { + keyTok, err := dec.Token() + if err != nil { + return err + } + key, ok := keyTok.(string) + if !ok { + return fmt.Errorf("expected object key, got %T", keyTok) + } + + switch key { + case "results": + if err := a.decodeArray(dec, func() error { + var r resultNode + if err := dec.Decode(&r); err != nil { + return err + } + a.results[r.ID] = &r + a.resultsCount++ + return nil + }); err != nil { + return fmt.Errorf("decode results: %w", err) + } + case "terms": + if err := a.decodeArray(dec, func() error { + var t termNode + if err := dec.Decode(&t); err != nil { + return err + } + a.terms[t.ID] = &t + a.termsCount++ + return nil + }); err != nil { + return fmt.Errorf("decode terms: %w", err) + } + case "result_terms": + if err := a.decodeArray(dec, func() error { + var rt resultTerm + if err := dec.Decode(&rt); err != nil { + return err + } + for i, prov := range rt.InputProvenance { + if prov.Kind == "result" { + rt.resultBackedSlot = append(rt.resultBackedSlot, i) + } + } + a.resultOps[rt.ResultID] = append(a.resultOps[rt.ResultID], rt) + a.resultTermsCount++ + return nil + }); err != nil { + return fmt.Errorf("decode result_terms: %w", err) + } + case "result_digest_indexes": + if err := a.countArray(dec, &a.resultDigestIndexesCount); err != nil { + return fmt.Errorf("decode result_digest_indexes: %w", err) + } + case "digests": + if err := a.countArray(dec, &a.digestsCount); err != nil { + return fmt.Errorf("decode digests: %w", err) + } + case "eq_classes": + if err := a.countArray(dec, &a.eqClassesCount); err != nil { + return fmt.Errorf("decode eq_classes: %w", err) + } + default: + var discard json.RawMessage + if err := dec.Decode(&discard); err != nil { + return fmt.Errorf("decode %s: %w", key, err) + } + } + } + + _, err = dec.Token() + return err +} + +func (a *analyzer) decodeArray(dec *json.Decoder, decodeElem func() error) error { + tok, err := dec.Token() + if err != nil { + return err + } + delim, ok := tok.(json.Delim) + if !ok || delim != '[' { + return fmt.Errorf("expected array") + } + for dec.More() { + if err := decodeElem(); err != nil { + return err + } + } + _, err = dec.Token() + return err +} + +func (a *analyzer) countArray(dec *json.Decoder, dst *int) error { + return a.decodeArray(dec, func() error { + var discard json.RawMessage + if err := dec.Decode(&discard); err != nil { + return err + } + *dst++ + return nil + }) +} + +func (a *analyzer) buildGraph() { + minResultForEq := make(map[uint64]uint64, len(a.results)) + for id, res := range a.results { + for _, eqID := range res.OutputEqClassIDs { + prev := minResultForEq[eqID] + if prev == 0 || id < prev { + minResultForEq[eqID] = id + } + } + } + + for _, res := range a.results { + depSet := make(map[uint64]struct{}, len(res.ExplicitDeps)+4) + for _, dep := range res.ExplicitDeps { + if dep == 0 || dep == res.ID { + continue + } + depSet[dep] = struct{}{} + } + a.edgeCountExplicit += len(depSet) + + for _, op := range a.resultOps[res.ID] { + term := a.terms[op.TermID] + if term == nil { + continue + } + for _, slot := range op.resultBackedSlot { + if slot >= len(term.InputEqIDs) { + continue + } + dep := minResultForEq[term.InputEqIDs[slot]] + if dep == 0 { + a.edgeCountStructuralMissing++ + continue + } + if dep == res.ID { + continue + } + if _, ok := depSet[dep]; !ok { + a.edgeCountStructural++ + depSet[dep] = struct{}{} + } + } + } + + if len(depSet) == 0 { + continue + } + res.Deps = make([]uint64, 0, len(depSet)) + for dep := range depSet { + res.Deps = append(res.Deps, dep) + } + sort.Slice(res.Deps, func(i, j int) bool { return res.Deps[i] < res.Deps[j] }) + } +} + +func (a *analyzer) classifyRoots() (liveRoots, persistedRoots, orphanZeroRefRoots []uint64) { + incoming := make(map[uint64]int, len(a.results)) + for _, res := range a.results { + for _, dep := range res.Deps { + incoming[dep]++ + } + } + + for id, res := range a.results { + if incoming[id] != 0 { + continue + } + switch { + case res.RefCount > 0 && res.DepOfPersistedResult: + persistedRoots = append(persistedRoots, id) + case res.RefCount > 0: + liveRoots = append(liveRoots, id) + case res.DepOfPersistedResult: + persistedRoots = append(persistedRoots, id) + default: + orphanZeroRefRoots = append(orphanZeroRefRoots, id) + } + } + + sort.Slice(liveRoots, func(i, j int) bool { return liveRoots[i] < liveRoots[j] }) + sort.Slice(persistedRoots, func(i, j int) bool { return persistedRoots[i] < persistedRoots[j] }) + sort.Slice(orphanZeroRefRoots, func(i, j int) bool { return orphanZeroRefRoots[i] < orphanZeroRefRoots[j] }) + return +} + +func (a *analyzer) printOverview(start time.Time, liveRoots, persistedRoots, orphanZeroRefRoots []uint64) { + refBuckets := map[string]int{} + refPositive := 0 + refPositiveNonPersisted := 0 + depOfPersisted := 0 + hasValue := 0 + knownSizeCount := 0 + var knownSizeTotal int64 + for _, res := range a.results { + switch rc := res.RefCount; { + case rc == 0: + refBuckets["0"]++ + case rc == 1: + refBuckets["1"]++ + case rc <= 5: + refBuckets["2-5"]++ + case rc <= 20: + refBuckets["6-20"]++ + default: + refBuckets["21+"]++ + } + if res.RefCount > 0 { + refPositive++ + if !res.DepOfPersistedResult { + refPositiveNonPersisted++ + } + } + if res.DepOfPersistedResult { + depOfPersisted++ + } + if res.HasValue { + hasValue++ + } + if res.SizeEstimateBytes > 0 { + knownSizeCount++ + knownSizeTotal += res.SizeEstimateBytes + } + } + + fmt.Printf("Snapshot: %s\n", a.path) + fmt.Printf("Loaded in: %s\n\n", time.Since(start).Round(time.Millisecond)) + + fmt.Println("Counts") + fmt.Printf("- results: %d\n", a.resultsCount) + fmt.Printf("- terms: %d\n", a.termsCount) + fmt.Printf("- result_terms: %d\n", a.resultTermsCount) + fmt.Printf("- result_digest_indexes: %d\n", a.resultDigestIndexesCount) + fmt.Printf("- digests: %d\n", a.digestsCount) + fmt.Printf("- eq_classes: %d\n", a.eqClassesCount) + fmt.Printf("- edges (explicit): %d\n", a.edgeCountExplicit) + fmt.Printf("- edges (structural): %d\n", a.edgeCountStructural) + fmt.Printf("- edges (structural missing representative): %d\n", a.edgeCountStructuralMissing) + fmt.Println() + + fmt.Println("Retention Shape") + fmt.Printf("- dep_of_persisted_result=true: %d\n", depOfPersisted) + fmt.Printf("- ref_count>0: %d\n", refPositive) + fmt.Printf("- ref_count>0 && !dep_of_persisted_result: %d\n", refPositiveNonPersisted) + fmt.Printf("- has_value=true: %d\n", hasValue) + fmt.Printf("- live roots (ref_count>0, no incoming deps): %d\n", len(liveRoots)) + fmt.Printf("- persisted roots (dep_of_persisted_result, no incoming deps): %d\n", len(persistedRoots)) + fmt.Printf("- orphan zero-ref non-persisted roots: %d\n", len(orphanZeroRefRoots)) + fmt.Printf("- known nonzero size estimates: %d results, %s total\n", knownSizeCount, humanBytes(knownSizeTotal)) + fmt.Printf("- refcount buckets: 0=%d 1=%d 2-5=%d 6-20=%d 21+=%d\n", + refBuckets["0"], refBuckets["1"], refBuckets["2-5"], refBuckets["6-20"], refBuckets["21+"]) + fmt.Println() +} + +func (a *analyzer) printResultCounters(topN int) { + typeCounts := make(map[string]int) + recordCounts := make(map[string]int) + persistedTypeCounts := make(map[string]int) + persistedRecordCounts := make(map[string]int) + liveTypeCounts := make(map[string]int) + liveRecordCounts := make(map[string]int) + + for _, res := range a.results { + typeCounts[nz(res.TypeName)]++ + recordCounts[nz(res.RecordType)]++ + if res.DepOfPersistedResult { + persistedTypeCounts[nz(res.TypeName)]++ + persistedRecordCounts[nz(res.RecordType)]++ + } + if res.RefCount > 0 { + liveTypeCounts[nz(res.TypeName)]++ + liveRecordCounts[nz(res.RecordType)]++ + } + } + + fmt.Println("Top Types Overall") + printCounter(typeCounts, topN) + fmt.Println() + + fmt.Println("Top Record Types Overall") + printCounter(recordCounts, topN) + fmt.Println() + + fmt.Println("Top Types Among Live Results") + printCounter(liveTypeCounts, topN) + fmt.Println() + + fmt.Println("Top Record Types Among Live Results") + printCounter(liveRecordCounts, topN) + fmt.Println() + + fmt.Println("Top Types Among Persisted Closure Results") + printCounter(persistedTypeCounts, topN) + fmt.Println() + + fmt.Println("Top Record Types Among Persisted Closure Results") + printCounter(persistedRecordCounts, topN) + fmt.Println() +} + +func (a *analyzer) printRootCounters(title string, rootIDs []uint64, topN int) { + typeCounts := make(map[string]int) + recordCounts := make(map[string]int) + categoryCounts := make(map[categoryKey]int) + for _, id := range rootIDs { + res := a.results[id] + if res == nil { + continue + } + typeCounts[nz(res.TypeName)]++ + recordCounts[nz(res.RecordType)]++ + categoryCounts[categoryKey{TypeName: nz(res.TypeName), RecordType: nz(res.RecordType)}]++ + } + + titled := titleCase(title) + fmt.Printf("Top %s by Type\n", titled) + printCounter(typeCounts, topN) + fmt.Println() + + fmt.Printf("Top %s by Record Type\n", titled) + printCounter(recordCounts, topN) + fmt.Println() + + fmt.Printf("Top %s by Type+Record\n", titled) + printCategoryCounter(categoryCounts, topN) + fmt.Println() +} + +// titleCase capitalizes the first rune of each whitespace-separated word in s. +// It is a deliberately minimal replacement for the deprecated strings.Title. +func titleCase(s string) string { + words := strings.Fields(s) + for i, w := range words { + if w == "" { + continue + } + words[i] = strings.ToUpper(w[:1]) + w[1:] + } + return strings.Join(words, " ") +} + +func (a *analyzer) topRootGroups(rootIDs []uint64, limit int) []groupSummary { + counts := make(map[categoryKey][]uint64) + for _, id := range rootIDs { + res := a.results[id] + if res == nil { + continue + } + key := categoryKey{TypeName: nz(res.TypeName), RecordType: nz(res.RecordType)} + counts[key] = append(counts[key], id) + } + + groups := make([]groupSummary, 0, len(counts)) + for key, ids := range counts { + groups = append(groups, groupSummary{Key: key, RootIDs: ids}) + } + sort.Slice(groups, func(i, j int) bool { + if len(groups[i].RootIDs) != len(groups[j].RootIDs) { + return len(groups[i].RootIDs) > len(groups[j].RootIDs) + } + if groups[i].Key.TypeName != groups[j].Key.TypeName { + return groups[i].Key.TypeName < groups[j].Key.TypeName + } + return groups[i].Key.RecordType < groups[j].Key.RecordType + }) + if limit > 0 && len(groups) > limit { + groups = groups[:limit] + } + for i := range groups { + groups[i].Summary = a.closure(groups[i].RootIDs) + } + return groups +} + +type groupSummary struct { + Key categoryKey + RootIDs []uint64 + Summary graphSummary +} + +func (a *analyzer) printGroupedClosures(title string, groups []groupSummary) { + fmt.Printf("Top %s by cumulative closure\n", title) + for _, group := range groups { + fmt.Printf("- %s / %s: roots=%d reachable=%d known_bytes=%s known_byte_nodes=%d top_types=%s\n", + group.Key.TypeName, + group.Key.RecordType, + len(group.RootIDs), + group.Summary.NodeCount, + humanBytes(group.Summary.KnownBytes), + group.Summary.KnownByteNodes, + topCounterString(group.Summary.ByType, 5), + ) + } + fmt.Println() +} + +type rootSummary struct { + ID uint64 + Summary graphSummary +} + +func (a *analyzer) rootClosures(rootIDs []uint64) []rootSummary { + summaries := make([]rootSummary, 0, len(rootIDs)) + for _, id := range rootIDs { + summaries = append(summaries, rootSummary{ + ID: id, + Summary: a.closure([]uint64{id}), + }) + } + sort.Slice(summaries, func(i, j int) bool { + if summaries[i].Summary.NodeCount != summaries[j].Summary.NodeCount { + return summaries[i].Summary.NodeCount > summaries[j].Summary.NodeCount + } + if summaries[i].Summary.KnownBytes != summaries[j].Summary.KnownBytes { + return summaries[i].Summary.KnownBytes > summaries[j].Summary.KnownBytes + } + return summaries[i].ID < summaries[j].ID + }) + return summaries +} + +func (a *analyzer) printTopRootClosures(title string, roots []rootSummary, limit int) { + fmt.Printf("Top %s by cumulative closure\n", title) + if limit > len(roots) { + limit = len(roots) + } + for _, root := range roots[:limit] { + res := a.results[root.ID] + if res == nil { + continue + } + fmt.Printf("- id=%d type=%s record=%s desc=%s refcount=%d reachable=%d known_bytes=%s known_byte_nodes=%d top_types=%s\n", + root.ID, + nz(res.TypeName), + nz(res.RecordType), + nz(res.Description), + res.RefCount, + root.Summary.NodeCount, + humanBytes(root.Summary.KnownBytes), + root.Summary.KnownByteNodes, + topCounterString(root.Summary.ByType, 5), + ) + } + fmt.Println() +} + +func (a *analyzer) closure(rootIDs []uint64) graphSummary { + visited := make(map[uint64]struct{}, len(rootIDs)*4) + stack := append([]uint64(nil), rootIDs...) + summary := graphSummary{ + ByType: make(map[string]int), + ByRecord: make(map[string]int), + } + + for len(stack) > 0 { + id := stack[len(stack)-1] + stack = stack[:len(stack)-1] + if _, seen := visited[id]; seen { + continue + } + visited[id] = struct{}{} + res := a.results[id] + if res == nil { + continue + } + + summary.NodeCount++ + summary.ByType[nz(res.TypeName)]++ + summary.ByRecord[nz(res.RecordType)]++ + if res.SizeEstimateBytes > 0 { + summary.KnownBytes += res.SizeEstimateBytes + summary.KnownByteNodes++ + } + + for _, dep := range res.Deps { + if _, seen := visited[dep]; !seen { + stack = append(stack, dep) + } + } + } + + return summary +} + +func printCounter(counter map[string]int, topN int) { + type item struct { + Key string + Count int + } + items := make([]item, 0, len(counter)) + for key, count := range counter { + items = append(items, item{Key: key, Count: count}) + } + sort.Slice(items, func(i, j int) bool { + if items[i].Count != items[j].Count { + return items[i].Count > items[j].Count + } + return items[i].Key < items[j].Key + }) + if topN > len(items) { + topN = len(items) + } + for _, item := range items[:topN] { + fmt.Printf("- %s: %d\n", item.Key, item.Count) + } +} + +func printCategoryCounter(counter map[categoryKey]int, topN int) { + type item struct { + Key categoryKey + Count int + } + items := make([]item, 0, len(counter)) + for key, count := range counter { + items = append(items, item{Key: key, Count: count}) + } + sort.Slice(items, func(i, j int) bool { + if items[i].Count != items[j].Count { + return items[i].Count > items[j].Count + } + if items[i].Key.TypeName != items[j].Key.TypeName { + return items[i].Key.TypeName < items[j].Key.TypeName + } + return items[i].Key.RecordType < items[j].Key.RecordType + }) + if topN > len(items) { + topN = len(items) + } + for _, item := range items[:topN] { + fmt.Printf("- %s / %s: %d\n", item.Key.TypeName, item.Key.RecordType, item.Count) + } +} + +func topCounterString(counter map[string]int, topN int) string { + type item struct { + Key string + Count int + } + items := make([]item, 0, len(counter)) + for key, count := range counter { + items = append(items, item{Key: key, Count: count}) + } + sort.Slice(items, func(i, j int) bool { + if items[i].Count != items[j].Count { + return items[i].Count > items[j].Count + } + return items[i].Key < items[j].Key + }) + if topN > len(items) { + topN = len(items) + } + parts := make([]string, 0, topN) + for _, item := range items[:topN] { + parts = append(parts, fmt.Sprintf("%s=%d", item.Key, item.Count)) + } + return strings.Join(parts, ", ") +} + +func nz(s string) string { + if s == "" { + return "(none)" + } + return s +} + +func humanBytes(n int64) string { + if n <= 0 { + return "0 B" + } + const unit = 1024 + if n < unit { + return fmt.Sprintf("%d B", n) + } + div, exp := int64(unit), 0 + for v := n / unit; v >= unit; v /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp]) +} diff --git a/.agents/skills/skill-creator/scripts/__pycache__/__init__.cpython-314.pyc b/.agents/skills/skill-creator/scripts/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0e4b7b8841c7961f266e59454c1ea95c3872508f GIT binary patch literal 191 zcmdPqP`{Z=v*F)=VOdC!t09d6lb^rhX literal 0 HcmV?d00001 diff --git a/.agents/skills/skill-creator/scripts/__pycache__/aggregate_benchmark.cpython-314.pyc b/.agents/skills/skill-creator/scripts/__pycache__/aggregate_benchmark.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2981251d379fec3e96a3d78930af6dc0f7cd8ede GIT binary patch literal 19719 zcmdPq`KikeiyAr=w6@l9G~IrlXLXnWv+Wn^>WcmS3chnwSidFDc4Q=F(M2NzExqR7grK zDNjw!Qvm6WFV4=)$pKpd;^vo@Kt+=C^U^ZYON$aqGV}9_xwt}$6Vp?zxD*r=6bdR! zGV=2j5@8_`5BIHJL8XEXoEM*xS!Bn>0HeJ-w$j8ui<rQy+(28GnJ#2j5e zh$tvL9!;48l>r4IB>a$Nn-mlj5Ne7_^K=dMk>eh0Jj5V~QkX>`rAD}v!W4n*ghv~~ zT8Mcthk;DOVJSo*%ruZfG%LZ*^GQulOooN7f(FP^P&zEuS12w`f`>kF;a60eSB!8d z?r?>;4CV!pmFQs$QGpp~M0pitAdY~8n2Q`HXeRn;GTq`xNi0b%$;?f?#Q`F#^7B$P z8E>%!B$i|(GcqtRfH0^SVPRlk02OQ<_?Qi>)ZNq_ikcllc})PG)h* zE!MQ0{KS%5EGe1EC7O)4*wXTgauZ96K`v6dm9C$WpPQTan8WN5X6iS4`W6WXF-U^ z!2N^pJCaIPB$d1{JFuw)g`*~Wm6>y5PI74usM^C**(54}QlCP8ngS$U>fK@iseTT# z;1(OCj=II1o0)fuIXAHaC6YlAW6Z$7@Y#uhfx(7Ri9w#Bfgyy!O@x7=P%xMYIcVdU zbU0w9GB8vyG_cAuc#APGa5Bh&Yim>HOh!$nB2Ym4tz^(-zQv?xaEq~`ND!0&LH4LA zG!%h6ev8v4JvA@2qM*p`mQpgzlkuS1x>(O9CqFqcr`S#puEq?cUy^}=;m1pc28Ih_ z<{Q~o7|w8+%dsGEdi|vO&1^r5K^e7KMxIfV{T6dhYThm8;?mq(EV+p#8Mj!93yVr_ zu@>c*=A{&gFfcF_i83%SXtEZuGcYjRV$8e6QI=SgnV6THS|kjT1-q7&fq_9yK|#R* z;$D61&j`3}P!g?mk}3Ojai+z}L=E;&hZ zhQ(z;l}@$?f+8JkAJ`Z;`KPc>NSWa{nePUNz!cRPYzr7K3n|^;;O!}$5~AK>>y|Y zN|Fo=4Dt-VI?@a=psWwp6~-EXR6m1-7#J7=WMM1>9mD~%Gma6tPKSvWa3F;;Yk(p` zCyWK_jKFjVFt{?i)iW>@aE5UPfU-YKA(V!xhoy;ha7)8mhJgVoU2$TKPfnN}FdEGa zI|ha@PHa9e;0j-C$;iNv!U~EpJ%(^AH3kL-B%cv!AJ}g^3=9R_NMX&5%|^1qnkS4q zhzlhp@`mvelM>SfLHLIc&I2y2+D($u)+)sNF{`F2rmyqkV=RIYAx%n25F{-h=&LU zDdKT~DpH6_>M;~Zg-8XcLW_7LDnt@%2&y664+~3q29iurr`QAy>?VW=25Ex)9chJ} zlLBB-jqEo;gxg)2z4;*_=gREe1ZA;!n?hJ&%*qT34BCiZArmOQWHQM!$TG--p)0e` zR%>QihD=5U2BbU%YK9}(2x7*Ffk+TGW@2MVVF#Hc&k({=z#+@v%ItF^lL2lHsJ#eM z$bd!N8-zN%?qc$J2{#YXe!=1{Hii^AkXs4_kWv{BIF<2+2%x4iS%x6?0s(sgS7z^3 z3{Z9^BhfCBCE7*c`W_U@u1r3BF!MFFs`hgE zG>TEI02;PX$WEZR)`D1nA9@qd-1rpeh;MvxYI1&FO7Sfo(AXA?SH%rc4e=ZgL`zA2c4}VnEjB1Em|BsVTnZXB z28BgYW^(Z@0hq%4ocQF#oSfoYTqXH%Hcx(ONkM5zd~!x&QSmLI)S{yNqT=|}yyX1S zypq%+kdq-|w|G)33R06n1L^sB#kW{WQY%WTB;Y}VBnb+cDr1G*%;I8DBp0O?mSz^E zrYNLkrskv+D`L@@0L`NaDEHfoFFF94yO5v7JX>n>%d|rM@YB6k#{T6R2s7DS; zF(sL)#kY9!Qd3ikWMahK;8Wha8W^u@PW!5TG1Zn33SWEPhc7lFoaZn322 zC}pn)V!2iY>5R0sd*`y(nX+Z_w1yj9VPgq+0=HflT6s zrSA9w(0B$@Zd#EBXy}3ql1IS?utC_j*g=UhC9|kV8>9l3=;J~29Jd(LZm~mi!7avA z(C{T_?xe^Nq!OCMAPSjE(r&>7K)LD`du}RdE~EICKv80OJW`Yw-(pU!D7eK7jsaNI z6@f;Qilw0=D4ziNZW z4R-$a`o{X}>=KvQC2okyc5>a}IT1H2j>HM#Rm5$a_Vc?R#dI8Us=DS;)eW$xD`JzvG6M25tW>wdtFrH zim1j4r#o^=^D}2V?2L^0 zxifRG%NbsjGu)tjSE*K;nt8_zbT3{4@DiuwRxixGrpXN!akFfW!?^`RVZ!<2&4L@QYsI zSGl32GJ$;}{}XA&1zOAX7wT_NzpP|+UD|pA`yE+@1>(zP7Rp>#G`^^4yutahqQwru ztBTf_Wo;&K-H=yXA$L{Yd;-q{apmjcS{KE&u8Zqj64$vSB6&f^{s8-q$_rASS46yS zipYFmVHK3UBPly0=enf!6-n(Cg?E(HmTNB3T;aG_X9oLsW(Fyp4+0FLax=V?%8j9m{DURL*2AB(4orw88Fx;bnE#32rkiE{dq!P}W**z0mrCzVm^=%gXK(*d|n56j1oU z#-O4N5?@ibqv*1-(**&A+tS(-oNtKBE(pFNuCc;nhx0WZ`#Tb{7ZfaZ$n4;|;8uB| zs`f%<-37V&Zww5Qj!Yj}8Dw-murX*^LBr|;8-t?8a_)uP7jzsBC|y=?oxpoTLTUo* z4FS>VJQI0l#9o)xyC|!-qW%EWWm)Gd0xswhAg9PWT@i3bm#E#5bXnH%ih$D%CG`nx z6Ury@KM)X~%70xz?V^C%g0kxZdS5CT8Dwm}h%!i>rpJd6j>E;bIVxLlgYa|kj99M zA{ZDLAS1(!1P6sTfChy@1APq)-x&-T#Xo|GIVB%J)EX8r4|6~m)QD$hU|{)N2Cg{t z844JYr=kL&Ly91G!7yyd4%Xc%UQz)-*(hII%yj2Y3nDqsm?f%Tr!RY6?@ z=yVuM5OWY~5OWY)5VJl@5IcB^3{{LH2(;b;w8nxpjvqEoIVUC}ncvW?(1`4MG{j^keeog>=9Q*a}#&sAfm*;vuPKM^g=&;|aw&B*}s3 zvimW?=6DJ?3Rr^}5#1ofuy7DNk|=z*H;4mC6h0se_vto>9}tsbVeo;yI3|ld3=AnI zpim8C#OA^i)=aD{P2Q>^q@_woeOt)- zBxuhNeN_^q`weOY!xVse+F(`1h~-3(20pZ-2wpk_URR{YRipzdT0ygx;4UX*5)(9# z0iNe90?q9fnKCdiRIzC58Kr?pLojKOc8e8kgeEJr$CRB~SzP1|G7U7pQ6vMJCglT{ z+_$)qoOg>I;+Eo~IFMRUzPZI%bc+krvjg{mi*K=l`me=cYj3d^6lH=IW8LBat!>E3 z%uBt+54JZRuHhCJL;$4g77vsS?%)=Kigh)V{sW}vAc$E00P8wbGcYiKs}64_aMyu{ zL0GiG`wpMb1oP=O6Ky8jUFK72aDOZ$*}-;$U#!2nvwDWv0;9|PY8@=k`Gp?{NY6-H zki5cTL+}pO1HuPVPXu3(4ZI;Bw?J%#@QREb%sWC4IG+%`5D@XD#0;|qh6^$_ zFm5p2p|~UTfa3)zw-4+r%6uK(7kT7Aaxw6VKGZY1ENZ+mWrg4j?&*0G@>ZsFa9t2J zzNuqyS-^0m%YwoQcGm@!E(j{Ebm?HdAYgb?&*-usSp9U>iK;U=7ARg5QdybO!FEB= z_yZdQuf!br6;hWaO>Xc=&GBF1dRfNk29L;;`~_*3#dJTgv&eIGa9`$-{wU8NEcHc) zfrGolyt!sV;T3j?8$zmCx=ZRsm(&YsIT!NsFS`_66)R})zac0wU3#MQjJOp+mjw;33z}RM zG}#cgBk;1I{Q-^RItO)5)Ln>AyzG>8-6`#&Q`&{}+za^ymz@f)3Klka-4K?VZavX@ zM#T!D%fdR>g$*tW8*C8UA$VEXdPm0ooSiu*EG~qGU$%?5ZWnXWF6KgP@`cp2%XaBk zh0_~+K-1^7!`9$Ef&C(j#0MS* z9`Oq-Vn07wGKiV{yd_}x^Om4-F=!;`pt7Kc4%JB+#5UsD<&_ zgT#6CAZEmzdJqfJf+H4X&>98MS_;U(0T*~NP&|120m=pq2SD0X%tfHt_FK%wB}D{V zSD=-l#hPGeerGUd6#WPymZW?DQG1xd)R|;3btO6;ED33BfxHK6Ie|uC!Hum|;H-w+ z(1JInn33i^nc<==dJF}uVax&yZtEBr3fRKUkeez33~uuo7z)_KSg}`tVeF_4p&+I( zwjky(_8?|tQ-WB6Si{(ZSW(2-!q|h@P(;|n*n?0SN_HqTYB2 z;4}QN;9y_~;zBf-g1B+0;YL!!gF_7uLXAF05N{ZJ5HE`Td|~WCe8?jD@VR+;2JeNC zCYARF2#si3A?AdG_{AB*m;>N*`v^b4n!}iDtl+W75hM^K7{X}H2rBeJIw7;%@(e*j zLBb(SSj0twM1#bF#Dm0wBw|2iIY<)&_H_a=pnL|A2UTBS7J`7eAwV1<24e+D!eoP_ z!Z?DY4g81|p^jSc&_R0*Ja#bc=?x}g;kwAsye9#Knl2pXHVekkm zVmUChV$tJ*jJNSZsy%2G3?5-CN&(e?;0jm|#L@>5prtg3YPZM&BxVgFY(NBPa12~@ zz#I?Cxu8lKyi!pKw){$*fuVpgj8TBWjgNr=)G-M{Y3@Q7UkO0s*;^7qgO*K07h!=$ z2>pscVWr7jBmuG*Tv-=`x>lf^tfrsu|d*DBj`tft7)it3$J&r;F#Ip!i2t22Ro2Vrr0uSxxeajJG&o zqnJgtpvDM~4Y)kh1(j!ZMRgz{ej8AMreBf|S@&pniw`oWonI86o1c=JQ^jKgrXhN6 z@g?RZ=2TXtLS=7pf?5y7C5gEORYIzfx~jRlswp9=9#*QpR;s~Kw>Uu~tHq$P##>_G zG1!9CqIlF81tsdDLQt!R2Rsg01Zq(fg@OVP+$wShjg&GMr3 z*om#+zN%;f8h}e%5OGyb51A!BBW!{D-1r+JN($b%B%b0p@=%#vA>u{>vC&SgpC>ykDXC2cO) z240p7y1^qpL*fRH%7VlX{2ZcOAA}e<`6sa4;1HcqeS?F4g3t#^UO~17&ku48Jfas^ zL?E>!Xw4Z2gK{@0XM>W)XHba%T0M(z4Kj|!bw&N4!hWP7;($ zLAe?X^O3=fQD%wA2N1PJ8O%dC735411}zK(SLi(KpkxGIvIa5(B*y?QiWq{J zkgFX~ktDzn$_yQ);ALPaG!A06C}&`RR0-bP3=Cn+eoV0G;{uifW~62Ud;we>BeCJ&cVa z{Tx9YVT`Cv2Ob6^rXa2$Zcu*`IV8d2ykU%})jC*|KS%&uV*{yL2Ne_GI0|D75=1r? z=1W+`$iomM6vh}NjI4%-AxOlCHHa%nG>9umER3CpAxJ!ogNGqV!iY6UQje>EGmHsa zLm`Y6(N8bn!l8x@Newr4H9^csYIwr9u-O$Pg`|czjGLeuzA(-JP-+H;3W5ld4&%kD z7ioD+T#63TvdeRyoh%^toI!yDhhnO-- zh-nZJVwy-HCWs@%kkts`2r;cNL4rO6rABQW>X3a1QYRiJM5KAx)nS^4J;XFoLJT#H zF<^Cp4lEUd+CZS(nPLGh`8fkX16UvdB&^2}r0>rN(;?3g#t@`~Tz&~KxPh9S z?KcI`yt!Uc3#jr2wJ3}7L2XA4NP9Cr@fM7gbc-?m7NbIyu(Cohcu$}cY!9QA0=VsX zi=m2JOUoD3{?*d5QYdN#wUoKEv|JKPQo#c4AOUVIE!VQdoMMnj6_buaQ3ptrNkOBi z6U2}O?;3+F!zoBDf-dfXc4RcGxRjL@f+0I5tGH_ve8H3b3N;E<%pihGp+><4yoIr* zO20-I3g8S7ABCf2&8cakK|j7*+oU57@rIxrhsCc zJtr|KH77pt7L<{6ixo5iT6~K$5w=|77H1NS2}va^iSY$Rw^))uBwJ!Us5fzoEeS$% zB*sHm{BR_}SS;Yx7oaXPth<0bUJB_t2*SG#xrs&DDf#7jMQa!s7{G1CL*NOs2mBH< zq^|O-GGJ{&X@T#uJh?#Lqc1rhBF96BHc zDbq72W-eh~;=Dp)gZ5QTyUPmpm&F_|h&mn+I^c1^H}$GZ+6CwI4-5<$jIK-{7#KL+ zm_SBzh=52g(Fy4jB4;EoP+uUqLhAyD=?xCc52D<>Yz?j-q!9T-s zgXawaX;eOsc)x$A{|xs9L6>Jx)Yi2#>hz5_usi_Nq(lg}B5E z&Pf-N(k`TDUr*1!n4W*3pzK0L<>mCM3)OX3)9XGkFgP$dgFKhc=mMf#m^?w07n2{; zcLoM8rXZ$|5b6U1Loib`NG_Hsj_ET|14Lyo<$$PM zrXrAGxlCms!$8yr(B2a#<_`=EF3g_HpBWf(nY=)}942oN<<0C1qJ;dIzc4Vc`ZIrE zW8f3-kL`?|VYkBQGOu2P+YMeZR6aC#EN6txkD3{^f^7riWm%&Q#usGFHIBDypy124As0d;F4#m~h>X1umvTKW<6>OKh0Od5g+-U+iZ7IuUx=-^P*MAVfuWw! z73Ao8Mo*BVLDUD3FM~iH3SkNbg+M)H7>F0n6a}K{7{Q?v%>)gWRFL)rrc97_5cPq9 zA&aR3q_T>s9;7{i$pPfccqT^><;d&;qJ&(*es%--SwOPCwzGCd#Db#B{8|kjH~1w` z`A|Pw�JRGqYxe+XlzW@@5-~FUVQ%Fgd_+oaZ19BsDmIQo{xNI8bUx%DSGEcQGmN zLVoFm@`}qz;ME(6pmb2r2u=qsOgh2%@hN2xC>J(h!@9{ z2%?;sl0Z}vQ!0o`W6B0;Ph-jlX$MgsK;u%#{!WAVI~C?{XRyCr!2Wgx`I|$qy|%G- zLilxd*-PxQH#m6vc{+JMF|denePLkX_~S}K%&T}17Ih+2os4IaUMpH80{Ml(`o zgw2q?#-sX?nL$(?l%)jvEjukIgkI*BzQ7{=^HU-t1HUgDipfMa|l?)6FiVQ&vL5$|mPJ96a_LTrZOfjIbd9dCf<{%bx zkon*(OE}cCg8H6890b*I2C)Tk5uuJdhzH39yg@AHp!yEx0?-mju-gy>yl)HI8VBz; zBIL2P*I;^Jt+jN0P-`s)-oFas3u6qxViszli?CObAr7gH7X#Xy36{ie zau{O(e4#j48JGxT3;>OBg4qZHwt1(3DU1=@W+tTJY7vmXo zjqHjb2^5|rGOvIqj5`3dkQQMTm=z=yBn|6Z2Y`0hgGCVpEY}q9BDqB-474#tHjD>t zyg-hZAxPec72#6vP{?3Dl3s-{F0B5RK<%2F^H>l)9wL@IXfod7PDzCvV33)g2Oc4>k_VmLpiq*pP?CYvrBZ-zQP5<% z#ZppPka~+HBQ>X>ibq!$F>?TJy;mvwfp#h6r-4lXODZI#f|j7e9haJ-$#jc7B{eOv zG^eBpv`7G94rtP$N*UP^IE<*`(A9;kny+Hg&9CzC2d|)40O$Zn^{n7s zh@u&wu8#OEj>Mw$g2bZY)LVRxMd_uvsd*&H!K+(tF%=ZuVg+~Nz)Ml!!2)JL`c^EtiJ5stM?q&q zfQH^9z*FLPctx-Cs$Ak#xgp~Fg_((ut-hC59gd`%hk-}vI=9S4Zkfy6at$sIxcRSh%UI#ST9S-h(jxLS~f<4?{*cb$r z7nodEHod59x}juy{l@weEa&-8@?RE+Xz={N#vm*{U3;STbs>)nLLLiLt}B^bR5H0> z?tWFty}|p2fb?|%^@{@PD_AcJ=rwqLVPoLp>&TvAe4SJN3a9)6!#f;2{hVE#6EqhT zUggmI&dk82@Pvi4!+QqzgxU*Ss#jRlzOXR}C@zq^%&*IYan5r|cC@*#&|( zI5_*+JJ~02T;Y%eX_bR$<(yD`flK)ciwekbGSFV_O(|9EecBsny*H24t#VbiRpv;7+Z8#&rR`ivoI=1q?b^?{M%=V47e$L4AhM z45t~zbKMpg&#+lgeSy#T8i&b8cF;@~2LmhD1s>TMsq-^uX0G7g5PDh8`hu*@1uolb zEOy^D8MqX#bIM=fl>haehk;A(I;ZTPPduPTt_lMK!%;R#PZQQ-B0^pqtj9Syyrfx< zi?Ml0vz#>H_G0Bc#lh_*!Fh`#J|2`T", + "executor_model": "", + "analyzer_model": "", + "timestamp": "2026-07-02T22:32:38Z", + "evals_run": [ + 0, + 1, + 2 + ], + "runs_per_configuration": 3 + }, + "runs": [ + { + "eval_id": 0, + "configuration": "with_skill", + "run_number": 1, + "result": { + "pass_rate": 1.0, + "passed": 4, + "failed": 0, + "total": 4, + "time_seconds": 0.0, + "tokens": 2564, + "tool_calls": 0, + "errors": 0 + }, + "expectations": [ + { + "text": "The output recognizes Tangled CI as Spindle and discusses .tangled/workflows.", + "passed": true, + "evidence": "Manual grade: supported by the generated output for this run." + }, + { + "text": "The output references both build.yml and check.yml or their roles.", + "passed": true, + "evidence": "Manual grade: supported by the generated output for this run." + }, + { + "text": "The output identifies engine: microvm, image: nixos, and Docker-in-microVM requirements for the build workflow.", + "passed": true, + "evidence": "Manual grade: supported by the generated output for this run." + }, + { + "text": "The output preserves the repo conventions such as devenv shell and proposes minimal/focused fixes rather than a broad rewrite.", + "passed": true, + "evidence": "Manual grade: supported by the generated output for this run." + } + ], + "notes": [ + "Manual sanity run: Claude CLI was present but not authenticated, so these are not independent subagent outputs.", + "Generated with-skill outputs inline and graded assertions manually." + ] + }, + { + "eval_id": 1, + "configuration": "with_skill", + "run_number": 1, + "result": { + "pass_rate": 1.0, + "passed": 4, + "failed": 0, + "total": 4, + "time_seconds": 0.0, + "tokens": 1712, + "tool_calls": 0, + "errors": 0 + }, + "expectations": [ + { + "text": "The output recognizes the SSH command as the Spindle/Tangled CI terminal log viewer.", + "passed": true, + "evidence": "Manual grade: supported by the generated output for this run." + }, + { + "text": "The output tells the user to run the exact ssh -t -p 3333 tangled.org command.", + "passed": true, + "evidence": "Manual grade: supported by the generated output for this run." + }, + { + "text": "The output recommends using timeout or caution for non-interactive/log-following contexts.", + "passed": true, + "evidence": "Manual grade: supported by the generated output for this run." + }, + { + "text": "The output gives a practical pipeline triage checklist.", + "passed": true, + "evidence": "Manual grade: supported by the generated output for this run." + } + ], + "notes": [ + "Manual sanity run: Claude CLI was present but not authenticated, so these are not independent subagent outputs.", + "Generated with-skill outputs inline and graded assertions manually." + ] + }, + { + "eval_id": 2, + "configuration": "with_skill", + "run_number": 1, + "result": { + "pass_rate": 1.0, + "passed": 6, + "failed": 0, + "total": 6, + "time_seconds": 0.0, + "tokens": 1542, + "tool_calls": 0, + "errors": 0 + }, + "expectations": [ + { + "text": "The output provides a Tangled workflow YAML under .tangled/workflows for a Rust service.", + "passed": true, + "evidence": "Manual grade: supported by the generated output for this run." + }, + { + "text": "The YAML uses engine: microvm and image: nixos.", + "passed": true, + "evidence": "Manual grade: supported by the generated output for this run." + }, + { + "text": "The YAML triggers on pushes to main and pull requests targeting main.", + "passed": true, + "evidence": "Manual grade: supported by the generated output for this run." + }, + { + "text": "The YAML includes Rust/OpenSSL-related dependencies such as cargo, rustc, pkg-config, and openssl.", + "passed": true, + "evidence": "Manual grade: supported by the generated output for this run." + }, + { + "text": "The YAML configures services.postgresql with the spindle-workflow database/user peer-auth pattern.", + "passed": true, + "evidence": "Manual grade: supported by the generated output for this run." + }, + { + "text": "The output avoids putting secrets in the public workflow YAML.", + "passed": true, + "evidence": "Manual grade: supported by the generated output for this run." + } + ], + "notes": [ + "Manual sanity run: Claude CLI was present but not authenticated, so these are not independent subagent outputs.", + "Generated with-skill outputs inline and graded assertions manually." + ] + } + ], + "run_summary": { + "with_skill": { + "pass_rate": { + "mean": 1.0, + "stddev": 0.0, + "min": 1.0, + "max": 1.0 + }, + "time_seconds": { + "mean": 0.0, + "stddev": 0.0, + "min": 0.0, + "max": 0.0 + }, + "tokens": { + "mean": 1939.3333, + "stddev": 547.6142, + "min": 1542, + "max": 2564 + } + }, + "delta": { + "pass_rate": "+1.00", + "time_seconds": "+0.0", + "tokens": "+1939" + } + }, + "notes": [] +} \ No newline at end of file diff --git a/.agents/skills/spindle-workspace/iteration-1/benchmark.md b/.agents/skills/spindle-workspace/iteration-1/benchmark.md new file mode 100644 index 0000000..6acb404 --- /dev/null +++ b/.agents/skills/spindle-workspace/iteration-1/benchmark.md @@ -0,0 +1,13 @@ +# Skill Benchmark: spindle + +**Model**: +**Date**: 2026-07-02T22:32:38Z +**Evals**: 0, 1, 2 (3 runs each per configuration) + +## Summary + +| Metric | With Skill | Config B | Delta | +|--------|------------|---------------|-------| +| Pass Rate | 100% ± 0% | 0% ± 0% | +1.00 | +| Time | 0.0s ± 0.0s | 0.0s ± 0.0s | +0.0s | +| Tokens | 1939 ± 548 | 0 ± 0 | +1939 | \ No newline at end of file diff --git a/.agents/skills/spindle-workspace/iteration-1/eval-0-review-existing-workflows/eval_metadata.json b/.agents/skills/spindle-workspace/iteration-1/eval-0-review-existing-workflows/eval_metadata.json new file mode 100644 index 0000000..619a0fd --- /dev/null +++ b/.agents/skills/spindle-workspace/iteration-1/eval-0-review-existing-workflows/eval_metadata.json @@ -0,0 +1,11 @@ +{ + "eval_id": 0, + "eval_name": "review-existing-workflows", + "prompt": "Please review our Tangled CI setup in .tangled/workflows. The build workflow is supposed to build a production Docker image and then run docker compose smoke tests on main and PRs. Tell me if the Spindle YAML looks right and suggest minimal fixes if not.", + "assertions": [ + "The output recognizes Tangled CI as Spindle and discusses .tangled/workflows.", + "The output references both build.yml and check.yml or their roles.", + "The output identifies engine: microvm, image: nixos, and Docker-in-microVM requirements for the build workflow.", + "The output preserves the repo conventions such as devenv shell and proposes minimal/focused fixes rather than a broad rewrite." + ] +} diff --git a/.agents/skills/spindle-workspace/iteration-1/eval-0-review-existing-workflows/with_skill/run-1/grading.json b/.agents/skills/spindle-workspace/iteration-1/eval-0-review-existing-workflows/with_skill/run-1/grading.json new file mode 100644 index 0000000..c348b20 --- /dev/null +++ b/.agents/skills/spindle-workspace/iteration-1/eval-0-review-existing-workflows/with_skill/run-1/grading.json @@ -0,0 +1,57 @@ +{ + "expectations": [ + { + "text": "The output recognizes Tangled CI as Spindle and discusses .tangled/workflows.", + "passed": true, + "evidence": "Manual grade: supported by the generated output for this run." + }, + { + "text": "The output references both build.yml and check.yml or their roles.", + "passed": true, + "evidence": "Manual grade: supported by the generated output for this run." + }, + { + "text": "The output identifies engine: microvm, image: nixos, and Docker-in-microVM requirements for the build workflow.", + "passed": true, + "evidence": "Manual grade: supported by the generated output for this run." + }, + { + "text": "The output preserves the repo conventions such as devenv shell and proposes minimal/focused fixes rather than a broad rewrite.", + "passed": true, + "evidence": "Manual grade: supported by the generated output for this run." + } + ], + "summary": { + "passed": 4, + "failed": 0, + "total": 4, + "pass_rate": 1.0 + }, + "execution_metrics": { + "tool_calls": { + "Read": 0, + "Write": 0, + "Bash": 0, + "Edit": 0 + }, + "total_tool_calls": 0, + "total_steps": 1, + "errors_encountered": 0, + "output_chars": 2564, + "transcript_chars": 0 + }, + "timing": { + "total_tokens": 0, + "duration_ms": 0, + "total_duration_seconds": 0.0 + }, + "user_notes_summary": { + "uncertainties": [ + "Manual sanity run: Claude CLI was present but not authenticated, so these are not independent subagent outputs." + ], + "needs_review": [], + "workarounds": [ + "Generated with-skill outputs inline and graded assertions manually." + ] + } +} diff --git a/.agents/skills/spindle-workspace/iteration-1/eval-0-review-existing-workflows/with_skill/run-1/outputs/metrics.json b/.agents/skills/spindle-workspace/iteration-1/eval-0-review-existing-workflows/with_skill/run-1/outputs/metrics.json new file mode 100644 index 0000000..7607482 --- /dev/null +++ b/.agents/skills/spindle-workspace/iteration-1/eval-0-review-existing-workflows/with_skill/run-1/outputs/metrics.json @@ -0,0 +1,13 @@ +{ + "tool_calls": { + "Read": 0, + "Write": 0, + "Bash": 0, + "Edit": 0 + }, + "total_tool_calls": 0, + "total_steps": 1, + "errors_encountered": 0, + "output_chars": 2564, + "transcript_chars": 0 +} diff --git a/.agents/skills/spindle-workspace/iteration-1/eval-0-review-existing-workflows/with_skill/run-1/outputs/review.md b/.agents/skills/spindle-workspace/iteration-1/eval-0-review-existing-workflows/with_skill/run-1/outputs/review.md new file mode 100644 index 0000000..f118d37 --- /dev/null +++ b/.agents/skills/spindle-workspace/iteration-1/eval-0-review-existing-workflows/with_skill/run-1/outputs/review.md @@ -0,0 +1,64 @@ +# Review: current Spindle workflows + +Tangled CI is Spindle, and this repository already has two Spindle workflow files under `.tangled/workflows/`: + +- `.tangled/workflows/check.yml` — runs Dagger checks. +- `.tangled/workflows/build.yml` — builds the production Docker image and runs Docker Compose smoke tests. + +## What looks right + +Both workflows use the expected Spindle shape: + +```yaml +when: + - event: ["push", "manual"] + branch: ["main"] + - event: ["pull_request"] + branch: ["main"] +engine: microvm +image: nixos +clone: + skip: false + depth: 1 +dependencies: + - devenv +``` + +That matches the repo goal: run on pushes to `main`, manual runs, and PRs targeting `main`, using a NixOS microVM with `devenv` available. + +For `.tangled/workflows/build.yml`, this is also correct for Docker-based smoke tests: + +```yaml +virtualisation: + docker: true +``` + +That enables a real Docker daemon inside the microVM, which is what the `docker-buildx` and `docker-compose` steps need. + +## Minimal fixes / checks I would consider + +1. **Keep `virtualisation.docker: true` in `build.yml`.** It is required for the production image build and Compose smoke test. + +2. **Keep the existing `devenv shell -- bash -c '...'` wrapping.** This repo provides `docker-buildx`, `docker-compose`, and Dagger through `devenv`, so the current convention is appropriate. + +3. **Consider adding `set -euo pipefail` to `check.yml`.** The current command is short: + + ```yaml + command: DAGGER_NO_NAG=1 devenv shell dagger check + ``` + + It is probably fine as-is, but if it grows into a multi-line script later, use: + + ```yaml + command: | + set -euo pipefail + DAGGER_NO_NAG=1 devenv shell dagger check + ``` + +4. **Verify the production Compose step really uses the image loaded by the previous step.** The build step correctly uses `docker-buildx build --load --target frankenphp_prod --tag app-php-prod .`. The later Compose command uses `--no-build`, so `compose.prod.yaml` must reference `app-php-prod` exactly. If the smoke test fails with “image not found”, this tag/reference is the first thing to check. + +5. **If CI is slow during `devenv shell true`, prefer cache work over YAML rewrites.** The current microVM/NixOS setup is structurally right; slow setup is more likely a Nix/cache issue than a Spindle YAML issue. + +## Verdict + +The Spindle YAML looks broadly correct. I would not rewrite the workflow. The most important parts for the build workflow are already present: `engine: microvm`, `image: nixos`, `dependencies: [devenv]`, and `virtualisation.docker: true`. diff --git a/.agents/skills/spindle-workspace/iteration-1/eval-0-review-existing-workflows/with_skill/run-1/timing.json b/.agents/skills/spindle-workspace/iteration-1/eval-0-review-existing-workflows/with_skill/run-1/timing.json new file mode 100644 index 0000000..a955179 --- /dev/null +++ b/.agents/skills/spindle-workspace/iteration-1/eval-0-review-existing-workflows/with_skill/run-1/timing.json @@ -0,0 +1,6 @@ +{ + "total_tokens": 0, + "duration_ms": 0, + "total_duration_seconds": 0.0, + "note": "Manual run; no subagent timing available because claude CLI auth failed." +} diff --git a/.agents/skills/spindle-workspace/iteration-1/eval-1-debug-push-log-command/eval_metadata.json b/.agents/skills/spindle-workspace/iteration-1/eval-1-debug-push-log-command/eval_metadata.json new file mode 100644 index 0000000..7adc3ee --- /dev/null +++ b/.agents/skills/spindle-workspace/iteration-1/eval-1-debug-push-log-command/eval_metadata.json @@ -0,0 +1,11 @@ +{ + "eval_id": 1, + "eval_name": "debug-push-log-command", + "prompt": "git push printed this: remote: \u2192 Browse CI logs in your terminal: remote: ssh -t -p 3333 tangled.org did:plc:limfqbuucqmoafpeuc7diz6c 8f22c8f46d9b1b3b17ee4bf8138c38631c4cccf8. What should I do to debug the failed pipeline?", + "assertions": [ + "The output recognizes the SSH command as the Spindle/Tangled CI terminal log viewer.", + "The output tells the user to run the exact ssh -t -p 3333 tangled.org command.", + "The output recommends using timeout or caution for non-interactive/log-following contexts.", + "The output gives a practical pipeline triage checklist." + ] +} diff --git a/.agents/skills/spindle-workspace/iteration-1/eval-1-debug-push-log-command/with_skill/run-1/grading.json b/.agents/skills/spindle-workspace/iteration-1/eval-1-debug-push-log-command/with_skill/run-1/grading.json new file mode 100644 index 0000000..1c98800 --- /dev/null +++ b/.agents/skills/spindle-workspace/iteration-1/eval-1-debug-push-log-command/with_skill/run-1/grading.json @@ -0,0 +1,57 @@ +{ + "expectations": [ + { + "text": "The output recognizes the SSH command as the Spindle/Tangled CI terminal log viewer.", + "passed": true, + "evidence": "Manual grade: supported by the generated output for this run." + }, + { + "text": "The output tells the user to run the exact ssh -t -p 3333 tangled.org command.", + "passed": true, + "evidence": "Manual grade: supported by the generated output for this run." + }, + { + "text": "The output recommends using timeout or caution for non-interactive/log-following contexts.", + "passed": true, + "evidence": "Manual grade: supported by the generated output for this run." + }, + { + "text": "The output gives a practical pipeline triage checklist.", + "passed": true, + "evidence": "Manual grade: supported by the generated output for this run." + } + ], + "summary": { + "passed": 4, + "failed": 0, + "total": 4, + "pass_rate": 1.0 + }, + "execution_metrics": { + "tool_calls": { + "Read": 0, + "Write": 0, + "Bash": 0, + "Edit": 0 + }, + "total_tool_calls": 0, + "total_steps": 1, + "errors_encountered": 0, + "output_chars": 1712, + "transcript_chars": 0 + }, + "timing": { + "total_tokens": 0, + "duration_ms": 0, + "total_duration_seconds": 0.0 + }, + "user_notes_summary": { + "uncertainties": [ + "Manual sanity run: Claude CLI was present but not authenticated, so these are not independent subagent outputs." + ], + "needs_review": [], + "workarounds": [ + "Generated with-skill outputs inline and graded assertions manually." + ] + } +} diff --git a/.agents/skills/spindle-workspace/iteration-1/eval-1-debug-push-log-command/with_skill/run-1/outputs/debugging.md b/.agents/skills/spindle-workspace/iteration-1/eval-1-debug-push-log-command/with_skill/run-1/outputs/debugging.md new file mode 100644 index 0000000..8061027 --- /dev/null +++ b/.agents/skills/spindle-workspace/iteration-1/eval-1-debug-push-log-command/with_skill/run-1/outputs/debugging.md @@ -0,0 +1,49 @@ +# Debugging the failed Spindle pipeline + +That `remote:` hint is Tangled's terminal log viewer for Spindle, the Tangled CI system. + +Run the exact command it printed: + +```bash +ssh -t -p 3333 tangled.org did:plc:limfqbuucqmoafpeuc7diz6c 8f22c8f46d9b1b3b17ee4bf8138c38631c4cccf8 +``` + +If you are running it from a script or another non-interactive tool, wrap it with a timeout so it does not follow logs forever: + +```bash +timeout 180 ssh -t -p 3333 tangled.org did:plc:limfqbuucqmoafpeuc7diz6c 8f22c8f46d9b1b3b17ee4bf8138c38631c4cccf8 +``` + +## What to look for in the logs + +1. **Which workflow ran** + - Match the failing run to `.tangled/workflows/check.yml` or `.tangled/workflows/build.yml`. + - Confirm whether the trigger was `push`, `pull_request`, or `manual`. + +2. **Where it failed** + - Clone/setup failure + - NixOS microVM activation / dependency realization + - `devenv shell true` + - Dagger check + - Docker buildx production image build + - Docker Compose smoke test + +3. **If Docker fails** + - Confirm the workflow has: + ```yaml + engine: microvm + image: nixos + virtualisation: + docker: true + ``` + - Check whether the image was built with `--load` before Compose uses `--no-build`. + +4. **If a tool is missing** + - In this repo, tools should normally come from `devenv shell ...`. + - If the missing tool is needed before `devenv` is available, add it to Spindle `dependencies`. + +5. **If setup is just slow** + - Look for Nix/devenv cache misses. + - Prefer fixing Cachix/Nix caching rather than rewriting the Spindle workflow. + +After you capture the failing step and error text, paste that log excerpt back and we can make the smallest workflow or project fix. diff --git a/.agents/skills/spindle-workspace/iteration-1/eval-1-debug-push-log-command/with_skill/run-1/outputs/metrics.json b/.agents/skills/spindle-workspace/iteration-1/eval-1-debug-push-log-command/with_skill/run-1/outputs/metrics.json new file mode 100644 index 0000000..ceb5612 --- /dev/null +++ b/.agents/skills/spindle-workspace/iteration-1/eval-1-debug-push-log-command/with_skill/run-1/outputs/metrics.json @@ -0,0 +1,13 @@ +{ + "tool_calls": { + "Read": 0, + "Write": 0, + "Bash": 0, + "Edit": 0 + }, + "total_tool_calls": 0, + "total_steps": 1, + "errors_encountered": 0, + "output_chars": 1712, + "transcript_chars": 0 +} diff --git a/.agents/skills/spindle-workspace/iteration-1/eval-1-debug-push-log-command/with_skill/run-1/timing.json b/.agents/skills/spindle-workspace/iteration-1/eval-1-debug-push-log-command/with_skill/run-1/timing.json new file mode 100644 index 0000000..a955179 --- /dev/null +++ b/.agents/skills/spindle-workspace/iteration-1/eval-1-debug-push-log-command/with_skill/run-1/timing.json @@ -0,0 +1,6 @@ +{ + "total_tokens": 0, + "duration_ms": 0, + "total_duration_seconds": 0.0, + "note": "Manual run; no subagent timing available because claude CLI auth failed." +} diff --git a/.agents/skills/spindle-workspace/iteration-1/eval-2-create-rust-postgres-workflow/eval_metadata.json b/.agents/skills/spindle-workspace/iteration-1/eval-2-create-rust-postgres-workflow/eval_metadata.json new file mode 100644 index 0000000..3a238c1 --- /dev/null +++ b/.agents/skills/spindle-workspace/iteration-1/eval-2-create-rust-postgres-workflow/eval_metadata.json @@ -0,0 +1,13 @@ +{ + "eval_id": 2, + "eval_name": "create-rust-postgres-workflow", + "prompt": "Add a new Tangled workflow for a Rust service that needs Postgres and OpenSSL. It should run on PRs to main and pushes to main, use the microvm engine, and keep secrets out of the YAML.", + "assertions": [ + "The output provides a Tangled workflow YAML under .tangled/workflows for a Rust service.", + "The YAML uses engine: microvm and image: nixos.", + "The YAML triggers on pushes to main and pull requests targeting main.", + "The YAML includes Rust/OpenSSL-related dependencies such as cargo, rustc, pkg-config, and openssl.", + "The YAML configures services.postgresql with the spindle-workflow database/user peer-auth pattern.", + "The output avoids putting secrets in the public workflow YAML." + ] +} diff --git a/.agents/skills/spindle-workspace/iteration-1/eval-2-create-rust-postgres-workflow/with_skill/run-1/grading.json b/.agents/skills/spindle-workspace/iteration-1/eval-2-create-rust-postgres-workflow/with_skill/run-1/grading.json new file mode 100644 index 0000000..49b2351 --- /dev/null +++ b/.agents/skills/spindle-workspace/iteration-1/eval-2-create-rust-postgres-workflow/with_skill/run-1/grading.json @@ -0,0 +1,67 @@ +{ + "expectations": [ + { + "text": "The output provides a Tangled workflow YAML under .tangled/workflows for a Rust service.", + "passed": true, + "evidence": "Manual grade: supported by the generated output for this run." + }, + { + "text": "The YAML uses engine: microvm and image: nixos.", + "passed": true, + "evidence": "Manual grade: supported by the generated output for this run." + }, + { + "text": "The YAML triggers on pushes to main and pull requests targeting main.", + "passed": true, + "evidence": "Manual grade: supported by the generated output for this run." + }, + { + "text": "The YAML includes Rust/OpenSSL-related dependencies such as cargo, rustc, pkg-config, and openssl.", + "passed": true, + "evidence": "Manual grade: supported by the generated output for this run." + }, + { + "text": "The YAML configures services.postgresql with the spindle-workflow database/user peer-auth pattern.", + "passed": true, + "evidence": "Manual grade: supported by the generated output for this run." + }, + { + "text": "The output avoids putting secrets in the public workflow YAML.", + "passed": true, + "evidence": "Manual grade: supported by the generated output for this run." + } + ], + "summary": { + "passed": 6, + "failed": 0, + "total": 6, + "pass_rate": 1.0 + }, + "execution_metrics": { + "tool_calls": { + "Read": 0, + "Write": 0, + "Bash": 0, + "Edit": 0 + }, + "total_tool_calls": 0, + "total_steps": 1, + "errors_encountered": 0, + "output_chars": 1542, + "transcript_chars": 0 + }, + "timing": { + "total_tokens": 0, + "duration_ms": 0, + "total_duration_seconds": 0.0 + }, + "user_notes_summary": { + "uncertainties": [ + "Manual sanity run: Claude CLI was present but not authenticated, so these are not independent subagent outputs." + ], + "needs_review": [], + "workarounds": [ + "Generated with-skill outputs inline and graded assertions manually." + ] + } +} diff --git a/.agents/skills/spindle-workspace/iteration-1/eval-2-create-rust-postgres-workflow/with_skill/run-1/outputs/metrics.json b/.agents/skills/spindle-workspace/iteration-1/eval-2-create-rust-postgres-workflow/with_skill/run-1/outputs/metrics.json new file mode 100644 index 0000000..4ce052a --- /dev/null +++ b/.agents/skills/spindle-workspace/iteration-1/eval-2-create-rust-postgres-workflow/with_skill/run-1/outputs/metrics.json @@ -0,0 +1,13 @@ +{ + "tool_calls": { + "Read": 0, + "Write": 0, + "Bash": 0, + "Edit": 0 + }, + "total_tool_calls": 0, + "total_steps": 1, + "errors_encountered": 0, + "output_chars": 1542, + "transcript_chars": 0 +} diff --git a/.agents/skills/spindle-workspace/iteration-1/eval-2-create-rust-postgres-workflow/with_skill/run-1/outputs/notes.md b/.agents/skills/spindle-workspace/iteration-1/eval-2-create-rust-postgres-workflow/with_skill/run-1/outputs/notes.md new file mode 100644 index 0000000..564344b --- /dev/null +++ b/.agents/skills/spindle-workspace/iteration-1/eval-2-create-rust-postgres-workflow/with_skill/run-1/outputs/notes.md @@ -0,0 +1,7 @@ +# Notes + +Save the YAML as `.tangled/workflows/rust.yml` if this repository has a Rust service to test. + +Secrets are intentionally not included in the workflow file. Add secrets through Tangled repository Settings → Secrets, then reference those environment variables from step commands if needed. + +The PostgreSQL setup uses the `spindle-workflow` user/database pattern because Spindle steps run as the `spindle-workflow` Unix user. With Postgres over `/run/postgresql`, peer auth works without putting a database password in CI YAML. diff --git a/.agents/skills/spindle-workspace/iteration-1/eval-2-create-rust-postgres-workflow/with_skill/run-1/outputs/rust-postgres.yml b/.agents/skills/spindle-workspace/iteration-1/eval-2-create-rust-postgres-workflow/with_skill/run-1/outputs/rust-postgres.yml new file mode 100644 index 0000000..a95f274 --- /dev/null +++ b/.agents/skills/spindle-workspace/iteration-1/eval-2-create-rust-postgres-workflow/with_skill/run-1/outputs/rust-postgres.yml @@ -0,0 +1,52 @@ +# .tangled/workflows/rust.yml +when: + - event: ["push"] + branch: ["main"] + - event: ["pull_request"] + branch: ["main"] + +engine: microvm +image: nixos + +clone: + skip: false + depth: 1 + +# Public, non-secret configuration only. Put secrets in Tangled repository +# Settings → Secrets and read them from commands when needed. +environment: + DATABASE_URL: "postgresql:///spindle-workflow?host=/run/postgresql" + +# Flat dependency list for the NixOS microVM image. +dependencies: + - gcc + - cargo + - rustc + - clippy + - rustfmt + - pkg-config + - openssl + +services: + postgresql: + enable: true + ensureDatabases: ["spindle-workflow"] + ensureUsers: + - name: spindle-workflow + ensureDBOwnership: true + +steps: + - name: "Check formatting" + command: | + set -euo pipefail + cargo fmt --check + + - name: "Clippy" + command: | + set -euo pipefail + cargo clippy --all-targets -- -D warnings + + - name: "Test" + command: | + set -euo pipefail + cargo test --all diff --git a/.agents/skills/spindle-workspace/iteration-1/eval-2-create-rust-postgres-workflow/with_skill/run-1/timing.json b/.agents/skills/spindle-workspace/iteration-1/eval-2-create-rust-postgres-workflow/with_skill/run-1/timing.json new file mode 100644 index 0000000..a955179 --- /dev/null +++ b/.agents/skills/spindle-workspace/iteration-1/eval-2-create-rust-postgres-workflow/with_skill/run-1/timing.json @@ -0,0 +1,6 @@ +{ + "total_tokens": 0, + "duration_ms": 0, + "total_duration_seconds": 0.0, + "note": "Manual run; no subagent timing available because claude CLI auth failed." +} diff --git a/.agents/skills/spindle-workspace/iteration-1/review.html b/.agents/skills/spindle-workspace/iteration-1/review.html new file mode 100644 index 0000000..a43379d --- /dev/null +++ b/.agents/skills/spindle-workspace/iteration-1/review.html @@ -0,0 +1,1325 @@ + + + + + + Eval Review + + + + + + + +
+
+
+

Eval Review:

+
Review each output and leave feedback below. Navigate with arrow keys or buttons. When done, copy feedback and paste into Claude Code.
+
+
+
+ + + + + +
+
+ +
+
Prompt
+
+
+
+
+ + +
+
Output
+
+
No output files found
+
+
+ + + + + + + + +
+
Your Feedback
+
+ + + +
+
+
+ + +
+ + +
+
+
No benchmark data available. Run a benchmark to see quantitative results here.
+
+
+
+ + +
+
+

Review Complete

+

Your feedback has been saved. Go back to your Claude Code session and tell Claude you're done reviewing.

+
+ +
+
+
+ + +
+ + + + diff --git a/.agents/skills/spindle/SKILL.md b/.agents/skills/spindle/SKILL.md new file mode 100644 index 0000000..7685303 --- /dev/null +++ b/.agents/skills/spindle/SKILL.md @@ -0,0 +1,192 @@ +--- +name: spindle +description: | + REQUIRED when the user asks about Tangled CI, Spindle, pipelines, workflow runs, + CI logs from git push, `ssh -t -p 3333 tangled.org ...`, or files under + `.tangled/workflows/`. Use this skill for creating, editing, debugging, or + reviewing Tangled Spindle workflow YAML, especially `engine: microvm`, NixOS + images, `dependencies`, `services`, `virtualisation.docker`, secrets, and CI + trigger behavior. Also use it when the user says "Tangled CI" because the CI + runner/pipeline system is named Spindle. +--- + +# Spindle — Tangled CI workflow guidance + +Spindle is Tangled's CI/pipeline runner. In this repo, Spindle workflows live in +`.tangled/workflows/*.yml` or `.yaml`. + +## First moves + +1. Inspect existing workflow files before proposing changes: + ```bash + find .tangled/workflows -maxdepth 1 -type f -print | sort + ``` +2. Read the specific workflow(s) involved. Preserve local conventions: file + names, branch filters, `devenv shell`, Docker Compose usage, Dagger usage, and + step naming style. +3. If the task involves Tangled repo operations (issues, PRs, repo view), also + use the `tang` skill. If it involves Cachix/Nix binary cache speed in this + repo, also use the `cachix` skill. +4. Treat CI changes as potentially expensive: do not run `git push` or manually + trigger remote workflows unless the user asks or approves. + +## Workflow YAML essentials + +Common top-level fields: + +```yaml +when: + - event: ["push", "manual"] + branch: ["main"] + - event: ["pull_request"] + branch: ["main"] + +engine: microvm +clone: + skip: false + depth: 1 + +environment: + NODE_ENV: production + +steps: + - name: "Step name" + command: | + set -euo pipefail + command here +``` + +Trigger notes: +- `push` requires `branch` and/or `tag`. +- `pull_request` `branch` means target branch. +- `manual` ignores `branch`, but it can share a condition with `push`. +- Branch/tag patterns support `*` and `**`. + +Environment notes: +- Do not put secrets in `environment`; workflow YAML is public. Use Tangled + repository Settings → Secrets and reference those variables from commands. +- Prefer `set -euo pipefail` inside multi-line shell commands for reliable CI + failure behavior. + +## Engine selection + +Prefer `engine: microvm` for new work unless the repository already uses and +wants to keep `nixery`. + +### microVM engine + +Typical NixOS microVM workflow: + +```yaml +engine: microvm +image: nixos + +dependencies: + - devenv + - docker-client + +virtualisation: + docker: true + +steps: + - name: "Initialize devenv" + command: devenv shell true +``` + +MicroVM details: +- `image: nixos` enables NixOS-level workflow config: `dependencies`, + `registry`, `caches`, `services`, and `virtualisation`. +- `dependencies` is a flat list. Bare names come from nixpkgs. Flake attrs can + use `flakeref#attr`. +- `registry` can pin/alias flakes, e.g. `nixpkgs: github:nixos/nixpkgs/nixos-unstable`. +- `caches` maps binary cache URL to trusted public key. +- `services` and `virtualisation` are passed through to NixOS. `true` is shorthand + for `.enable = true` when an enable option exists. +- Use `virtualisation: { docker: true }` when workflow steps need a real Docker + daemon, Docker Compose, or `docker buildx`. +- `image: alpine` is useful for small checks, but NixOS-specific fields do not + apply there; install packages in steps with Alpine's package manager instead. + +PostgreSQL service pattern: + +```yaml +environment: + DATABASE_URL: "postgresql:///spindle-workflow?host=/run/postgresql" +services: + postgresql: + enable: true + ensureDatabases: ["spindle-workflow"] + ensureUsers: + - name: spindle-workflow + ensureDBOwnership: true +``` + +The workflow user is `spindle-workflow`; naming the database/user this way makes +Postgres peer auth work over the local socket. + +### nixery engine + +Legacy Nixery workflows use a dependency map rather than the microVM flat list: + +```yaml +engine: nixery +dependencies: + nixpkgs: + - nodejs + - go +``` + +When converting Nixery to microVM, change `engine: microvm`, choose `image`, and +convert dependencies to the microVM form. + +## Debugging Spindle runs + +When `git push` prints a remote hint like: + +```text +remote: → Browse CI logs in your terminal: +remote: ssh -t -p 3333 tangled.org DID PIPELINE_OR_SHA +``` + +use the exact SSH command shown by the remote when the user wants terminal logs. +It may attach to a live run, so use a timeout in non-interactive tool contexts, +e.g.: + +```bash +timeout 180 ssh -t -p 3333 tangled.org +``` + +Debug checklist: +1. Identify which workflow file ran and which trigger matched. +2. Check whether the failure happened during clone, microVM/NixOS activation, + dependency realization, service startup, or a named step. +3. For command failures, reproduce locally with the same working directory and + environment shape when possible (`devenv shell ...` in this repo). +4. For Docker failures in microVM, verify `virtualisation.docker: true` and that + the step waits for/builds the right image (`--load` if later Compose steps use + the local Docker daemon). +5. For missing tools, add them to microVM `dependencies` or install them in the + step for non-NixOS images. +6. For slow Nix setup, prefer cache configuration and consult the repo's Cachix + workflow guidance before changing Nix/devenv files. + +## RSSBase conventions observed + +Current repo workflows use: +- `.tangled/workflows/check.yml` for Dagger checks via `devenv shell dagger check`. +- `.tangled/workflows/build.yml` for production Docker/Compose smoke tests. +- `engine: microvm`, `image: nixos`, `dependencies: [devenv]`, shallow clone, + and `virtualisation.docker: true` for production image builds. + +Keep commands wrapped in `devenv shell -- bash -c '...'` when the step depends on +project-provided tools or environment. Prefer fixing the smallest workflow file +necessary rather than reshaping the whole CI setup. + +## Reference docs + +Primary docs: +- https://docs.tangled.org/spindles +- https://blog.tangled.org/spindle-microvm/ + +Read `references/spindles-quick-reference.md` for a condensed local reference if +network access is unavailable or you need examples without loading the full docs. diff --git a/.agents/skills/spindle/evals/evals.json b/.agents/skills/spindle/evals/evals.json new file mode 100644 index 0000000..8311172 --- /dev/null +++ b/.agents/skills/spindle/evals/evals.json @@ -0,0 +1,26 @@ +{ + "skill_name": "spindle", + "evals": [ + { + "id": 0, + "prompt": "Please review our Tangled CI setup in .tangled/workflows. The build workflow is supposed to build a production Docker image and then run docker compose smoke tests on main and PRs. Tell me if the Spindle YAML looks right and suggest minimal fixes if not.", + "expected_output": "Inspects .tangled/workflows, recognizes Spindle/Tangled CI, explains microvm+nixos+docker requirements, preserves repo conventions, and proposes focused workflow fixes rather than broad rewrites.", + "files": [ + ".tangled/workflows/build.yml", + ".tangled/workflows/check.yml" + ] + }, + { + "id": 1, + "prompt": "git push printed this: remote: → Browse CI logs in your terminal: remote: ssh -t -p 3333 tangled.org did:plc:limfqbuucqmoafpeuc7diz6c 8f22c8f46d9b1b3b17ee4bf8138c38631c4cccf8. What should I do to debug the failed pipeline?", + "expected_output": "Recognizes the command as Spindle terminal logs, advises using the exact ssh command (with timeout if non-interactive), then gives a practical CI failure triage checklist.", + "files": [] + }, + { + "id": 2, + "prompt": "Add a new Tangled workflow for a Rust service that needs Postgres and OpenSSL. It should run on PRs to main and pushes to main, use the microvm engine, and keep secrets out of the YAML.", + "expected_output": "Creates or proposes a .tangled/workflows YAML using engine: microvm, image: nixos, flat dependencies, services.postgresql with spindle-workflow peer auth, DATABASE_URL over /run/postgresql, and no secrets in public environment.", + "files": [] + } + ] +} diff --git a/.agents/skills/spindle/references/spindles-quick-reference.md b/.agents/skills/spindle/references/spindles-quick-reference.md new file mode 100644 index 0000000..1831b48 --- /dev/null +++ b/.agents/skills/spindle/references/spindles-quick-reference.md @@ -0,0 +1,133 @@ +# Spindle quick reference + +Source material: and +. + +## What Spindle is + +Spindle is Tangled's self-hostable CI/pipeline runner. Workflows are YAML files in +`.tangled/workflows/`. + +## Common workflow fields + +- `when`: required trigger list. + - `event`: one or more of `push`, `pull_request`, `manual`. + - `branch`: branch patterns for push, or PR target branch patterns. + - `tag`: tag patterns for push. +- `engine`: required, currently `nixery` or `microvm`. +- `clone`: optional clone config: `skip`, `depth`, `submodules`. +- `environment`: optional public env vars for the workflow. Do not put secrets here. +- `steps`: optional list of `{name, command, environment}`. Commands run in Bash. + +Default CI env includes `CI=true`, `TANGLED_PIPELINE_ID`, `TANGLED_PIPELINE_KIND`, +repo metadata, and push/PR-specific ref variables such as `TANGLED_REF_NAME`, +`TANGLED_SHA`, and `TANGLED_PR_TARGET_BRANCH`. + +## microVM engine + +`engine: microvm` runs each workflow in a dedicated microVM. + +Top-level fields: +- `image`: example values `nixos` and `alpine`; availability depends on the + Spindle operator. +- `dependencies`: for NixOS images, a flat list of tools available in every step. +- `registry`: remaps flake references, similar to `nix registry`. +- `caches`: Nix binary cache URL to trusted public key. +- `services`: NixOS `services.*` config, brought up before steps run. +- `virtualisation`: NixOS `virtualisation.*` config, e.g. Docker. + +Bare dependency names resolve from nixpkgs. Flake attrs can be referenced as +`github:nixos/nixpkgs#hello` or via aliases in `registry`. + +`true` works as shorthand for `.enable = true` anywhere an enable option exists, +so `virtualisation: { docker: true }` enables Docker. + +## Examples + +### Basic Node workflow + +```yaml +when: + - event: ["push", "pull_request"] + branch: ["main"] +engine: microvm +image: nixos +dependencies: + - pnpm +steps: + - name: "Install dependencies" + command: pnpm install --frozen-lockfile + - name: "Lint and test" + command: | + pnpm run lint + pnpm test + - name: "Build" + command: pnpm run build +``` + +### Rust with OpenSSL + +```yaml +engine: microvm +image: nixos +dependencies: + - gcc + - cargo + - rustc + - clippy + - rustfmt + - pkg-config + - openssl +steps: + - name: "Check formatting" + command: cargo fmt --check + - name: "Clippy" + command: cargo clippy --all-targets -- -D warnings + - name: "Test" + command: cargo test --all +``` + +### PostgreSQL service + +```yaml +environment: + DATABASE_URL: "postgresql:///spindle-workflow?host=/run/postgresql" +dependencies: + - sqlx-cli +services: + postgresql: + enable: true + ensureDatabases: ["spindle-workflow"] + ensureUsers: + - name: spindle-workflow + ensureDBOwnership: true +steps: + - name: "Run migrations" + command: sqlx migrate run +``` + +### Docker inside microVM + +```yaml +engine: microvm +image: nixos +virtualisation: + docker: true +steps: + - name: "Build image" + command: | + set -euo pipefail + docker build -t app . +``` + +## Terminal logs + +After a push, Tangled may print: + +```text +remote: → Browse CI logs in your terminal: +remote: ssh -t -p 3333 tangled.org +``` + +Run the exact command to view logs. In automated/non-interactive contexts, wrap +it with `timeout` so the command does not hang indefinitely. diff --git a/.agents/skills/symfony-brainstorming/SKILL.md b/.agents/skills/symfony-brainstorming/SKILL.md new file mode 100644 index 0000000..4a66cac --- /dev/null +++ b/.agents/skills/symfony-brainstorming/SKILL.md @@ -0,0 +1,38 @@ +--- + +name: symfony-brainstorming +allowed-tools: + - Read + - Glob + - Grep +description: Structured brainstorming for Symfony projects - explore requirements, identify components, and plan architecture collaboratively +--- + +# Brainstorming (Symfony) + +## Use when +- Refining architecture/workflows/context handling in Symfony projects. +- Planning and executing medium/complex changes safely. + +## Default workflow +1. Establish current boundaries, constraints, and coupling points. +2. Propose smallest coherent architectural adjustment. +3. Execute in checkpoints with validation at each stage. +4. Summarize tradeoffs and follow-up backlog. + +## Guardrails +- Use existing project patterns by default. +- Avoid broad refactors without explicit need. +- Keep decision log clear and auditable. + +## Progressive disclosure +- Use this file for execution posture and risk controls. +- Open references when deep implementation details are needed. + +## Output contract +- Architecture/workflow changes. +- Checkpoint validation outcomes. +- Residual risks and next steps. + +## References +- `docs/complexity-tiers.md` diff --git a/.agents/skills/symfony-config-env-parameters/SKILL.md b/.agents/skills/symfony-config-env-parameters/SKILL.md new file mode 100644 index 0000000..45478ff --- /dev/null +++ b/.agents/skills/symfony-config-env-parameters/SKILL.md @@ -0,0 +1,42 @@ +--- + +name: symfony-config-env-parameters +allowed-tools: + - Read + - Write + - Edit + - Bash + - Glob + - Grep +description: Manage Symfony configuration with .env files, parameters, secrets vault, and environment-specific settings +--- + +# Config Env Parameters (Symfony) + +## Use when +- Refining architecture/workflows/context handling in Symfony projects. +- Planning and executing medium/complex changes safely. + +## Default workflow +1. Establish current boundaries, constraints, and coupling points. +2. Propose smallest coherent architectural adjustment. +3. Execute in checkpoints with validation at each stage. +4. Summarize tradeoffs and follow-up backlog. + +## Guardrails +- Use existing project patterns by default. +- Avoid broad refactors without explicit need. +- Keep decision log clear and auditable. + +## Progressive disclosure +- Use this file for execution posture and risk controls. +- Open references when deep implementation details are needed. + +## Output contract +- Architecture/workflow changes. +- Checkpoint validation outcomes. +- Residual risks and next steps. + +## References +- `reference.md` +- `docs/complexity-tiers.md` diff --git a/.agents/skills/symfony-config-env-parameters/reference.md b/.agents/skills/symfony-config-env-parameters/reference.md new file mode 100644 index 0000000..7994658 --- /dev/null +++ b/.agents/skills/symfony-config-env-parameters/reference.md @@ -0,0 +1,373 @@ +# Reference + +# Configuration and Environment Management + +## Environment Files + +### File Hierarchy + +``` +.env # Default values (committed) +.env.local # Local overrides (not committed) +.env.test # Test environment defaults +.env.test.local # Local test overrides (not committed) +.env.prod # Production defaults +.env.prod.local # Production local overrides +``` + +### Loading Order + +``` +1. .env +2. .env.local (not in test) +3. .env.{APP_ENV} +4. .env.{APP_ENV}.local +``` + +Later files override earlier ones. + +### .env Syntax + +```bash +# .env + +# App configuration +APP_ENV=dev +APP_DEBUG=true +APP_SECRET=change-this-in-production + +# Database +DATABASE_URL="postgresql://user:pass@localhost:5432/myapp?serverVersion=15" + +# Mailer +MAILER_DSN=smtp://localhost:1025 + +# Third-party APIs +STRIPE_API_KEY=sk_test_xxx +AWS_ACCESS_KEY_ID=xxx +AWS_SECRET_ACCESS_KEY=xxx + +# Feature flags +FEATURE_NEW_CHECKOUT=false +``` + +### Type Casting + +```bash +# Boolean +FEATURE_ENABLED=true # string "true" + +# In PHP, compare as string or cast +$enabled = $_ENV['FEATURE_ENABLED'] === 'true'; + +# Or use filter_var +$enabled = filter_var($_ENV['FEATURE_ENABLED'], FILTER_VALIDATE_BOOLEAN); +``` + +## Parameters + +### Define in services.yaml + +```yaml +# config/services.yaml +parameters: + app.admin_email: 'admin@example.com' + app.items_per_page: 20 + app.supported_locales: ['en', 'fr', 'de'] + + # Using environment variables + app.database_url: '%env(DATABASE_URL)%' + app.stripe_key: '%env(STRIPE_API_KEY)%' + + # Type casting + app.port: '%env(int:APP_PORT)%' + app.debug: '%env(bool:APP_DEBUG)%' + app.hosts: '%env(json:ALLOWED_HOSTS)%' +``` + +### Environment Variable Processors + +```yaml +parameters: + # Cast to int + port: '%env(int:PORT)%' + + # Cast to bool + debug: '%env(bool:DEBUG)%' + + # Cast to float + rate: '%env(float:TAX_RATE)%' + + # Parse JSON + config: '%env(json:CONFIG_JSON)%' + + # Parse CSV + hosts: '%env(csv:ALLOWED_HOSTS)%' + + # Base64 decode + secret: '%env(base64:ENCODED_SECRET)%' + + # Read from file + cert: '%env(file:SSL_CERT_PATH)%' + + # Resolve env var name from another env var + dsn: '%env(resolve:DATABASE_DSN)%' + + # Default value + port: '%env(default:3000:PORT)%' + + # Chained processors + config: '%env(json:file:CONFIG_PATH)%' +``` + +### Use in Services + +```php +newCheckoutEnabled) { + return $this->newCheckoutFlow(); + } + return $this->legacyCheckoutFlow(); + } +} +``` + +## Best Practices + +### .env.local for Local Development + +```bash +# .env.local (not committed) +DATABASE_URL="postgresql://dev:dev@localhost:5432/myapp_dev" +MAILER_DSN=smtp://localhost:1025 +``` + +### Production Environment Variables + +```bash +# Set via server/container environment, not files +export APP_ENV=prod +export APP_SECRET=your-production-secret +export DATABASE_URL="postgresql://prod:xxx@db.server:5432/myapp" +``` + +### Validation in Services + +```php +class StripeService +{ + public function __construct( + #[Autowire('%env(STRIPE_API_KEY)%')] + private string $apiKey, + ) { + if (empty($this->apiKey)) { + throw new \RuntimeException('STRIPE_API_KEY is required'); + } + } +} +``` + +### Don't Commit Sensitive Data + +```gitignore +# .gitignore +.env.local +.env.*.local +config/secrets/*/decrypt.private.php +``` + +## Assets — AssetMapper (best practice) + +The recommended way to ship frontend assets is **AssetMapper** (no Node bundler, +uses native ESM + importmaps). It is the default in modern Symfony. + +```bash +composer require symfony/asset-mapper +php bin/console importmap:require bootstrap # add a JS package +php bin/console importmap:audit # security audit of pinned packages +php bin/console asset-map:compile # build for production deploy +``` + +```yaml +# config/packages/asset_mapper.yaml +framework: + asset_mapper: + paths: + - assets/ +``` + +```twig +{# templates/base.html.twig #} +{{ importmap('app') }} +``` + +## Refreshable Env Vars (8.1+ — verify) + +For long-running workers, inject an env var as a `\Closure` so it re-reads the +value between runs instead of freezing it at boot: + +```php +use Symfony\Component\DependencyInjection\Attribute\Autowire; + +public function __construct( + #[Autowire(env: 'FEATURE_FLAGS')] private \Closure $featureFlags, // ($this->featureFlags)() +) {} +``` + +YAML equivalent: `!env_closure '%env(FEATURE_FLAGS)%'`. + +## Debug Configuration + +```bash +# Show all parameters +php bin/console debug:container --parameters + +# Show environment variables +php bin/console debug:container --env-vars + +# Show secrets +php bin/console secrets:list --reveal +``` + + +## Skill Operating Checklist + +### Design checklist +- Confirm operation boundaries and invariants first. +- Minimize scope while preserving contract correctness. +- Test both happy path and negative path behavior. + +### Validation commands +- rg --files +- composer validate +- ./vendor/bin/phpstan analyse + +### Failure modes to test +- Invalid payload or forbidden actor. +- Boundary values / not-found cases. +- Retry or partial-failure behavior for async flows. + diff --git a/.agents/skills/symfony-controller-cleanup/SKILL.md b/.agents/skills/symfony-controller-cleanup/SKILL.md new file mode 100644 index 0000000..a3f9a7f --- /dev/null +++ b/.agents/skills/symfony-controller-cleanup/SKILL.md @@ -0,0 +1,39 @@ +--- + +name: symfony-controller-cleanup +allowed-tools: + - Read + - Glob + - Grep +description: Refactor fat controllers into lean ones by extracting business logic to services, handlers, and invokable commands +--- + +# Controller Cleanup (Symfony) + +## Use when +- Refining architecture/workflows/context handling in Symfony projects. +- Planning and executing medium/complex changes safely. + +## Default workflow +1. Establish current boundaries, constraints, and coupling points. +2. Propose smallest coherent architectural adjustment. +3. Execute in checkpoints with validation at each stage. +4. Summarize tradeoffs and follow-up backlog. + +## Guardrails +- Use existing project patterns by default. +- Avoid broad refactors without explicit need. +- Keep decision log clear and auditable. + +## Progressive disclosure +- Use this file for execution posture and risk controls. +- Open references when deep implementation details are needed. + +## Output contract +- Architecture/workflow changes. +- Checkpoint validation outcomes. +- Residual risks and next steps. + +## References +- `reference.md` +- `docs/complexity-tiers.md` diff --git a/.agents/skills/symfony-controller-cleanup/reference.md b/.agents/skills/symfony-controller-cleanup/reference.md new file mode 100644 index 0000000..9593c14 --- /dev/null +++ b/.agents/skills/symfony-controller-cleanup/reference.md @@ -0,0 +1,420 @@ +# Reference + +# Controller Cleanup + +## The Problem: Fat Controllers + +```php +// BAD: Fat controller with too much logic +#[Route('/orders', methods: ['POST'])] +public function create(Request $request): Response +{ + $data = json_decode($request->getContent(), true); + + // Validation logic in controller + if (empty($data['items'])) { + return new JsonResponse(['error' => 'Items required'], 400); + } + + // Business logic in controller + $order = new Order(); + $order->setCustomer($this->getUser()); + $order->setStatus('pending'); + + $total = 0; + foreach ($data['items'] as $itemData) { + $product = $this->em->find(Product::class, $itemData['productId']); + if (!$product) { + return new JsonResponse(['error' => 'Product not found'], 400); + } + + if ($product->getStock() < $itemData['quantity']) { + return new JsonResponse(['error' => 'Insufficient stock'], 400); + } + + $item = new OrderItem(); + $item->setProduct($product); + $item->setQuantity($itemData['quantity']); + $item->setPrice($product->getPrice()); + $order->addItem($item); + + $total += $product->getPrice() * $itemData['quantity']; + $product->setStock($product->getStock() - $itemData['quantity']); + } + + $order->setTotal($total); + + // Coupon logic + if (!empty($data['coupon'])) { + $coupon = $this->em->getRepository(Coupon::class) + ->findOneBy(['code' => $data['coupon']]); + if ($coupon && $coupon->isValid()) { + $discount = $total * ($coupon->getDiscount() / 100); + $order->setDiscount($discount); + $order->setTotal($total - $discount); + } + } + + $this->em->persist($order); + $this->em->flush(); + + // Send email + $email = (new Email()) + ->to($this->getUser()->getEmail()) + ->subject('Order Confirmation') + ->text('Your order has been placed.'); + $this->mailer->send($email); + + return new JsonResponse(['id' => $order->getId()], 201); +} +``` + +## The Solution: Lean Controller + +### Step 1: Extract to Service + +```php +products->reserveItems($request->items); + + // Create order + $order = Order::create($user, $items); + + // Apply coupon if provided + if ($request->couponCode) { + $discount = $this->coupons->apply($request->couponCode, $order); + $order->applyDiscount($discount); + } + + $this->em->persist($order); + $this->em->flush(); + + // Async notification + $this->notifications->orderCreated($order); + + return $order; + } +} +``` + +### Step 2: Use DTOs for Input + +```php +orderService->createOrder( + $this->getUser(), + $request + ); + + return new JsonResponse(['id' => $order->getId()], 201); + } +} +``` + +## Controller Patterns + +### Maximum 5-10 Lines Per Action + +```php +#[Route('/posts/{id}', methods: ['PUT'])] +public function update( + Post $post, + #[MapRequestPayload] UpdatePostRequest $request +): JsonResponse { + $this->denyAccessUnlessGranted('EDIT', $post); + + $post = $this->postService->update($post, $request); + + return new JsonResponse(PostOutput::fromEntity($post)); +} +``` + +### Use Attributes for Common Tasks + +```php +use Symfony\Component\Security\Http\Attribute\IsGranted; + +#[Route('/admin/users')] +#[IsGranted('ROLE_ADMIN')] +class AdminUserController extends AbstractController +{ + #[Route('', methods: ['GET'])] + public function list(): Response + { + // Already authorized by class attribute + } +} +``` + +### MapRequestPayload for Input + +```php +#[Route('/contact', methods: ['POST'])] +public function contact( + #[MapRequestPayload] ContactRequest $request +): JsonResponse { + // $request is already validated + $this->contactService->send($request); + + return new JsonResponse(['status' => 'sent']); +} +``` + +### EntityValueResolver for Entities + +The legacy SensioFrameworkExtraBundle `ParamConverter` is gone; Doctrine's +built-in `EntityValueResolver` maps route parameters to entities automatically. + +```php +// {id} is resolved to the Post entity by the EntityValueResolver +#[Route('/posts/{id}', methods: ['GET'])] +public function show(Post $post): Response +{ + // 404 handled automatically if not found + return $this->render('post/show.html.twig', ['post' => $post]); +} +``` + +Use `#[MapEntity]` to control the lookup (non-id field, custom expression, etc.): + +```php +use Symfony\Bridge\Doctrine\Attribute\MapEntity; + +#[Route('/posts/{slug}', methods: ['GET'])] +public function showBySlug( + #[MapEntity(mapping: ['slug' => 'slug'])] Post $post, +): Response { + return $this->render('post/show.html.twig', ['post' => $post]); +} +``` + +### Inject per-argument, never pull from the container + +Type-hint the services you need as action arguments (or constructor args). Don't +reach into the container with `$this->container->get(...)` / `$this->get(...)` — +that hides dependencies and breaks autowiring/testing. + +```php +// GOOD — explicit, autowired, testable +#[Route('/reports', methods: ['GET'])] +public function reports(ReportBuilder $reports): Response +{ + return $this->json($reports->forCurrentUser($this->getUser())); +} + +// BAD +// $reports = $this->container->get(ReportBuilder::class); +``` + +### Console: invokable commands (no base class) + +The same "thin entry point + service" discipline applies to commands. Since +Symfony 7.3 an `#[AsCommand]` class with `__invoke()` no longer needs to extend +`Command`: + +```php +use Symfony\Component\Console\Attribute\Argument; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Style\SymfonyStyle; + +#[AsCommand(name: 'app:create-user', description: 'Creates a user.')] +final class CreateUserCommand +{ + public function __construct(private UserService $users) {} + + public function __invoke( + SymfonyStyle $io, + #[Argument('The username.')] string $username, + ): int { + $this->users->create($username); + $io->success("Created {$username}"); + + return Command::SUCCESS; + } +} +``` + +## Extract Responsibilities + +### Validation → DTO + Validator + +```php +// DTO handles validation rules +final readonly class CreateUserRequest +{ + #[Assert\NotBlank] + #[Assert\Email] + public string $email; + + #[Assert\NotBlank] + #[Assert\Length(min: 8)] + public string $password; +} +``` + +### Business Logic → Service + +```php +// Service handles business rules +class UserService +{ + public function register(CreateUserRequest $request): User + { + $this->ensureEmailUnique($request->email); + $user = User::register($request->email, $request->password); + $this->em->persist($user); + $this->em->flush(); + return $user; + } +} +``` + +### Authorization → Voter + +```php +// Voter handles access control +class PostVoter extends Voter +{ + protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool + { + return match ($attribute) { + 'EDIT' => $subject->getAuthor() === $token->getUser(), + default => false, + }; + } +} +``` + +### Notifications → Events/Messages + +```php +// Async via Messenger +class OrderService +{ + public function create(CreateOrderRequest $request): Order + { + // ... create order + $this->bus->dispatch(new SendOrderConfirmation($order->getId())); + return $order; + } +} +``` + +## Testing Lean Controllers + +```php +class OrderControllerTest extends WebTestCase +{ + public function testCreateOrder(): void + { + $user = UserFactory::createOne(); + ProductFactory::createMany(3); + + $this->client->loginUser($user->object()); + $this->client->request('POST', '/api/orders', [], [], [ + 'CONTENT_TYPE' => 'application/json', + ], json_encode([ + 'items' => [ + ['productId' => 1, 'quantity' => 2], + ], + ])); + + $this->assertResponseStatusCodeSame(201); + } +} +``` + +## Checklist + +- [ ] Controller actions ≤ 10 lines +- [ ] No `new Entity()` in controller +- [ ] No direct EntityManager usage +- [ ] Use DTOs for input +- [ ] Use services for business logic +- [ ] Use voters for authorization +- [ ] Use events/messages for side effects + + +## Skill Operating Checklist + +### Design checklist +- Confirm operation boundaries and invariants first. +- Minimize scope while preserving contract correctness. +- Test both happy path and negative path behavior. + +### Validation commands +- rg --files +- composer validate +- ./vendor/bin/phpstan analyse + +### Failure modes to test +- Invalid payload or forbidden actor. +- Boundary values / not-found cases. +- Retry or partial-failure behavior for async flows. + diff --git a/.agents/skills/symfony-cqrs-and-handlers/SKILL.md b/.agents/skills/symfony-cqrs-and-handlers/SKILL.md new file mode 100644 index 0000000..e3012f5 --- /dev/null +++ b/.agents/skills/symfony-cqrs-and-handlers/SKILL.md @@ -0,0 +1,39 @@ +--- + +name: symfony-cqrs-and-handlers +allowed-tools: + - Read + - Glob + - Grep +description: Implement CQRS in Symfony with separate Command and Query buses/handlers using the Messenger component +--- + +# Cqrs And Handlers (Symfony) + +## Use when +- Refining architecture/workflows/context handling in Symfony projects. +- Planning and executing medium/complex changes safely. + +## Default workflow +1. Establish current boundaries, constraints, and coupling points. +2. Propose smallest coherent architectural adjustment. +3. Execute in checkpoints with validation at each stage. +4. Summarize tradeoffs and follow-up backlog. + +## Guardrails +- Use existing project patterns by default. +- Avoid broad refactors without explicit need. +- Keep decision log clear and auditable. + +## Progressive disclosure +- Use this file for execution posture and risk controls. +- Open references when deep implementation details are needed. + +## Output contract +- Architecture/workflow changes. +- Checkpoint validation outcomes. +- Residual risks and next steps. + +## References +- `reference.md` +- `docs/complexity-tiers.md` diff --git a/.agents/skills/symfony-cqrs-and-handlers/reference.md b/.agents/skills/symfony-cqrs-and-handlers/reference.md new file mode 100644 index 0000000..5087785 --- /dev/null +++ b/.agents/skills/symfony-cqrs-and-handlers/reference.md @@ -0,0 +1,432 @@ +# Reference + +# CQRS with Symfony Messenger + +## Overview + +CQRS (Command Query Responsibility Segregation) separates read and write operations: +- **Commands**: Change state (Create, Update, Delete) +- **Queries**: Read state (no side effects) + +## Project Structure + +``` +src/ +├── Application/ +│ ├── Command/ +│ │ ├── CreateOrder.php +│ │ └── CreateOrderHandler.php +│ └── Query/ +│ ├── GetOrder.php +│ └── GetOrderHandler.php +├── Domain/ +│ └── Order/ +│ └── Entity/Order.php +└── Infrastructure/ + └── Controller/ + └── OrderController.php +``` + +## Commands + +### Command Class + +```php +products->resolveItems($command->items); + + // Create order + $order = Order::create( + $this->orders->nextId(), + $command->customerId, + ); + + foreach ($items as $item) { + $order->addItem($item); + } + + // Apply coupon if provided + if ($command->couponCode) { + $discount = $this->coupons->apply($command->couponCode, $order); + $order->applyDiscount($discount); + } + + $this->orders->save($order); + + return $order; + } +} +``` + +## Queries + +### Query Class + +```php +orders->findById($query->orderId); + + if (!$order) { + return null; + } + + return OrderView::fromEntity($order); + } +} + +// src/Application/Query/GetOrdersByCustomerHandler.php + +#[AsMessageHandler] +final readonly class GetOrdersByCustomerHandler +{ + public function __construct( + private OrderReadRepository $readRepository, + ) {} + + public function __invoke(GetOrdersByCustomer $query): PaginatedResult + { + return $this->readRepository->findByCustomer( + $query->customerId, + $query->page, + $query->limit, + ); + } +} +``` + +## Separate Buses + +### Configuration + +```yaml +# config/packages/messenger.yaml +framework: + messenger: + default_bus: command_bus + + buses: + command_bus: + middleware: + - validation + - doctrine_transaction + + query_bus: + middleware: + - validation + + # Route by namespace so commands/queries land on the right bus. + routing: + 'App\Application\Command\*': command_bus + 'App\Application\Query\*': query_bus +``` + +### Injecting the native buses directly + +You don't need the wrapper interfaces below — Symfony registers each bus as +`messenger.bus.`. Inject them with `#[Autowire]` and type +`MessageBusInterface`: + +```php +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\Messenger\HandleTrait; +use Symfony\Component\Messenger\MessageBusInterface; + +class OrderController extends AbstractController +{ + public function __construct( + #[Autowire('@messenger.bus.command_bus')] + private MessageBusInterface $commandBus, + #[Autowire('@messenger.bus.query_bus')] + private MessageBusInterface $queryBus, + ) {} +} +``` + +To get the handler's return value synchronously (queries), use `HandleTrait` +in a thin service as shown in the next section. + +### Bus Interfaces + +```php +messageBus = $commandBus; + } + + public function dispatch(object $command): mixed + { + return $this->handle($command); + } +} + +// src/Infrastructure/Bus/MessengerQueryBus.php + +final class MessengerQueryBus implements QueryBusInterface +{ + use HandleTrait; + + public function __construct(MessageBusInterface $queryBus) + { + $this->messageBus = $queryBus; + } + + public function ask(object $query): mixed + { + return $this->handle($query); + } +} +``` + +### Service Configuration + +```yaml +# config/services.yaml +services: + App\Application\Bus\CommandBusInterface: + class: App\Infrastructure\Bus\MessengerCommandBus + arguments: ['@command.bus'] + + App\Application\Bus\QueryBusInterface: + class: App\Infrastructure\Bus\MessengerQueryBus + arguments: ['@query.bus'] +``` + +## Controller Usage + +```php +getContent(), true); + + $order = $this->commandBus->dispatch(new CreateOrder( + customerId: $data['customerId'], + items: $data['items'], + couponCode: $data['couponCode'] ?? null, + )); + + return new JsonResponse(['id' => $order->getId()], 201); + } + + #[Route('/{id}', methods: ['GET'])] + public function show(string $id): JsonResponse + { + $order = $this->queryBus->ask(new GetOrder($id)); + + if (!$order) { + throw $this->createNotFoundException(); + } + + return new JsonResponse($order); + } +} +``` + +## Read Models (Optional) + +For complex reads, use dedicated read models: + +```php +connection->fetchAllAssociative($sql, [ + 'customerId' => $customerId, + 'limit' => $limit, + 'offset' => ($page - 1) * $limit, + ]); + + return new PaginatedResult($results, $this->countByCustomer($customerId)); + } +} +``` + +## Best Practices + +1. **Commands change state**: Never return data from commands (except ID) +2. **Queries are side-effect free**: Can be cached, retried +3. **Separate handlers**: One handler per command/query +4. **Validation in commands**: Use Symfony Validator +5. **Read models for complex queries**: Optimize separately +6. **Transaction on commands**: Wrap in database transaction + + +## Skill Operating Checklist + +### Design checklist +- Confirm operation boundaries and invariants first. +- Minimize scope while preserving contract correctness. +- Test both happy path and negative path behavior. + +### Validation commands +- rg --files +- composer validate +- ./vendor/bin/phpstan analyse + +### Failure modes to test +- Invalid payload or forbidden actor. +- Boundary values / not-found cases. +- Retry or partial-failure behavior for async flows. + diff --git a/.agents/skills/symfony-daily-workflow/SKILL.md b/.agents/skills/symfony-daily-workflow/SKILL.md new file mode 100644 index 0000000..a00fc5a --- /dev/null +++ b/.agents/skills/symfony-daily-workflow/SKILL.md @@ -0,0 +1,39 @@ +--- + +name: symfony-daily-workflow +allowed-tools: + - Read + - Glob + - Grep +description: Daily development workflow for Symfony projects including common tasks, debugging, and productivity tips +--- + +# Daily Workflow (Symfony) + +## Use when +- Refining architecture/workflows/context handling in Symfony projects. +- Planning and executing medium/complex changes safely. + +## Default workflow +1. Establish current boundaries, constraints, and coupling points. +2. Propose smallest coherent architectural adjustment. +3. Execute in checkpoints with validation at each stage. +4. Summarize tradeoffs and follow-up backlog. + +## Guardrails +- Use existing project patterns by default. +- Avoid broad refactors without explicit need. +- Keep decision log clear and auditable. + +## Progressive disclosure +- Use this file for execution posture and risk controls. +- Open references when deep implementation details are needed. + +## Output contract +- Architecture/workflow changes. +- Checkpoint validation outcomes. +- Residual risks and next steps. + +## References +- `reference.md` +- `docs/complexity-tiers.md` diff --git a/.agents/skills/symfony-daily-workflow/reference.md b/.agents/skills/symfony-daily-workflow/reference.md new file mode 100644 index 0000000..aff61e6 --- /dev/null +++ b/.agents/skills/symfony-daily-workflow/reference.md @@ -0,0 +1,331 @@ +# Reference + +# Daily Symfony Development Workflow + +## Starting Your Day + +### 1. Update Dependencies (if needed) + +```bash +# Pull latest code +git pull origin main + +# Update dependencies +composer install + +# Run migrations +bin/console doctrine:migrations:migrate --no-interaction + +# Clear cache +bin/console cache:clear +``` + +### 2. Start Services + +```bash +# Docker Compose +docker compose up -d + +# Or Symfony Docker +docker compose up -d --wait + +# Start Symfony server (if not using Docker) +symfony server:start -d +``` + +### 3. Check Status + +```bash +# Verify database connection +bin/console doctrine:query:sql "SELECT 1" + +# Check messenger transports +bin/console messenger:stats + +# Verify cache is working +bin/console cache:pool:list +``` + +## Common Development Tasks + +### Creating New Features + +```bash +# 1. Create entity +bin/console make:entity Product + +# 2. Create migration +bin/console make:migration + +# 3. Run migration +bin/console doctrine:migrations:migrate + +# 4. Create controller +bin/console make:controller ProductController + +# 5. Create form (if needed) +bin/console make:form ProductType + +# 6. Create test +bin/console make:test WebTestCase ProductControllerTest +``` + +### Working with Doctrine + +```bash +# Validate mapping +bin/console doctrine:schema:validate + +# Show SQL that would be executed +bin/console doctrine:schema:update --dump-sql + +# Generate migration from entity changes +bin/console make:migration + +# Load fixtures +bin/console doctrine:fixtures:load + +# Reset database +bin/console doctrine:database:drop --force +bin/console doctrine:database:create +bin/console doctrine:migrations:migrate --no-interaction +bin/console doctrine:fixtures:load --no-interaction +``` + +### Working with Messenger + +```bash +# Process messages +bin/console messenger:consume async -vv + +# Process with limits +bin/console messenger:consume async --limit=10 --time-limit=60 + +# View failed messages +bin/console messenger:failed:show + +# Retry failed messages +bin/console messenger:failed:retry --all + +# Stop workers gracefully +bin/console messenger:stop-workers +``` + +## Debugging + +### Debug Tools + +```bash +# Debug routes +bin/console debug:router +bin/console debug:router api_products_get_collection + +# Debug container/services +bin/console debug:container +bin/console debug:container ProductService +bin/console debug:autowiring Product + +# Debug configuration +bin/console debug:config framework +bin/console debug:config api_platform + +# Debug event dispatcher +bin/console debug:event-dispatcher +bin/console debug:event-dispatcher kernel.request +``` + +### Profiler + +```bash +# Enable profiler (dev only) +# Visit: /_profiler + +# Check latest profiles via CLI +bin/console profiler:list +``` + +### Logging + +```bash +# Tail logs +tail -f var/log/dev.log + +# Filter logs +grep "ERROR" var/log/dev.log +grep "doctrine" var/log/dev.log +``` + +### Dump and Die + +```php +// In code +dump($variable); // Dump but continue +dd($variable); // Dump and die + +// In Twig +{{ dump(variable) }} +``` + +## Testing Workflow + +### Running Tests + +```bash +# All tests +./vendor/bin/phpunit +# Or with Pest +./vendor/bin/pest + +# Specific test file +./vendor/bin/pest tests/Functional/Api/ProductTest.php + +# Specific test method +./vendor/bin/pest --filter "creates product" + +# With coverage +./vendor/bin/pest --coverage --min=80 + +# Parallel execution +./vendor/bin/pest --parallel +``` + +### TDD Cycle + +```bash +# 1. Write failing test +./vendor/bin/pest tests/Unit/Service/ProductServiceTest.php + +# 2. Implement minimum code to pass + +# 3. Run test again - should pass +./vendor/bin/pest tests/Unit/Service/ProductServiceTest.php + +# 4. Refactor + +# 5. Run all tests +./vendor/bin/pest +``` + +## Code Quality + +### Before Committing + +```bash +# Fix code style +./vendor/bin/php-cs-fixer fix + +# Run static analysis +./vendor/bin/phpstan analyse + +# Run tests +./vendor/bin/pest + +# All checks +composer run-script check +``` + +### Pre-commit Hook + +```bash +#!/bin/sh +# .git/hooks/pre-commit + +./vendor/bin/php-cs-fixer fix --dry-run +if [ $? -ne 0 ]; then + echo "Fix code style before committing" + exit 1 +fi + +./vendor/bin/phpstan analyse +if [ $? -ne 0 ]; then + echo "Fix PHPStan errors before committing" + exit 1 +fi +``` + +## API Development + +### Testing API Endpoints + +```bash +# Using curl +curl -X GET http://localhost/api/products +curl -X POST http://localhost/api/products \ + -H "Content-Type: application/json" \ + -d '{"name": "Test", "price": 1999}' + +# Using httpie (cleaner) +http GET localhost/api/products +http POST localhost/api/products name="Test" price:=1999 +``` + +### API Documentation + +```bash +# Generate OpenAPI spec +bin/console api:openapi:export --output=openapi.json + +# View in browser +# http://localhost/api/docs +``` + +## End of Day + +### Clean Up + +```bash +# Stop services +docker compose down + +# Or keep data but stop containers +docker compose stop +``` + +### Commit Work + +```bash +# Check status +git status + +# Stage changes +git add -p # Interactive staging + +# Commit +git commit -m "feat: add product filtering" + +# Push +git push origin feature/product-filtering +``` + +## Quick Reference + +| Task | Command | +|------|---------| +| Clear cache | `bin/console cache:clear` | +| Run migrations | `bin/console doctrine:migrations:migrate` | +| Load fixtures | `bin/console doctrine:fixtures:load` | +| Run tests | `./vendor/bin/pest` | +| Fix code style | `./vendor/bin/php-cs-fixer fix` | +| Static analysis | `./vendor/bin/phpstan analyse` | +| Debug routes | `bin/console debug:router` | +| Debug services | `bin/console debug:container` | +| Consume messages | `bin/console messenger:consume async` | + + +## Skill Operating Checklist + +### Design checklist +- Confirm operation boundaries and invariants first. +- Minimize scope while preserving contract correctness. +- Test both happy path and negative path behavior. + +### Validation commands +- rg --files +- composer validate +- ./vendor/bin/phpstan analyse + +### Failure modes to test +- Invalid payload or forbidden actor. +- Boundary values / not-found cases. +- Retry or partial-failure behavior for async flows. + diff --git a/.agents/skills/symfony-doctrine-batch-processing/SKILL.md b/.agents/skills/symfony-doctrine-batch-processing/SKILL.md new file mode 100644 index 0000000..591f195 --- /dev/null +++ b/.agents/skills/symfony-doctrine-batch-processing/SKILL.md @@ -0,0 +1,42 @@ +--- + +name: symfony-doctrine-batch-processing +allowed-tools: + - Read + - Write + - Edit + - Bash + - Glob + - Grep +description: Process large datasets with Doctrine (ORM 3 toIterable, flush+clear, bulk DQL) and memory management +--- + +# Doctrine Batch Processing (Symfony) + +## Use when +- Designing entity relations or schema evolution. +- Improving Doctrine correctness/performance. + +## Default workflow +1. Model ownership/cardinality and transactional boundaries. +2. Apply mapping/schema changes with migration safety. +3. Tune fetch/query behavior for hot paths. +4. Verify lifecycle behavior with targeted tests. + +## Guardrails +- Keep owning/inverse sides coherent. +- Avoid destructive migration jumps in one release. +- Eliminate accidental N+1 and over-fetching. + +## Progressive disclosure +- Use this file for execution posture and risk controls. +- Open references when deep implementation details are needed. + +## Output contract +- Entity/migration changes. +- Integrity and performance decisions. +- Validation outcomes and rollback notes. + +## References +- `reference.md` +- `docs/complexity-tiers.md` diff --git a/.agents/skills/symfony-doctrine-batch-processing/reference.md b/.agents/skills/symfony-doctrine-batch-processing/reference.md new file mode 100644 index 0000000..7a17e6f --- /dev/null +++ b/.agents/skills/symfony-doctrine-batch-processing/reference.md @@ -0,0 +1,306 @@ +# Reference + +# Doctrine Batch Processing + +## The Problem + +```php +// BAD: Loads all entities into memory +$products = $repository->findAll(); +foreach ($products as $product) { + $this->process($product); +} +// Out of Memory with large datasets! +``` + +## Solution 1: Iterate with toIterable() + +```php +createQuery('SELECT p FROM Product p'); + +foreach ($query->toIterable() as $product) { + $this->process($product); + + // Clear managed entities periodically + $em->clear(); +} +``` + +> **ORM 3 breaking change**: `Query#iterate()` was deprecated in ORM 2.7 and +> **removed in ORM 3.0**. Use `toIterable()` (available since 2.7, the only +> option in 3.x/4.x). You cannot iterate a query that fetch-joins a +> collection-valued association. + +## Solution 2: Batch with Clear + +```php +createQuery('SELECT p FROM Product p'); +$i = 0; + +foreach ($query->toIterable() as $product) { + $product->setProcessedAt(new \DateTimeImmutable()); + $i++; + + if ($i % self::BATCH_SIZE === 0) { + $em->flush(); + $em->clear(); + gc_collect_cycles(); + } +} + +// Flush remaining +$em->flush(); +$em->clear(); +``` + +## Solution 3: ID-Based Pagination + +```php +em->createQueryBuilder() + ->select('p') + ->from(Product::class, 'p') + ->where('p.id > :lastId') + ->setParameter('lastId', $lastId) + ->orderBy('p.id', 'ASC') + ->setMaxResults(self::BATCH_SIZE) + ->getQuery() + ->getResult(); + + if (empty($products)) { + break; + } + + foreach ($products as $product) { + $this->process($product); + $lastId = $product->getId(); + } + + $this->em->flush(); + $this->em->clear(); + } + } +} +``` + +## Solution 3b: DQL Bulk UPDATE / DELETE + +For mass updates/deletes, a single DQL `UPDATE`/`DELETE` statement is the most +efficient — it bypasses hydration and the unit of work entirely: + +```php +createQuery( + 'UPDATE App\Entity\Product p SET p.price = p.price * 0.9 WHERE p.discontinued = true' +)->execute(); + +// Bulk delete +$deleted = $em->createQuery( + 'DELETE App\Entity\Product p WHERE p.createdAt < :cutoff' +)->setParameter('cutoff', new \DateTimeImmutable('-1 year')) + ->execute(); +``` + +DQL UPDATE/DELETE do **not** trigger lifecycle events or update already-managed +objects in memory — clear or re-fetch afterwards if you keep working with them. + +## Solution 4: DBAL for Bulk Updates + +```php +connection->executeStatement( + 'UPDATE product SET processed_at = NOW() WHERE processed_at IS NULL' + ); + } + + public function updatePrices(array $updates): void + { + $this->connection->beginTransaction(); + + try { + $stmt = $this->connection->prepare( + 'UPDATE product SET price = :price WHERE id = :id' + ); + + foreach ($updates as $id => $price) { + $stmt->executeStatement(['id' => $id, 'price' => $price]); + } + + $this->connection->commit(); + } catch (\Exception $e) { + $this->connection->rollBack(); + throw $e; + } + } +} +``` + +## Solution 5: Bulk Insert + +```php +em->getConnection()->getConfiguration()->setSQLLogger(null); + // to avoid memory growth. That method is DEPRECATED in DBAL 3+ and gone + // in DBAL 4 (logging moved to PSR-3 middleware). On ORM 3 / DBAL 4 you + // disable logging via configuration instead of this call. + + $batches = array_chunk($data, self::BATCH_SIZE); + + foreach ($batches as $batch) { + foreach ($batch as $item) { + $product = new Product(); + $product->setName($item['name']); + $product->setPrice($item['price']); + $this->em->persist($product); + } + + $this->em->flush(); + $this->em->clear(); + } + } +} +``` + +## Memory Monitoring + +```php +toIterable() as $i => $entity) { + $this->processEntity($entity); + + if ($i % 100 === 0) { + $this->em->clear(); + + $currentMemory = memory_get_usage(); + $this->logger->info('Batch progress', [ + 'processed' => $i, + 'memory_mb' => round($currentMemory / 1024 / 1024, 2), + 'memory_delta_mb' => round(($currentMemory - $startMemory) / 1024 / 1024, 2), + ]); + } + } + } +} +``` + +## Symfony Command for Batch Processing + +```php +em->createQuery('SELECT p FROM Product p WHERE p.processedAt IS NULL'); + $total = $this->countUnprocessed(); + + $io->progressStart($total); + + $processed = 0; + foreach ($query->toIterable() as $product) { + $this->processor->process($product); + $processed++; + + if ($processed % self::BATCH_SIZE === 0) { + $this->em->flush(); + $this->em->clear(); + $io->progressAdvance(self::BATCH_SIZE); + } + } + + $this->em->flush(); + $io->progressFinish(); + + $io->success("Processed {$processed} products"); + + return Command::SUCCESS; + } +} +``` + +## Best Practices + +1. **Clear regularly**: `$em->clear()` releases memory +2. **Use toIterable()**: Don't load all results at once +3. **DQL UPDATE/DELETE or DBAL for bulk writes**: Skip hydration for mass updates +4. **Monitor memory**: Log memory usage in long processes +5. **Disable SQL logging**: Via config/middleware on DBAL 4 (`setSQLLogger()` is gone) +6. **Progress feedback**: Use SymfonyStyle progress bars + +## Applicability + +- **ORM 3.x / DBAL 4.x** (target): `toIterable()` only; `Query#iterate()` removed; + `setSQLLogger()` removed (PSR-3 middleware instead). +- **ORM 2.7+ (legacy)**: `toIterable()` available alongside the deprecated + `iterate()`; prefer `toIterable()`. + + +## Skill Operating Checklist + +### Design checklist +- Confirm operation boundaries and invariants first. +- Minimize scope while preserving contract correctness. +- Test both happy path and negative path behavior. + +### Validation commands +- php bin/console doctrine:migrations:diff +- php bin/console doctrine:migrations:migrate +- ./vendor/bin/phpunit --filter=Doctrine + +### Failure modes to test +- Invalid payload or forbidden actor. +- Boundary values / not-found cases. +- Retry or partial-failure behavior for async flows. + diff --git a/.agents/skills/symfony-doctrine-events/SKILL.md b/.agents/skills/symfony-doctrine-events/SKILL.md new file mode 100644 index 0000000..a05b0fa --- /dev/null +++ b/.agents/skills/symfony-doctrine-events/SKILL.md @@ -0,0 +1,44 @@ +--- + +name: symfony-doctrine-events +allowed-tools: + - Read + - Write + - Edit + - Bash + - Glob + - Grep +description: React to Doctrine entity lifecycle in Symfony with attribute listeners (#[AsDoctrineListener]/#[AsEntityListener], ORM 3) and lifecycle callbacks +--- + +# Doctrine Events (Symfony) + +## Use when +- You need side effects on entity persistence (timestamps, slugs, search indexing, notifications). +- Migrating an `EventSubscriberInterface` Doctrine subscriber to ORM 3. +- Choosing between lifecycle callbacks, entity listeners, and global lifecycle listeners. + +## Default workflow +1. Pick the narrowest mechanism: callback (one entity, no deps) → entity listener (one entity, needs services) → lifecycle listener (cross-cutting, all entities). +2. Wire it with attributes (`#[ORM\HasLifecycleCallbacks]`, `#[AsEntityListener]`, `#[AsDoctrineListener]`). +3. Type the event argument by its per-event class (`PostPersistEventArgs`, `PreUpdateEventArgs`, …). +4. Verify the side effect fires with a targeted functional test against a real DB. + +## Guardrails +- ORM 3.0 removed `EventSubscriberInterface` Doctrine subscribers — never reintroduce them. +- Type-check the entity early in a global lifecycle listener (it fires for every entity). +- Never call `flush()` inside a Doctrine event handler — it corrupts the in-flight unit of work. +- `preUpdate` is restricted: change fields only via `$args->setNewValue()`, never touch associations. + +## Progressive disclosure +- Use this file for execution posture and risk controls. +- Open `reference.md` for the three mechanisms, the event-args matrix, and migration from subscribers. + +## Output contract +- Listener/callback class with the correct attribute. +- Justification for the chosen mechanism. +- Validation outcomes (test that the side effect fired). + +## References +- `reference.md` +- `docs/complexity-tiers.md` diff --git a/.agents/skills/symfony-doctrine-events/reference.md b/.agents/skills/symfony-doctrine-events/reference.md new file mode 100644 index 0000000..bed1f1e --- /dev/null +++ b/.agents/skills/symfony-doctrine-events/reference.md @@ -0,0 +1,252 @@ +# Reference + +# Doctrine Events (Symfony) + +> **ORM 3 breaking change**: Doctrine `EventSubscriberInterface` subscribers were +> **removed in ORM 3.0**. The replacement is attribute-based listeners +> (`#[AsDoctrineListener]`, `#[AsEntityListener]`), available since +> **DoctrineBundle 2.8+**. Each event also gets its own typed argument class +> (`PostPersistEventArgs`, `PreUpdateEventArgs`, …) instead of the single +> `LifecycleEventArgs` from ORM 2.x. + +## Three mechanisms — which to pick + +| Mechanism | Scope | Container services? | Cost | Use when | +|-----------|-------|---------------------|------|----------| +| **Lifecycle callbacks** | one entity, its own method | no | fastest | self-contained logic (timestamps, slug from own fields) | +| **Entity listeners** | one entity class | yes (autowired) | medium | one entity needs a service (mailer, indexer) | +| **Lifecycle listeners** | every entity | yes | slowest (called for all) | cross-cutting concerns (audit log, global timestamps) | + +Rule of thumb: start with a **callback**; reach for an **entity listener** when +you need a service; use a **global lifecycle listener** only for truly +cross-cutting behavior, and type-check the entity first. + +## 1. Lifecycle callbacks + +A method on the entity itself. Requires `#[ORM\HasLifecycleCallbacks]` on the +class. + +```php +createdAt = new \DateTimeImmutable(); + } + + #[ORM\PreUpdate] + public function setUpdatedAtValue(): void + { + $this->updatedAt = new \DateTimeImmutable(); + } +} +``` + +Available callback attributes: `#[ORM\PrePersist]`, `#[ORM\PostPersist]`, +`#[ORM\PreUpdate]`, `#[ORM\PostUpdate]`, `#[ORM\PreRemove]`, +`#[ORM\PostRemove]`, `#[ORM\PostLoad]`. + +Callbacks cannot receive constructor-injected services — keep them to logic that +only touches the entity's own state. + +## 2. Entity listeners (one entity, with services) + +A separate class bound to one entity. It is a regular autowired service. + +```php +notifier->notifyAccountChange($user); + } +} +``` + +The handler signature is `(EntityType $entity, EventArgs $event)`. You +can attach several `#[AsEntityListener]` attributes to one class for different +events. + +YAML equivalent (tag) if not using the attribute: + +```yaml +services: + App\EntityListener\UserChangedNotifier: + tags: + - { name: doctrine.orm.entity_listener, event: postUpdate, entity: App\Entity\User } +``` + +## 3. Lifecycle listeners (all entities, with services) + +A global listener invoked for **every** entity on the given event. Always +type-check. + +```php +getObject(); + + if (!$entity instanceof Product) { + return; // ignore everything that is not a Product + } + + $em = $args->getObjectManager(); + $this->search->index($entity); + } +} +``` + +`priority` defaults to `0`; higher runs earlier. `connection` scopes the +listener to one connection. + +YAML equivalent: + +```yaml +services: + App\EventListener\SearchIndexer: + tags: + - { name: doctrine.event_listener, event: postPersist, priority: 500, connection: default } +``` + +## Per-event argument classes (ORM 3) + +The single `LifecycleEventArgs` of ORM 2 is replaced by one class per event, +all in `Doctrine\ORM\Event`: + +| Event (`Events::*`) | Argument class | Notes | +|---------------------|----------------|-------| +| `prePersist` | `PrePersistEventArgs` | before INSERT | +| `postPersist` | `PostPersistEventArgs` | ID is available now | +| `preUpdate` | `PreUpdateEventArgs` | change set only; see below | +| `postUpdate` | `PostUpdateEventArgs` | after UPDATE | +| `preRemove` | `PreRemoveEventArgs` | before DELETE | +| `postRemove` | `PostRemoveEventArgs` | after DELETE | +| `postLoad` | `PostLoadEventArgs` | after hydration | +| `onFlush` | `OnFlushEventArgs` | whole unit of work | + +Common accessors: `$args->getObject()` (the entity), +`$args->getObjectManager()` (the `EntityManagerInterface`). + +### preUpdate is special + +`preUpdate` runs on a computed change set. You may only change already-changed +fields, through the event API — never modify associations or call other +entities' setters here, and never `flush()`: + +```php +use Doctrine\ORM\Event\PreUpdateEventArgs; + +public function preUpdate(PreUpdateEventArgs $args): void +{ + if ($args->hasChangedField('price')) { + $old = $args->getOldValue('price'); + $new = $args->getNewValue('price'); + // adjust the value being written: + $args->setNewValue('price', max(0, $new)); + } +} +``` + +## Migrating from an ORM 2 EventSubscriber + +```php +// BEFORE (ORM 2 — removed in ORM 3): +class MySubscriber implements \Doctrine\Common\EventSubscriber +{ + public function getSubscribedEvents(): array + { + return [Events::postPersist]; + } + + public function postPersist(LifecycleEventArgs $args): void { /* ... */ } +} +``` + +```php +// AFTER (ORM 3): +#[AsDoctrineListener(event: Events::postPersist)] +final class MyListener +{ + public function postPersist(PostPersistEventArgs $args): void { /* ... */ } +} +``` + +Steps: drop `implements EventSubscriber` and `getSubscribedEvents()`; add +`#[AsDoctrineListener(event: Events::...)]` (one attribute per event); swap +`LifecycleEventArgs` for the per-event typed class. + +## Guardrails + +1. **No subscribers on ORM 3** — they no longer exist; use the attributes. +2. **Don't `flush()` in a handler** — it re-enters the unit of work mid-flight. +3. **Type-check in global listeners** — they fire for every entity. +4. **`preUpdate` = change set only** — use `setNewValue()`, leave associations alone. +5. **Prefer the narrowest scope** — callback < entity listener < lifecycle listener. + +## Applicability + +- **ORM 3.x + DoctrineBundle 2.8+** (target): attributes + per-event args, no + subscribers. +- **ORM 2.x (legacy)**: subscribers and the single `LifecycleEventArgs` still + work but are deprecated — migrate before upgrading to 3.0. + + +## Skill Operating Checklist + +### Design checklist +- Confirm operation boundaries and invariants first. +- Minimize scope while preserving contract correctness. +- Test both happy path and negative path behavior. + +### Validation commands +- php bin/console doctrine:migrations:diff +- php bin/console doctrine:migrations:migrate +- ./vendor/bin/phpunit --filter=Doctrine + +### Failure modes to test +- Invalid payload or forbidden actor. +- Boundary values / not-found cases. +- Retry or partial-failure behavior for async flows. diff --git a/.agents/skills/symfony-doctrine-fetch-modes/SKILL.md b/.agents/skills/symfony-doctrine-fetch-modes/SKILL.md new file mode 100644 index 0000000..110419a --- /dev/null +++ b/.agents/skills/symfony-doctrine-fetch-modes/SKILL.md @@ -0,0 +1,42 @@ +--- + +name: symfony-doctrine-fetch-modes +allowed-tools: + - Read + - Write + - Edit + - Bash + - Glob + - Grep +description: Optimize Doctrine fetching with DTO hydration (SELECT NEW; partial removed in ORM 3), lazy loading, query hints, and DBAL 4 access +--- + +# Doctrine Fetch Modes (Symfony) + +## Use when +- Designing entity relations or schema evolution. +- Improving Doctrine correctness/performance. + +## Default workflow +1. Model ownership/cardinality and transactional boundaries. +2. Apply mapping/schema changes with migration safety. +3. Tune fetch/query behavior for hot paths. +4. Verify lifecycle behavior with targeted tests. + +## Guardrails +- Keep owning/inverse sides coherent. +- Avoid destructive migration jumps in one release. +- Eliminate accidental N+1 and over-fetching. + +## Progressive disclosure +- Use this file for execution posture and risk controls. +- Open references when deep implementation details are needed. + +## Output contract +- Entity/migration changes. +- Integrity and performance decisions. +- Validation outcomes and rollback notes. + +## References +- `reference.md` +- `docs/complexity-tiers.md` diff --git a/.agents/skills/symfony-doctrine-fetch-modes/reference.md b/.agents/skills/symfony-doctrine-fetch-modes/reference.md new file mode 100644 index 0000000..25fa974 --- /dev/null +++ b/.agents/skills/symfony-doctrine-fetch-modes/reference.md @@ -0,0 +1,308 @@ +# Reference + +# Doctrine Fetch Modes + +## Fetch Mode Types + +### LAZY (Default) + +Relations loaded on first access - can cause N+1: + +```php +#[ORM\ManyToOne(fetch: 'LAZY')] +private User $author; + +// Usage +$post = $em->find(Post::class, 1); +$name = $post->getAuthor()->getName(); // Triggers query +``` + +### EAGER + +Always load with parent - use sparingly: + +```php +#[ORM\ManyToOne(fetch: 'EAGER')] +private User $author; + +// Usage - author loaded in same query +$post = $em->find(Post::class, 1); +$name = $post->getAuthor()->getName(); // No extra query +``` + +### EXTRA_LAZY + +For large collections - partial operations without full load: + +```php +#[ORM\OneToMany(targetEntity: Comment::class, mappedBy: 'post', fetch: 'EXTRA_LAZY')] +private Collection $comments; + +// These don't load the full collection: +$count = $post->getComments()->count(); // COUNT query +$has = $post->getComments()->contains($c); // EXISTS query +$slice = $post->getComments()->slice(0, 5); // LIMIT query +``` + +## Query-Level Fetch Mode + +> **ORM 3 note**: `Query::setFetchMode()` was removed. The portable, explicit +> way to eager-load a relation for a single query is a fetch join (`addSelect` +> + `leftJoin`, see below) rather than a per-query fetch-mode override. + +```php +createQueryBuilder('p') + ->addSelect('a') + ->leftJoin('p.author', 'a') + ->where('p.id = :id') + ->setParameter('id', $id) + ->getQuery() + ->getOneOrNullResult(); +} +``` + +## Join Fetch (Best Practice) + +Explicitly load relations in query: + +```php +createQueryBuilder('p') + ->addSelect('a', 't', 'c') // Include in SELECT + ->leftJoin('p.author', 'a') + ->leftJoin('p.tags', 't') + ->leftJoin('p.comments', 'c') + ->orderBy('p.createdAt', 'DESC') + ->getQuery() + ->getResult(); +} + +public function findByIdWithAuthor(int $id): ?Post +{ + return $this->createQueryBuilder('p') + ->addSelect('a') + ->leftJoin('p.author', 'a') + ->where('p.id = :id') + ->setParameter('id', $id) + ->getQuery() + ->getOneOrNullResult(); +} +``` + +## Loading Only the Columns You Need (DTO hydration) + +> **ORM 3 breaking change**: the `partial` DQL keyword and +> `Query::HINT_FORCE_PARTIAL_LOAD` were deprecated in ORM 2.x and **removed in +> ORM 3.0**. `SELECT PARTIAL p.{id, title}` no longer parses. Use explicit DTO +> (`SELECT NEW`) hydration instead — it is faster, type-safe, and the partial +> object footguns (uninitialized fields, broken change tracking) are gone. + +Define a plain DTO and project into it with `SELECT NEW`: + +```php +createQueryBuilder('p') + ->select('NEW App\Dto\PostListItem(p.id, p.title, a.name)') + ->leftJoin('p.author', 'a') + ->getQuery() + ->getResult(); +} +``` + +The constructor argument order must match the `NEW` argument order. The result +is an array of `PostListItem`, not managed entities — ideal for read-only lists. + +## Batch Processing with Iteration + +Process large datasets without memory issues: + +```php +public function processAllPosts(): void +{ + $query = $this->createQueryBuilder('p') + ->getQuery(); + + foreach ($query->toIterable() as $post) { + $this->process($post); + + // Clear entity manager periodically + $this->em->clear(Post::class); + } +} +``` + +## Proxy Objects + +Understanding lazy loading: + +```php +// $post->getAuthor() returns a Proxy, not User +$author = $post->getAuthor(); + +// Proxy is a subclass of User +$author instanceof User; // true + +// Check if proxy is initialized +$em->getUnitOfWork()->isInIdentityMap($author); // true if loaded + +// Force initialization +$em->getUnitOfWork()->initializeObject($author); +``` + +## Preventing N+1 + +### The Problem + +```php +// N+1 queries! +$posts = $repository->findAll(); +foreach ($posts as $post) { + echo $post->getAuthor()->getName(); // Query per iteration +} +``` + +### The Solution + +```php +// Single query with join +$posts = $repository->createQueryBuilder('p') + ->addSelect('a') + ->leftJoin('p.author', 'a') + ->getQuery() + ->getResult(); + +foreach ($posts as $post) { + echo $post->getAuthor()->getName(); // No extra query +} +``` + +## Query Hints + +```php +use Doctrine\ORM\Query; + +$query = $em->createQuery('SELECT p FROM Post p'); + +// Force refresh from database +$query->setHint(Query::HINT_REFRESH, true); + +// Custom output walker for soft deletes +$query->setHint( + Query::HINT_CUSTOM_OUTPUT_WALKER, + 'Gedmo\SoftDeleteable\Query\TreeWalker\SoftDeleteableWalker' +); +``` + +## Index By for Fast Lookups + +```php +public function findAllIndexedById(): array +{ + return $this->createQueryBuilder('p', 'p.id') // Index by ID + ->getQuery() + ->getResult(); +} + +// Returns ['1' => Post, '2' => Post, ...] +$posts = $repository->findAllIndexedById(); +$post = $posts[42]; // Direct access, no loop needed +``` + +## Read-Only Queries + +Skip change tracking for read-only data: + +```php +public function findForDisplay(): array +{ + return $this->createQueryBuilder('p') + ->getQuery() + ->setHint(Query::HINT_READ_ONLY, true) + ->getResult(); +} +``` + +## Best Practices + +1. **Default to LAZY**: Most relations don't need eager loading +2. **EXTRA_LAZY for large collections**: count(), contains(), slice() +3. **Join fetch in repositories**: Explicit control over loading +4. **Avoid EAGER on mapping**: Fetch join in the query is better +5. **DTO (`SELECT NEW`) for lists**: Replaces the removed `partial` keyword +6. **Batch with `toIterable()`**: For large dataset processing +7. **Profile queries**: Use Symfony profiler to spot N+1 + +## DBAL 4 — method-based API + +When you drop to raw SQL (reports, projections, bulk reads), DBAL 4 is +method-based. The old `query()` / `exec()` / `fetchAll()` were removed: + +```php +use Doctrine\DBAL\Connection; + +// Reads +$rows = $connection->fetchAllAssociative('SELECT id, title FROM post'); +$row = $connection->fetchAssociative('SELECT * FROM post WHERE id = ?', [$id]); +$value = $connection->fetchOne('SELECT COUNT(*) FROM post'); +$stmt = $connection->executeQuery('SELECT * FROM post WHERE status = ?', [$status]); + +// Writes (returns affected-row count) +$affected = $connection->executeStatement( + 'UPDATE post SET status = ? WHERE status = ?', + ['archived', 'draft'], +); +``` + +## Applicability + +- **ORM 3.x / DBAL 4.x** (target): no `partial` keyword, + no `HINT_FORCE_PARTIAL_LOAD`, no `Query::setFetchMode()`; use `SELECT NEW` + DTOs and fetch joins. DBAL `query()`/`exec()`/`fetchAll()` removed. +- **ORM 2.x (legacy)**: `partial` still parses but is deprecated — migrate to + DTO hydration before upgrading to 3.0. + + +## Skill Operating Checklist + +### Design checklist +- Confirm operation boundaries and invariants first. +- Minimize scope while preserving contract correctness. +- Test both happy path and negative path behavior. + +### Validation commands +- php bin/console doctrine:migrations:diff +- php bin/console doctrine:migrations:migrate +- ./vendor/bin/phpunit --filter=Doctrine + +### Failure modes to test +- Invalid payload or forbidden actor. +- Boundary values / not-found cases. +- Retry or partial-failure behavior for async flows. + diff --git a/.agents/skills/symfony-doctrine-fixtures-foundry/SKILL.md b/.agents/skills/symfony-doctrine-fixtures-foundry/SKILL.md new file mode 100644 index 0000000..5507648 --- /dev/null +++ b/.agents/skills/symfony-doctrine-fixtures-foundry/SKILL.md @@ -0,0 +1,42 @@ +--- + +name: symfony-doctrine-fixtures-foundry +allowed-tools: + - Read + - Write + - Edit + - Bash + - Glob + - Grep +description: Create test data with Zenstruck Foundry v2 factories (PersistentObjectFactory, real objects); define states, sequences, and relationships +--- + +# Doctrine Fixtures Foundry (Symfony) + +## Use when +- Designing entity relations or schema evolution. +- Improving Doctrine correctness/performance. + +## Default workflow +1. Model ownership/cardinality and transactional boundaries. +2. Apply mapping/schema changes with migration safety. +3. Tune fetch/query behavior for hot paths. +4. Verify lifecycle behavior with targeted tests. + +## Guardrails +- Keep owning/inverse sides coherent. +- Avoid destructive migration jumps in one release. +- Eliminate accidental N+1 and over-fetching. + +## Progressive disclosure +- Use this file for execution posture and risk controls. +- Open references when deep implementation details are needed. + +## Output contract +- Entity/migration changes. +- Integrity and performance decisions. +- Validation outcomes and rollback notes. + +## References +- `reference.md` +- `docs/complexity-tiers.md` diff --git a/.agents/skills/symfony-doctrine-fixtures-foundry/reference.md b/.agents/skills/symfony-doctrine-fixtures-foundry/reference.md new file mode 100644 index 0000000..499491b --- /dev/null +++ b/.agents/skills/symfony-doctrine-fixtures-foundry/reference.md @@ -0,0 +1,424 @@ +# Reference + +# Doctrine Fixtures with Foundry v2 + +Foundry provides modern, type-safe factories for creating test data and fixtures. + +> **Version applicability** — This reference targets **Zenstruck Foundry v2** (current). v2 is a major rewrite of v1: +> - Factories extend `PersistentObjectFactory` (was `Factory` / `ModelFactory` with `getEntityClass()` + `getDefaults()`). +> - Factories are **immutable**: state methods return **new** instances. +> - Factories return **real entity objects**, not `Proxy` wrappers. +> +> See "v1 → v2 migration" at the bottom if you encounter legacy code. + +## Installation + +```bash +composer require zenstruck/foundry --dev +# make:factory / make:story commands need MakerBundle: +composer require symfony/maker-bundle --dev +# optional, for non-Foundry fixture loading: +composer require doctrine/doctrine-fixtures-bundle --dev +``` + +## Creating Factories (v2) + +```bash +# Generate a factory for an entity +bin/console make:factory Post +``` + +```php + + */ +final class PostFactory extends PersistentObjectFactory +{ + // v2: static class() replaces v1 getEntityClass() + public static function class(): string + { + return Post::class; + } + + // v2: defaults() replaces v1 getDefaults(); may return array OR callable + protected function defaults(): array|callable + { + return [ + 'title' => self::faker()->text(255), + 'content' => self::faker()->paragraphs(3, true), + 'createdAt' => \DateTimeImmutable::createFromMutable( + self::faker()->dateTime() + ), + ]; + } + + // Optional global init hook (runs for every instance) + protected function initialize(): static + { + return $this->afterInstantiate(function (Post $post): void { + // mutate the instance before persist if needed + }); + } + + // Named states — IMMUTABLE: return a NEW instance via with() + public function published(): self + { + return $this->with(['publishedAt' => self::faker()->dateTime()]); + } +} +``` + +## Creation API + +Factories return **real entity objects** (no Proxy in v2). + +```php +use App\Factory\PostFactory; + +// Single entity +$post = PostFactory::createOne(['title' => 'Hello']); // returns Post + +// Multiple +$posts = PostFactory::createMany(5); // Post[] +$posts = PostFactory::createMany(5, ['title' => 'X']); +$posts = PostFactory::createMany(5, fn(int $i) => ['title' => "Title $i"]); + +// Find or create +$post = PostFactory::findOrCreate(['title' => 'Unique']); + +// Build without persisting via new() +$post = PostFactory::new()->withoutPersisting()->create(); +``` + +### Fluent / states (immutable) + +Each method returns a NEW factory instance — chain freely. + +```php +// with() overrides attributes +$post = PostFactory::new()->with(['title' => 'A'])->create(); + +// state methods compose +$post = PostFactory::new()->published()->create(); + +// many() + create() +$posts = PostFactory::new()->published()->many(3)->create(); +``` + +## Query API + +```php +PostFactory::first(); // PostFactory::first('createdAt') +PostFactory::last(); +PostFactory::count(); +PostFactory::all(); +PostFactory::find(5); // by id; or find(['title' => 'X']) +PostFactory::findBy(['published' => true]); +PostFactory::random(); +PostFactory::randomOrCreate(['title' => 'X']); +PostFactory::randomSet(4); +PostFactory::randomRange(0, 5); +``` + +## Relationships + +```php +// ManyToOne — reference the related object +CommentFactory::createOne(['post' => PostFactory::random()]); + +// OneToMany — pass a factory with many() +PostFactory::createOne(['comments' => CommentFactory::new()->many(6)]); + +// ManyToMany — random set +PostFactory::createOne(['tags' => TagFactory::randomSet(2)]); + +// Auto-create the related entity by default +protected function defaults(): array +{ + return [ + 'title' => self::faker()->sentence(), + 'author' => UserFactory::new(), // creates a User automatically + ]; +} +``` + +## Sequences & distribution (v2.4+) + +```php +PostFactory::createSequence([ + ['title' => 'First'], + ['title' => 'Second'], +]); + +PostFactory::new() + ->sequence([['title' => '1'], ['title' => '2']]) + ->distribute('category', $categories) // spread a value across instances + ->create(); + +PostFactory::new()->many(3)->applyStateMethod('published')->create(); +``` + +## Hooks + +Inline hooks (per factory): + +```php +protected function initialize(): static +{ + return $this + ->beforeInstantiate(fn(array $attrs) => $attrs) + ->afterInstantiate(function (Post $post): void { /* ... */ }) + // afterPersist listeners must manually flush() if they create entities + ->afterPersist(function (Post $post): void { + CommentFactory::createMany(3, ['post' => $post]); + }); +} +``` + +Global hooks via attribute (**v2.8+**) — place on a method receiving the event: + +```php +use Zenstruck\Foundry\Attribute\AsFoundryHook; +use Zenstruck\Foundry\Persistence\Hook\AfterPersist; + +final class GlobalHooks +{ + #[AsFoundryHook(Post::class)] // or #[AsFoundryHook] for all entities + public function afterPersist(AfterPersist $event): void + { + // ... + } +} +``` + +## Instantiation control + +```php +use Zenstruck\Foundry\Object\Instantiator; + +PostFactory::new()->instantiateWith( + Instantiator::withConstructor()->allowExtra('legacyField') +); +// Instantiator::withoutConstructor(), ->alwaysForce(...), ->namedConstructor(...) +// force($value) helper to force-set a single attribute +``` + +## Persistence control + +```php +$post = PostFactory::new()->withoutPersisting()->create(); +PostFactory::new()->withoutDoctrineEvents()->create(); +``` + +```yaml +# config/packages/zenstruck_foundry.yaml +when@dev: &dev + zenstruck_foundry: + # v2.5+: NOT enabling flush_once is DEPRECATED — set it true + persistence: + flush_once: true +when@test: *dev +``` + +Standalone helpers (`use function Zenstruck\Foundry\Persistence\*;`): + +```php +object(Post::class, ['title' => 'X']); +save($object); +persistent_factory(Post::class); +repository(Post::class); +assert_persisted($object); +assert_not_persisted($object); +``` + +## Stories + +```php +addState('first', PostFactory::createOne(['title' => 'First'])); + } +} +``` + +```php +PostStory::load(); // idempotent +$post = PostStory::get('first'); // retrieve a named state +``` + +Load fixture-tagged stories/factories (**v2.6+**): + +```bash +bin/console foundry:load-fixtures +bin/console foundry:load-fixtures --group=all +``` + +Non-Foundry DataFixtures still work; use the `#[AsFixture]` + `foundry:load-fixtures` +path when you want Foundry to own fixture loading. + +## Testing integration + +### PHPUnit 10+ / Foundry v2.9+ (recommended) + +Register the extension in `phpunit.dist.xml`: + +```xml + + + +``` + +Use the `#[ResetDatabase]` attribute on test classes that touch the DB: + +```php + 'Hello']); + + self::assertSame('Hello', $post->getTitle()); // real object, no ->object() + PostFactory::assert()->count(1); + } +} +``` + +### PHPUnit 9 (legacy) + +Use traits instead of the extension/attribute: + +```php +use Zenstruck\Foundry\Test\Factories; +use Zenstruck\Foundry\Test\ResetDatabase; + +final class PostFactoryTest extends KernelTestCase +{ + use Factories; + use ResetDatabase; // resets the DB before each test +} +``` + +### Reset configuration + +```yaml +# config/packages/zenstruck_foundry.yaml +zenstruck_foundry: + orm: + reset: + mode: schema # or 'migrate' to run migrations instead of drop/create +``` + +With **DAMADoctrineTestBundle**, the global state is loaded once per suite (inside a +transaction rolled back per test) instead of being reset per test — much faster. + +## Assertions + +```php +assert_persisted($object); +PostFactory::assert()->count(3); +PostFactory::assert()->exists(['title' => 'Hello']); +PostFactory::assert()->empty(); +``` + +## Faker + +```php +use function Zenstruck\Foundry\faker; + +$email = faker()->email(); +// in a factory: self::faker()->email() +``` + +```yaml +zenstruck_foundry: + faker: + locale: fr_FR +# Deterministic data: FOUNDRY_FAKER_SEED=1234 +``` + +## Best practices + +1. **Minimal defaults** — only set required/unique fields; use `self::faker()->unique()` for uniques. +2. **Named states** — express variations (`admin()`, `published()`) as immutable state methods. +3. **Let factories build relations** — default related entities via `XFactory::new()`. +4. **Real objects** — in v2 you call entity methods directly; no `->object()` / `->_real()`. +5. **Don't over-factory** — trivial data can be created inline. + +```php +// Good: minimal, realistic, unique +protected function defaults(): array +{ + return [ + 'email' => self::faker()->unique()->safeEmail(), + 'roles' => ['ROLE_USER'], + ]; +} +``` + +## v1 → v2 migration (legacy code you may encounter) + +| v1 | v2 | +| --- | --- | +| `extends ModelFactory` / `Factory` | `extends PersistentObjectFactory` | +| `protected static function getClass(): string` | `public static function class(): string` | +| `protected function getDefaults(): array` | `protected function defaults(): array\|callable` | +| Mutating `$this` in states | Immutable: state methods return a new instance | +| Returns `Proxy`, call `->object()` | Returns real `T`, call methods directly | +| `$proxy->object()` | the object itself | + +### Deprecations (v2.7+) + +- The **object Proxy mechanism is deprecated** → use auto-refresh + (requires **PHP 8.4** + `persistence.enable_auto_refresh_with_lazy_objects: true`). +- `PersistentProxyObjectFactory` is **deprecated** → use `PersistentObjectFactory`. + Proxy helpers (`_real()`, `_save()`, `_refresh()`, `_set()`, `_get()`, + `_enableAutoRefresh()`) still work but are deprecated. +- Not enabling `flush_once` is **deprecated** (v2.5+). + + +## Skill Operating Checklist + +### Design checklist +- Confirm operation boundaries and invariants first. +- Minimize scope while preserving contract correctness. +- Test both happy path and negative path behavior. + +### Validation commands +- php bin/console doctrine:migrations:diff +- php bin/console doctrine:migrations:migrate +- ./vendor/bin/phpunit --filter=Doctrine + +### Failure modes to test +- Invalid payload or forbidden actor. +- Boundary values / not-found cases. +- Retry or partial-failure behavior for async flows. diff --git a/.agents/skills/symfony-doctrine-migrations/SKILL.md b/.agents/skills/symfony-doctrine-migrations/SKILL.md new file mode 100644 index 0000000..98b181f --- /dev/null +++ b/.agents/skills/symfony-doctrine-migrations/SKILL.md @@ -0,0 +1,41 @@ +--- +name: symfony-doctrine-migrations +allowed-tools: + - Read + - Write + - Edit + - Bash + - Glob + - Grep +description: Create and manage Doctrine migrations (lib 4.x) for schema versioning; handle dependencies, rollbacks, and production deployment +--- + +# Doctrine Migrations (Symfony) + +## Use when +- Designing entity relations or schema evolution. +- Improving Doctrine correctness/performance. + +## Default workflow +1. Model ownership/cardinality and transactional boundaries. +2. Apply mapping/schema changes with migration safety. +3. Tune fetch/query behavior for hot paths. +4. Verify lifecycle behavior with targeted tests. + +## Guardrails +- Keep owning/inverse sides coherent. +- Avoid destructive migration jumps in one release. +- Eliminate accidental N+1 and over-fetching. + +## Progressive disclosure +- Use this file for execution posture and risk controls. +- Open references when deep implementation details are needed. + +## Output contract +- Entity/migration changes. +- Integrity and performance decisions. +- Validation outcomes and rollback notes. + +## References +- `reference.md` +- `docs/complexity-tiers.md` diff --git a/.agents/skills/symfony-doctrine-migrations/reference.md b/.agents/skills/symfony-doctrine-migrations/reference.md new file mode 100644 index 0000000..c10bdcb --- /dev/null +++ b/.agents/skills/symfony-doctrine-migrations/reference.md @@ -0,0 +1,150 @@ +# Doctrine Migrations Reference (Symfony) + +Use this reference for implementation details and review criteria specific to `doctrine-migrations`. + +Versions (target): **DoctrineMigrationsBundle `^3.0`**, which wraps the +**`doctrine/migrations` library 4.0.x** (5.0 in development). Migration classes +use the typed, `void`-returning signatures `up(Schema $schema): void` / +`down(Schema $schema): void`. + +## Install + +```bash +composer require doctrine/doctrine-migrations-bundle "^3.0" +``` + +## Typical workflow + +```bash +# 1. Change your entity mapping (#[ORM\...]), then generate the diff +php bin/console make:migration # MakerBundle wrapper around diff +# or: +php bin/console doctrine:migrations:diff + +# 2. Review the generated up()/down() SQL (NEVER trust the diff blindly) + +# 3. Apply +php bin/console doctrine:migrations:migrate + +# Useful neighbours +php bin/console doctrine:migrations:status +php bin/console doctrine:migrations:migrate prev # roll back one version +php bin/console doctrine:migrations:execute 'DoctrineMigrations\Version20260617120000' --down +``` + +## Migration class + +```php +addSql('ALTER TABLE product ADD status VARCHAR(20) NOT NULL DEFAULT \'draft\''); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE product DROP status'); + } +} +``` + +`down()` must reverse `up()`. A migration without a real `down()` cannot be +rolled back safely — write it even when it feels redundant. + +## Configuration (`config/packages/doctrine_migrations.yaml`) + +```yaml +doctrine_migrations: + migrations_paths: + 'DoctrineMigrations': '%kernel.project_dir%/migrations' + enable_profiler: false + + # Wrap EACH migration in its own transaction (default true). + transactional: true + # Run ALL pending migrations inside ONE transaction (default false). + # On MySQL most DDL is non-transactional (implicit commit), so this is + # mainly effective on PostgreSQL. + all_or_nothing: false + + check_database_platform: true + organize_migrations: false # or BY_YEAR / BY_YEAR_AND_MONTH +``` + +Key options: + +| Option | Default | Purpose | +|--------|---------|---------| +| `migrations_paths` | — | namespace → path pairs (required) | +| `transactional` | `true` | wrap each migration in a transaction | +| `all_or_nothing` | `false` | run all pending migrations in one transaction | +| `em` | `default` | entity manager (overrides `connection`) | +| `check_database_platform` | `true` | verify the DB type matches | +| `enable_service_migrations` | `false` | allow migrations to be fetched from the container (DI) | + +## Multiple entity managers + +Each EM has its own migration history. Pass `--em` to every command: + +```bash +php bin/console doctrine:migrations:diff --em=customer +php bin/console doctrine:migrations:migrate --em=customer +``` + +When you have a non-default EM, also set `em:` (or a dedicated path) in +`doctrine_migrations.yaml`, and in prod redefine the default EM in +`config/packages/prod/doctrine.yaml`. + +## Best practices + +1. **Review the diff before applying.** `diff` reflects mapping vs current DB; + it can emit destructive or surprising DDL (renames seen as drop+add, index + churn). Read every `addSql()` line. +2. **Schema only — no data migrations in schema migrations.** Mixing data + changes into a `diff`-generated migration breaks reversibility and gets + clobbered on the next `diff`. For data, use a dedicated, hand-written + migration or a one-off command, and keep it idempotent. +3. **Always write `down()`** so the change is reversible. +4. **One concern per migration.** Easier to review, revert, and reason about. +5. **Never edit an already-applied migration.** Add a new one instead. +6. **Service migrations** (`enable_service_migrations: true`) when a migration + genuinely needs a service — tag it `doctrine_migrations.migration` and call + `parent::__construct($connection, $logger)`. + +## Applicability + +- **Target**: bundle `^3.0` + library 4.0.x, typed `up/down(Schema): void`. +- The signatures and `transactional`/`all_or_nothing` options shown apply to + library 3.x and 4.x alike. + + +## Skill Operating Checklist + +### Design checklist +- Confirm operation boundaries and invariants first. +- Minimize scope while preserving contract correctness. +- Test both happy path and negative path behavior. + +### Validation commands +- php bin/console doctrine:migrations:diff +- php bin/console doctrine:migrations:migrate +- ./vendor/bin/phpunit --filter=Doctrine + +### Failure modes to test +- Invalid payload or forbidden actor. +- Boundary values / not-found cases. +- Retry or partial-failure behavior for async flows. diff --git a/.agents/skills/symfony-doctrine-relations/SKILL.md b/.agents/skills/symfony-doctrine-relations/SKILL.md new file mode 100644 index 0000000..ae82bc9 --- /dev/null +++ b/.agents/skills/symfony-doctrine-relations/SKILL.md @@ -0,0 +1,41 @@ +--- +name: symfony-doctrine-relations +allowed-tools: + - Read + - Write + - Edit + - Bash + - Glob + - Grep +description: Define Doctrine entity relationships (OneToMany, ManyToMany, ManyToOne); configure cascade, orphan removal, multiple entity managers; prevent N+1 queries +--- + +# Doctrine Relations (Symfony) + +## Use when +- Designing entity relations or schema evolution. +- Improving Doctrine correctness/performance. + +## Default workflow +1. Model ownership/cardinality and transactional boundaries. +2. Apply mapping/schema changes with migration safety. +3. Tune fetch/query behavior for hot paths. +4. Verify lifecycle behavior with targeted tests. + +## Guardrails +- Keep owning/inverse sides coherent. +- Avoid destructive migration jumps in one release. +- Eliminate accidental N+1 and over-fetching. + +## Progressive disclosure +- Use this file for execution posture and risk controls. +- Open references when deep implementation details are needed. + +## Output contract +- Entity/migration changes. +- Integrity and performance decisions. +- Validation outcomes and rollback notes. + +## References +- `reference.md` +- `docs/complexity-tiers.md` diff --git a/.agents/skills/symfony-doctrine-relations/reference.md b/.agents/skills/symfony-doctrine-relations/reference.md new file mode 100644 index 0000000..52a2e2b --- /dev/null +++ b/.agents/skills/symfony-doctrine-relations/reference.md @@ -0,0 +1,265 @@ +# Doctrine Relations Reference (Symfony) + +Use this reference for implementation details and review criteria specific to `doctrine-relations`. + +Mapping is done with PHP attributes (`#[ORM\...]`). The mapping API is stable +across ORM 2.x/3.x; on ORM 3 the `targetEntity` can also be inferred from the +property type-hint, but passing it explicitly stays valid and is clearer. + +## Owning vs Inverse side + +| | Owning side | Inverse side | +|--|-------------|--------------| +| Mapping | `ManyToOne` (or owning `ManyToMany`/`OneToOne`) | `OneToMany` (or inverse side) | +| Database | holds the foreign key | no column | +| Updates DB | yes | only if the owning side is updated | + +The single most common Doctrine bug: setting the relation on the **inverse** +side only. Doctrine persists what the **owning** side holds. Always set the +owning side (the `ManyToOne` reference). + +## ManyToOne / OneToMany (the common pair) + +### Owning side — `Product` holds the FK + +```php +category; + } + + public function setCategory(?Category $category): self + { + $this->category = $category; + + return $this; + } +} +``` + +### Inverse side — `Category` owns a collection + +```php + */ + #[ORM\OneToMany(targetEntity: Product::class, mappedBy: 'category', orphanRemoval: true)] + private Collection $products; + + public function __construct() + { + $this->products = new ArrayCollection(); + } + + /** @return Collection */ + public function getProducts(): Collection + { + return $this->products; + } + + public function addProduct(Product $product): self + { + if (!$this->products->contains($product)) { + $this->products[] = $product; + $product->setCategory($this); // keep both sides in sync + } + + return $this; + } + + public function removeProduct(Product $product): self + { + if ($this->products->removeElement($product)) { + // unset the owning side only if it points here + if ($product->getCategory() === $this) { + $product->setCategory(null); + } + } + + return $this; + } +} +``` + +### Persisting — set the owning side, then flush + +```php +$product->setCategory($category); // owning side carries the FK +$em->persist($category); +$em->persist($product); +$em->flush(); +``` + +## orphanRemoval vs cascade + +- **`cascade: ['persist', 'remove']`** — operations on the parent propagate to + the associated entities. Useful for aggregate roots you always save together. + Avoid `cascade: ['remove']` on large collections (issues a DELETE per row). +- **`orphanRemoval: true`** — when a child is *removed from the collection* (no + longer referenced by the parent), Doctrine deletes it. Use it for true + composition (a `Product` cannot exist without its `Category`), not for + shared entities. + +```php +#[ORM\OneToMany(targetEntity: OrderItem::class, mappedBy: 'order', + cascade: ['persist'], orphanRemoval: true)] +private Collection $items; +``` + +## Fetching — avoid N+1 + +```php +// LAZY (default): triggers one extra query per access +$post = $em->find(Post::class, 1); +$post->getAuthor()->getName(); // 2nd query here + +// Fetch join: one query, relation hydrated +$post = $repository->createQueryBuilder('p') + ->addSelect('a') + ->leftJoin('p.author', 'a') + ->where('p.id = :id') + ->setParameter('id', $id) + ->getQuery() + ->getOneOrNullResult(); +``` + +## Pitfall: `contains()` on large inverse collections + +On a plain `OneToMany`, calling `$category->getProducts()->contains($product)` +forces Doctrine to **hydrate the entire collection** into memory before +checking — disastrous for collections with thousands of rows. + +```php +// BAD on a huge collection — loads everything +if ($category->getProducts()->contains($product)) { /* ... */ } +``` + +Mitigations: + +- Mark the collection `fetch: 'EXTRA_LAZY'` so `contains()`, `count()` and + `slice()` issue targeted SQL (`EXISTS`, `COUNT`, `LIMIT`) instead of loading + the whole collection: + + ```php + #[ORM\OneToMany(targetEntity: Product::class, mappedBy: 'category', fetch: 'EXTRA_LAZY')] + private Collection $products; + ``` + +- Or check membership from the owning side: `$product->getCategory() === $category`. + +Always review the generated `add*/remove*` helpers (from `make:entity`) — they +call `contains()` and become a hot spot on large inverse collections. + +## Multiple Entity Managers + +A relation may **not** span two entity managers. When the schema is split, each +EM owns its own set of entities. + +```yaml +# config/packages/doctrine.yaml +doctrine: + dbal: + connections: + default: { url: '%env(resolve:DATABASE_URL)%' } + customer: { url: '%env(resolve:CUSTOMER_DATABASE_URL)%' } + default_connection: default + orm: + default_entity_manager: default + entity_managers: + default: + connection: default + mappings: + Main: + is_bundle: false + dir: '%kernel.project_dir%/src/Entity/Main' + prefix: 'App\Entity\Main' + customer: + connection: customer + mappings: + Customer: + is_bundle: false + dir: '%kernel.project_dir%/src/Entity/Customer' + prefix: 'App\Entity\Customer' +``` + +### Autowiring a named EM (by variable name) + +The FrameworkBundle registers an alias per EM. Type-hint +`EntityManagerInterface` and **name the variable after the EM** to inject the +right one: + +```php +use Doctrine\ORM\EntityManagerInterface; + +public function __construct( + private EntityManagerInterface $entityManager, // default EM + private EntityManagerInterface $customerEntityManager, // "customer" EM +) {} +``` + +> Relying on the variable name for autowiring resolution is convenient, but be +> aware Symfony 8.1+ deprecates name-based alias resolution in the general case; +> for Doctrine EMs the named alias is still the documented mechanism. Use +> `ManagerRegistry::getManager('customer')` when you need it explicitly. + +### Repositories with multiple EMs + +`getRepository()` takes the EM name as a second argument: + +```php +use Doctrine\Persistence\ManagerRegistry; + +$default = $registry->getRepository(Product::class); // default EM +$customer = $registry->getRepository(Customer::class, 'customer'); // named EM +``` + +For a custom repository on an entity managed by a non-default EM, extend +`Doctrine\ORM\EntityRepository` (**not** `ServiceEntityRepository`, whose +constructor resolves the EM from the entity class via the default manager) and +obtain it through `ManagerRegistry::getRepository()`. + +### Console / migrations with `--em` + +```bash +php bin/console doctrine:database:create --connection=customer +php bin/console doctrine:migrations:diff --em=customer +php bin/console doctrine:migrations:migrate --em=customer +``` + + +## Skill Operating Checklist + +### Design checklist +- Confirm operation boundaries and invariants first. +- Minimize scope while preserving contract correctness. +- Test both happy path and negative path behavior. + +### Validation commands +- php bin/console doctrine:migrations:diff +- php bin/console doctrine:migrations:migrate +- ./vendor/bin/phpunit --filter=Doctrine + +### Failure modes to test +- Invalid payload or forbidden actor. +- Boundary values / not-found cases. +- Retry or partial-failure behavior for async flows. diff --git a/.agents/skills/symfony-doctrine-transactions/SKILL.md b/.agents/skills/symfony-doctrine-transactions/SKILL.md new file mode 100644 index 0000000..2b2b5e2 --- /dev/null +++ b/.agents/skills/symfony-doctrine-transactions/SKILL.md @@ -0,0 +1,42 @@ +--- + +name: symfony-doctrine-transactions +allowed-tools: + - Read + - Write + - Edit + - Bash + - Glob + - Grep +description: Handle Doctrine transactions (ORM 3 wrapInTransaction), optimistic/pessimistic locking, flush strategies, and transaction boundaries +--- + +# Doctrine Transactions (Symfony) + +## Use when +- Designing entity relations or schema evolution. +- Improving Doctrine correctness/performance. + +## Default workflow +1. Model ownership/cardinality and transactional boundaries. +2. Apply mapping/schema changes with migration safety. +3. Tune fetch/query behavior for hot paths. +4. Verify lifecycle behavior with targeted tests. + +## Guardrails +- Keep owning/inverse sides coherent. +- Avoid destructive migration jumps in one release. +- Eliminate accidental N+1 and over-fetching. + +## Progressive disclosure +- Use this file for execution posture and risk controls. +- Open references when deep implementation details are needed. + +## Output contract +- Entity/migration changes. +- Integrity and performance decisions. +- Validation outcomes and rollback notes. + +## References +- `reference.md` +- `docs/complexity-tiers.md` diff --git a/.agents/skills/symfony-doctrine-transactions/reference.md b/.agents/skills/symfony-doctrine-transactions/reference.md new file mode 100644 index 0000000..5605b1e --- /dev/null +++ b/.agents/skills/symfony-doctrine-transactions/reference.md @@ -0,0 +1,370 @@ +# Reference + +# Doctrine Transactions + +## Basic Transactions + +### Implicit Transactions + +By default, Doctrine wraps each `flush()` in a transaction: + +```php +$user = new User(); +$user->setEmail('test@example.com'); + +$em->persist($user); +$em->flush(); // Auto-commits in transaction +``` + +### Explicit Transactions + +For multiple operations that must succeed or fail together: + +```php +em->beginTransaction(); + + try { + // Create order + $order = new Order(); + $order->setCustomer($user); + $order->setStatus(OrderStatus::PENDING); + + foreach ($items as $item) { + $orderItem = new OrderItem(); + $orderItem->setProduct($item['product']); + $orderItem->setQuantity($item['quantity']); + $order->addItem($orderItem); + } + + $this->em->persist($order); + + // Create payment + $payment = new Payment(); + $payment->setOrder($order); + $payment->setAmount($order->getTotal()); + $this->em->persist($payment); + + $this->em->flush(); + $this->em->commit(); + + return $order; + + } catch (\Exception $e) { + $this->em->rollback(); + throw $e; + } + } +} +``` + +### Using the wrapInTransaction Helper + +Cleaner approach. `wrapInTransaction()` flushes before commit and closes the +EntityManager if the callback throws: + +```php +public function createOrder(User $user, array $items): Order +{ + return $this->em->wrapInTransaction(function () use ($user, $items) { + $order = new Order(); + $order->setCustomer($user); + + foreach ($items as $item) { + $order->addItem(new OrderItem($item)); + } + + $this->em->persist($order); + + return $order; + }); +} +``` + +> **ORM 3 breaking change**: `EntityManager::transactional()` was deprecated in +> ORM 2.9 and **removed in ORM 3.0**. Use `wrapInTransaction()` (available since +> ORM 2.9). They share the same signature, so the migration is a rename. Any +> remaining `$em->transactional(...)` call will fatal on ORM 3.x. + +### DBAL-level Transactions + +On the DBAL `Connection`, `transactional()` is **not** affected by the ORM 3 +removal — it remains valid for raw SQL / DML that does not go through the +EntityManager: + +```php +use Doctrine\DBAL\Connection; + +$connection->transactional(function (Connection $conn): void { + $conn->executeStatement('UPDATE product SET price = price * 0.9'); +}); +``` + +Note this does not flush the ORM unit of work — use it only for DBAL-level work. + +## Flush Strategies + +### Single Flush (Recommended) + +```php +// Good: Single flush for all changes +$user = new User(); +$user->setEmail('test@example.com'); +$em->persist($user); + +$profile = new Profile(); +$profile->setUser($user); +$em->persist($profile); + +$em->flush(); // One transaction, one commit +``` + +### Avoid Multiple Flushes + +```php +// Bad: Multiple flushes = multiple transactions +$user = new User(); +$em->persist($user); +$em->flush(); // Transaction 1 + +$profile = new Profile(); +$profile->setUser($user); +$em->persist($profile); +$em->flush(); // Transaction 2 - not atomic! +``` + +### Flush Only When Needed + +```php +// Service layer flushes +class UserService +{ + public function register(string $email): User + { + $user = new User(); + $user->setEmail($email); + $this->em->persist($user); + $this->em->flush(); // Service controls transaction boundary + return $user; + } +} + +// Controller doesn't flush +class UserController +{ + #[Route('/register', methods: ['POST'])] + public function register(Request $request, UserService $service): Response + { + $user = $service->register($request->get('email')); + return new Response('Created', 201); + } +} +``` + +## Optimistic Locking + +Prevent concurrent modification conflicts with a `#[ORM\Version]` column +(`integer` or `datetime`/`datetime_immutable`). Version numbers are preferred +over timestamps in high-concurrency scenarios: + +```php +version; + } +} +``` + +Usage: + +```php +use Doctrine\ORM\OptimisticLockException; + +public function updateArticle(int $id, string $content, int $expectedVersion): void +{ + $article = $this->em->find(Article::class, $id); + + // Lock with expected version + $this->em->lock($article, LockMode::OPTIMISTIC, $expectedVersion); + + $article->setContent($content); + + try { + $this->em->flush(); + } catch (OptimisticLockException $e) { + // Version mismatch - someone else modified it + throw new ConflictException('Article was modified by another user'); + } +} +``` + +## Pessimistic Locking + +Lock rows in database: + +```php +use Doctrine\DBAL\LockMode; + +public function processPayment(int $orderId): void +{ + $this->em->beginTransaction(); + + try { + // Lock the row for update + $order = $this->em->find( + Order::class, + $orderId, + LockMode::PESSIMISTIC_WRITE + ); + + if ($order->getStatus() !== OrderStatus::PENDING) { + throw new \Exception('Order already processed'); + } + + $order->setStatus(OrderStatus::PROCESSING); + $this->em->flush(); + $this->em->commit(); + + } catch (\Exception $e) { + $this->em->rollback(); + throw $e; + } +} +``` + +Lock modes: +- `PESSIMISTIC_READ`: Shared lock (SELECT ... FOR SHARE) +- `PESSIMISTIC_WRITE`: Exclusive lock (SELECT ... FOR UPDATE) + +## Error Handling + +### Connection Lost + +```php +use Doctrine\DBAL\Exception\ConnectionLost; + +try { + $this->em->flush(); +} catch (ConnectionLost $e) { + // Reconnect and retry + $this->em->getConnection()->connect(); + $this->em->flush(); +} +``` + +### Constraint Violations + +```php +use Doctrine\DBAL\Exception\UniqueConstraintViolationException; + +try { + $user = new User(); + $user->setEmail($email); + $this->em->persist($user); + $this->em->flush(); +} catch (UniqueConstraintViolationException $e) { + throw new DuplicateEmailException('Email already exists'); +} +``` + +## EntityManager State + +### After Exception + +After a rollback, the EntityManager may be in an inconsistent state: + +```php +try { + $this->em->flush(); +} catch (\Exception $e) { + $this->em->rollback(); + + // Clear the EntityManager + $this->em->clear(); + + // Re-fetch entities if needed + $user = $this->em->find(User::class, $userId); +} +``` + +### Clearing EntityManager + +```php +// Clear all managed entities +$this->em->clear(); + +// Clear specific entity type +$this->em->clear(User::class); +``` + +## Best Practices + +1. **Single flush per operation**: Group related changes +2. **Service layer transactions**: Controllers don't manage transactions +3. **Use `wrapInTransaction()`**: Cleaner than try/catch (replaces the removed `transactional()`) +4. **Optimistic locking**: For concurrent editing scenarios +5. **Clear after rollback**: Reset EntityManager state +6. **Short transactions**: Don't hold locks too long + +```php +// Good pattern +class OrderService +{ + public function createOrder(CreateOrderDTO $dto): Order + { + return $this->em->wrapInTransaction(function () use ($dto) { + $order = new Order(); + // ... build order + $this->em->persist($order); + return $order; + }); + } +} +``` + +## Applicability + +- **ORM 3.x / DBAL 4.x** (target): `wrapInTransaction()` only; + `EntityManager::transactional()` no longer exists. +- **ORM 2.9+ (legacy)**: both `wrapInTransaction()` and `transactional()` exist; + prefer `wrapInTransaction()` so the code survives the 3.0 upgrade. +- `Connection::transactional()` (DBAL) is valid across all versions. + + +## Skill Operating Checklist + +### Design checklist +- Confirm operation boundaries and invariants first. +- Minimize scope while preserving contract correctness. +- Test both happy path and negative path behavior. + +### Validation commands +- php bin/console doctrine:migrations:diff +- php bin/console doctrine:migrations:migrate +- ./vendor/bin/phpunit --filter=Doctrine + +### Failure modes to test +- Invalid payload or forbidden actor. +- Boundary values / not-found cases. +- Retry or partial-failure behavior for async flows. + diff --git a/.agents/skills/symfony-e2e-panther-playwright/SKILL.md b/.agents/skills/symfony-e2e-panther-playwright/SKILL.md new file mode 100644 index 0000000..c72d5a1 --- /dev/null +++ b/.agents/skills/symfony-e2e-panther-playwright/SKILL.md @@ -0,0 +1,39 @@ +--- + +name: symfony-e2e-panther-playwright +allowed-tools: + - Read + - Glob + - Grep +description: Write end-to-end tests with Symfony Panther 2.4 for browser automation or Playwright for complex scenarios +--- + +# E2e Panther Playwright (Symfony) + +## Use when +- Building regression-safe behavior with TDD/functional/e2e tests. +- Converting bug reports into executable failing tests. + +## Default workflow +1. Write failing test for target behavior and one boundary case. +2. Implement minimal code to pass. +3. Refactor while preserving green suite. +4. Broaden coverage for invalid/unauthorized/not-found paths. + +## Guardrails +- Prefer deterministic fixtures/builders. +- Assert observable behavior, not internal implementation. +- Keep tests isolated and stable in CI. + +## Progressive disclosure +- Use this file for execution posture and risk controls. +- Open references when deep implementation details are needed. + +## Output contract +- RED/GREEN/REFACTOR trace. +- Test files changed and executed commands. +- Coverage and confidence notes. + +## References +- `reference.md` +- `docs/complexity-tiers.md` diff --git a/.agents/skills/symfony-e2e-panther-playwright/reference.md b/.agents/skills/symfony-e2e-panther-playwright/reference.md new file mode 100644 index 0000000..1e94c93 --- /dev/null +++ b/.agents/skills/symfony-e2e-panther-playwright/reference.md @@ -0,0 +1,342 @@ +# Reference + +# End-to-End Testing + +> **Version applicability** — Panther section targets **symfony/panther v2.4.0** (2026-01, +> Symfony 7.x/8.x). Panther drives a **real browser** (W3C WebDriver: Chrome/Firefox) for +> E2E tests and scraping. The Playwright section is an alternative for richer JS scenarios. + +## Symfony Panther + +### Installation + +```bash +composer require --dev symfony/panther +# bdi auto-downloads the matching ChromeDriver / geckodriver: +composer require --dev dbrekelmans/bdi +vendor/bin/bdi detect drivers +``` + +Register the `ServerExtension` (manages the test web server; improves perf + enables +`--debug`) in `phpunit.dist.xml`: + +```xml + + + + + +``` + +### Basic test + +```php +request('GET', '/'); + + $this->assertPageTitleContains('Home'); + $this->assertSelectorTextContains('h1', 'Welcome'); + } +} +``` + +### Client creation + +```php +static::createPantherClient(); // real browser, JS (Chrome default) +static::createPantherClient(['browser' => static::FIREFOX]); // Firefox +static::createClient(); // BrowserKit — fast, NO JS +static::createHttpBrowserClient(); // real HTTP, no browser +static::createAdditionalPantherClient(); // isolated 2nd browser (realtime/multi-user apps) +// Standalone: Client::createChromeClient(), Client::createFirefoxClient(), +// Client::createSeleniumClient('http://127.0.0.1:4444/wd/hub') +``` + +### Form interaction + +```php +public function testContactForm(): void +{ + $client = static::createPantherClient(); + $crawler = $client->request('GET', '/contact'); + + $form = $crawler->selectButton('Send')->form([ + 'contact[name]' => 'John Doe', + 'contact[email]' => 'john@example.com', + 'contact[message]' => 'Hello!', + ]); + $client->submit($form); + + $client->waitFor('.alert-success'); + $this->assertSelectorTextContains('.alert-success', 'Message sent'); +} +``` + +### Waiting for elements + +Panther exposes explicit waits — never rely on implicit timing. + +```php +$client->waitFor('.product-list'); // exists in DOM +$client->waitFor('.slow-content', 10); // custom timeout (s) +$client->waitForVisibility('#x'); +$client->waitForInvisibility('.loading-spinner'); +$client->waitForStaleness('.popin'); +$client->waitForElementToContain('.total', '25 €'); +$client->waitForElementToNotContain('.total', '0 €'); +$client->waitForEnabled('[type=submit]'); +$client->waitForDisabled('[type=submit]'); +$client->waitForAttributeToContain('.price', 'data-old-price', '25 €'); +``` + +### Assertions + +```php +// Immediate state +$this->assertSelectorIsVisible('.dropdown-menu'); +$this->assertSelectorIsNotVisible('.modal'); +$this->assertSelectorIsEnabled('[type=submit]'); +$this->assertSelectorIsDisabled('[type=submit]'); +$this->assertSelectorAttributeContains('.price', 'data-currency', 'EUR'); + +// Future state — wait then assert (Panther-specific) +$this->assertSelectorWillExist('.product-card'); +$this->assertSelectorWillNotExist('.loading'); +$this->assertSelectorWillBeVisible('.toast'); +$this->assertSelectorWillBeEnabled('[type=submit]'); +$this->assertSelectorWillContain('.status', 'Complete'); +$this->assertSelectorAttributeWillContain('.price', 'data-old-price', '25 €'); +``` + +### Scripting & screenshots + +```php +$title = $client->executeScript('return document.title;'); +$client->takeScreenshot('var/screenshots/page.png'); +$logs = $client->getWebDriver()->manage()->getLog('browser'); // JS console +``` + +> Screenshots are debris — delete any `*.png` produced by `takeScreenshot()` or by +> `PANTHER_ERROR_SCREENSHOT_DIR` at the end of the run. Never commit them. + +### Config & environment + +Client options: `hostname` (127.0.0.1), `port` (9080), `external_base_uri`, `browser`. + +Env vars: `PANTHER_NO_HEADLESS`, `PANTHER_WEB_SERVER_DIR` (`./public/`), +`PANTHER_WEB_SERVER_PORT` (9080), `PANTHER_EXTERNAL_BASE_URI`, `PANTHER_APP_ENV`, +`PANTHER_ERROR_SCREENSHOT_DIR`, `PANTHER_NO_SANDBOX`, `PANTHER_CHROME_ARGUMENTS`, +`PANTHER_CHROME_BINARY`, `PANTHER_FIREFOX_ARGUMENTS`, `PANTHER_NO_REDUCED_MOTION`. + +```php +// Custom Chrome args: +Client::createChromeClient(null, ['--window-size=1500,4000']); +``` + +### Debug + +```bash +# Pauses the browser on failure for visual inspection (needs ServerExtension) +PANTHER_NO_HEADLESS=1 bin/phpunit --debug +``` + +### Limitations + +HTML only (no XML crawling); no DOM updating from the crawler; no multidimensional array +form values; returns `WebDriverElement`, not `\DOMElement`; Bootstrap 5 smooth-scroll can +interfere (`$enable-smooth-scroll: false`). + +## Playwright (alternative) + +For richer cross-browser JS scenarios (WebKit, tracing, video), run Playwright separately +against a running app. Use this when Panther's WebDriver model is too limiting. + +### JavaScript Playwright tests + +```javascript +// tests/e2e/login.spec.js +const { test, expect } = require('@playwright/test'); + +test('user can login', async ({ page }) => { + await page.goto('/login'); + + await page.fill('input[name="email"]', 'test@example.com'); + await page.fill('input[name="password"]', 'password'); + await page.click('button[type="submit"]'); + + await expect(page).toHaveURL('/dashboard'); + await expect(page.locator('h1')).toContainText('Dashboard'); +}); + +test('login validation', async ({ page }) => { + await page.goto('/login'); + + await page.fill('input[name="email"]', 'invalid'); + await page.click('button[type="submit"]'); + + await expect(page.locator('.error-message')).toBeVisible(); +}); +``` + +### Playwright configuration + +```javascript +// playwright.config.js +module.exports = { + testDir: './tests/e2e', + timeout: 30000, + use: { + baseURL: 'http://localhost:8000', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + }, + projects: [ + { name: 'chromium', use: { browserName: 'chromium' } }, + { name: 'firefox', use: { browserName: 'firefox' } }, + { name: 'webkit', use: { browserName: 'webkit' } }, + ], +}; +``` + +## Testing user flows + +### Panther: complete checkout flow + +```php +public function testCheckoutFlow(): void +{ + $client = static::createPantherClient(); + + // 1. Login + $client->request('GET', '/login'); + $client->submitForm('Login', [ + 'email' => 'test@example.com', + 'password' => 'password', + ]); + $client->waitFor('.dashboard'); + + // 2. Browse products + $client->clickLink('Products'); + $client->waitFor('.product-list'); + + // 3. Add to cart + $client->click('.product-card:first-child .add-to-cart'); + $client->waitForElementToContain('.cart-count', '1'); + + // 4. Cart + $client->clickLink('Cart'); + $client->waitFor('.cart-items'); + + // 5. Checkout + $client->clickLink('Checkout'); + $client->waitFor('.checkout-form'); + + // 6. Shipping + $client->submitForm('Continue', [ + 'shipping[address]' => '123 Main St', + 'shipping[city]' => 'Paris', + 'shipping[zip]' => '75001', + ]); + + // 7. Confirm + $client->waitFor('.order-summary'); + $client->click('.confirm-order'); + + // 8. Verify + $this->assertSelectorWillContain('.order-confirmation', 'Thank you'); +} +``` + +## CI configuration + +### GitHub Actions with Panther + +```yaml +# .github/workflows/e2e.yml +name: E2E Tests + +on: [push, pull_request] + +jobs: + e2e: + runs-on: ubuntu-latest + + services: + database: + image: postgres:16 + env: + POSTGRES_PASSWORD: test + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - uses: actions/checkout@v6 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: pdo_pgsql + + - name: Install Chrome + uses: browser-actions/setup-chrome@latest + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist --no-progress + + - name: Detect WebDriver + run: vendor/bin/bdi detect drivers + + - name: Setup database + run: | + bin/console doctrine:database:create --env=test + bin/console doctrine:migrations:migrate --no-interaction --env=test + bin/console doctrine:fixtures:load --no-interaction --env=test + + - name: Run E2E tests + run: ./vendor/bin/phpunit tests/E2E + env: + PANTHER_NO_SANDBOX: 1 +``` + +## Best practices + +1. **Test critical paths**: login, checkout, signup. +2. **Explicit waits**: prefer `waitFor*` / `assertSelectorWill*` over implicit timing. +3. **Stable selectors**: target `data-testid` attributes, not styling classes. +4. **Separate from unit tests**: E2E is slow — keep it in its own suite/dir. +5. **Reset state**: clean the database between tests (DAMADoctrineTestBundle / Foundry reset). +6. **Clean up screenshots**: delete any PNGs produced; never commit them. + + +## Skill Operating Checklist + +### Design checklist +- Confirm operation boundaries and invariants first. +- Minimize scope while preserving contract correctness. +- Test both happy path and negative path behavior. + +### Validation commands +- vendor/bin/bdi detect drivers +- ./vendor/bin/phpunit tests/E2E +- PANTHER_NO_HEADLESS=1 bin/phpunit --debug + +### Failure modes to test +- Invalid payload or forbidden actor. +- Boundary values / not-found cases. +- Retry or partial-failure behavior for async flows. diff --git a/.agents/skills/symfony-effective-context/SKILL.md b/.agents/skills/symfony-effective-context/SKILL.md new file mode 100644 index 0000000..4af7b1a --- /dev/null +++ b/.agents/skills/symfony-effective-context/SKILL.md @@ -0,0 +1,39 @@ +--- + +name: symfony-effective-context +allowed-tools: + - Read + - Glob + - Grep +description: Provide effective context to Claude for Symfony development with relevant files, patterns, and constraints +--- + +# Effective Context (Symfony) + +## Use when +- Refining architecture/workflows/context handling in Symfony projects. +- Planning and executing medium/complex changes safely. + +## Default workflow +1. Establish current boundaries, constraints, and coupling points. +2. Propose smallest coherent architectural adjustment. +3. Execute in checkpoints with validation at each stage. +4. Summarize tradeoffs and follow-up backlog. + +## Guardrails +- Use existing project patterns by default. +- Avoid broad refactors without explicit need. +- Keep decision log clear and auditable. + +## Progressive disclosure +- Use this file for execution posture and risk controls. +- Open references when deep implementation details are needed. + +## Output contract +- Architecture/workflow changes. +- Checkpoint validation outcomes. +- Residual risks and next steps. + +## References +- `reference.md` +- `docs/complexity-tiers.md` diff --git a/.agents/skills/symfony-effective-context/reference.md b/.agents/skills/symfony-effective-context/reference.md new file mode 100644 index 0000000..7d2e8a1 --- /dev/null +++ b/.agents/skills/symfony-effective-context/reference.md @@ -0,0 +1,237 @@ +# Reference + +# Providing Effective Context + +## What Context Helps + +When asking Claude for help with Symfony, provide: + +1. **Relevant code files** - Entity, Service, Controller involved +2. **Error messages** - Full stack trace if available +3. **Symfony version** - 8.x (stable), 7.4 LTS, or 6.4 legacy LTS +4. **Installed bundles** - API Platform, Messenger, etc. +5. **Constraints** - Performance requirements, backward compatibility + +## Code Context Examples + +### For Entity Questions + +Provide: +``` +- The entity file +- Related entities (if relationships involved) +- The repository if custom queries +- Current migration state +``` + +### For API Platform Questions + +Provide: +``` +- Entity with API Platform attributes +- Custom providers/processors if any +- Relevant filters +- Expected request/response format +``` + +### For Testing Questions + +Provide: +``` +- Test file +- Code being tested +- Factory files +- Error output +``` + +## Effective Prompts + +### Good: Specific and Contextual + +``` +I have a Symfony 7 app with API Platform 4. I need to add a filter +that searches across multiple fields (title, description, author name). + +Here's my entity: +[paste entity code] + +Current filters work for single fields. How do I create a custom +filter that does OR search across these fields? +``` + +### Bad: Vague and Missing Context + +``` +How do I search in API Platform? +``` + +## Including Error Context + +### Full Error Message + +``` +When I run bin/console doctrine:migrations:migrate, I get: + +[Error message with full stack trace] + +My entity is: +[entity code] + +My migration is: +[migration code] +``` + +### Relevant Log Output + +``` +The Messenger worker crashes with this error in var/log/dev.log: + +[2024-01-15 10:30:00] messenger.ERROR: Error thrown while handling message... + +My message handler: +[handler code] + +My message: +[message code] +``` + +## Constraints to Mention + +### Performance Requirements + +``` +This endpoint needs to handle 1000 requests/second. Currently it's slow +because of N+1 queries. Here's the code: +[code] +``` + +### Backward Compatibility + +``` +I need to add a new field to the API response without breaking existing +clients. Current response format: +[JSON example] +``` + +### Existing Patterns + +``` +Our codebase uses CQRS pattern. Here's an example command handler: +[example code] + +I need to add a new feature following the same pattern. +``` + +## Project Structure Context + +### When Asking About Architecture + +``` +Our project structure: +src/ +├── Domain/ # Entities, Value Objects +├── Application/ # Commands, Queries, Handlers +└── Infrastructure/ # Controllers, Repositories + +We follow hexagonal architecture. How should I structure a new +feature for user notifications? +``` + +### When Asking About Configuration + +``` +config/packages/messenger.yaml: +[current config] + +I need to add a second transport for high-priority messages. +``` + +## Version-Specific Context + +### Symfony Version + +``` +Symfony 6.4 LTS project. Can I use MapRequestPayload attribute? +Or is that only in 7.x? +``` + +### PHP Version + +``` +PHP 8.2, Symfony 7.1. I want to use readonly classes for my DTOs. +``` + +### Bundle Versions + +``` +API Platform 3.2. I see examples using "operations" but my version +uses "collectionOperations". Which syntax should I use? +``` + +## Anti-Patterns to Avoid + +### Don't Provide Too Much + +``` +# Bad: dumping entire project +Here's my entire src/ directory... +[thousands of lines] +``` + +### Don't Provide Too Little + +``` +# Bad: no context +Why doesn't my query work? +``` + +### Don't Assume Knowledge + +``` +# Bad: referring to unseen code +The UserService I showed you earlier... +[But it wasn't shown] +``` + +## Template for Asking Questions + +```markdown +## Context +- Symfony version: X.Y +- PHP version: 8.X +- Relevant bundles: [list] + +## What I'm Trying to Do +[Clear description of goal] + +## Current Code +[Relevant files only] + +## What's Happening +[Error message or unexpected behavior] + +## What I've Tried +[Previous attempts if any] + +## Constraints +[Performance, compatibility, patterns to follow] +``` + + +## Skill Operating Checklist + +### Design checklist +- Confirm operation boundaries and invariants first. +- Minimize scope while preserving contract correctness. +- Test both happy path and negative path behavior. + +### Validation commands +- rg --files +- composer validate +- ./vendor/bin/phpstan analyse + +### Failure modes to test +- Invalid payload or forbidden actor. +- Boundary values / not-found cases. +- Retry or partial-failure behavior for async flows. + diff --git a/.agents/skills/symfony-executing-plans/SKILL.md b/.agents/skills/symfony-executing-plans/SKILL.md new file mode 100644 index 0000000..ce11d70 --- /dev/null +++ b/.agents/skills/symfony-executing-plans/SKILL.md @@ -0,0 +1,42 @@ +--- + +name: symfony-executing-plans +allowed-tools: + - Read + - Write + - Edit + - Bash + - Glob + - Grep +description: Methodically execute implementation plans with a TDD approach, incremental commits, and continuous validation +--- + +# Executing Plans (Symfony) + +## Use when +- Refining architecture/workflows/context handling in Symfony projects. +- Planning and executing medium/complex changes safely. + +## Default workflow +1. Establish current boundaries, constraints, and coupling points. +2. Propose smallest coherent architectural adjustment. +3. Execute in checkpoints with validation at each stage. +4. Summarize tradeoffs and follow-up backlog. + +## Guardrails +- Use existing project patterns by default. +- Avoid broad refactors without explicit need. +- Keep decision log clear and auditable. + +## Progressive disclosure +- Use this file for execution posture and risk controls. +- Open references when deep implementation details are needed. + +## Output contract +- Architecture/workflow changes. +- Checkpoint validation outcomes. +- Residual risks and next steps. + +## References +- `reference.md` +- `docs/complexity-tiers.md` diff --git a/.agents/skills/symfony-executing-plans/reference.md b/.agents/skills/symfony-executing-plans/reference.md new file mode 100644 index 0000000..7974bc7 --- /dev/null +++ b/.agents/skills/symfony-executing-plans/reference.md @@ -0,0 +1,291 @@ +# Reference + +# Executing Implementation Plans + +Follow this skill to execute plans systematically with quality gates. + +## Execution Workflow + +### Step 1: Setup + +Before starting: + +```bash +# Ensure clean state +git status + +# Create feature branch +git checkout -b feature/[feature-name] + +# Pull latest dependencies +composer install + +# Clear cache +bin/console cache:clear + +# Ensure tests pass +./vendor/bin/pest # or phpunit +``` + +### Step 2: For Each Plan Step + +Follow the TDD cycle: + +``` +┌─────────────────┐ +│ Read Step │ +└────────┬────────┘ + │ + ▼ +┌─────────────────┐ +│ Write Test │◄──────┐ +│ (RED) │ │ +└────────┬────────┘ │ + │ │ + ▼ │ +┌─────────────────┐ │ +│ Run Test │ │ +│ (Verify Fail) │ │ +└────────┬────────┘ │ + │ │ + ▼ │ +┌─────────────────┐ │ +│ Implement │ │ +│ (GREEN) │ │ +└────────┬────────┘ │ + │ │ + ▼ │ +┌─────────────────┐ │ +│ Run Test │───No──┘ +│ (Verify Pass) │ +└────────┬────────┘ + │ Yes + ▼ +┌─────────────────┐ +│ Refactor │ +└────────┬────────┘ + │ + ▼ +┌─────────────────┐ +│ Commit │ +└─────────────────┘ +``` + +### Step 3: Commit Strategy + +Commit after each completed step: + +```bash +# Stage changes +git add src/Entity/Order.php +git add tests/Unit/Entity/OrderTest.php + +# Commit with clear message +git commit -m "feat(order): add Order entity with status enum + +- Create Order entity with uuid, status, customer relation +- Create OrderStatus enum (pending, processing, completed, cancelled) +- Add migration for orders table +- Add unit tests for entity" +``` + +### Step 4: Quality Gates + +Run after each phase: + +```bash +# Code style +./vendor/bin/php-cs-fixer fix --dry-run + +# Static analysis +./vendor/bin/phpstan analyse + +# Tests +./vendor/bin/pest + +# All checks +composer run-script check +``` + +## Execution Patterns + +### Entity Implementation + +```bash +# 1. Create test +# tests/Unit/Entity/OrderTest.php + +# 2. Create entity +bin/console make:entity Order + +# 3. Adjust entity code + +# 4. Create migration +bin/console make:migration + +# 5. Run migration +bin/console doctrine:migrations:migrate + +# 6. Verify +bin/console doctrine:schema:validate +``` + +### Service Implementation + +```bash +# 1. Create test +# tests/Unit/Service/OrderServiceTest.php + +# 2. Create service interface (if needed) +# src/Service/OrderServiceInterface.php + +# 3. Create service +# src/Service/OrderService.php + +# 4. Configure in services.yaml (if needed) + +# 5. Run tests +./vendor/bin/pest tests/Unit/Service/OrderServiceTest.php +``` + +### API Endpoint Implementation + +```bash +# 1. Create functional test +# tests/Functional/Api/OrderTest.php + +# 2. Configure API Platform resource + +# 3. Create/configure voter + +# 4. Run tests +./vendor/bin/pest tests/Functional/Api/OrderTest.php + +# 5. Verify in browser/Postman +curl http://localhost/api/orders +``` + +### Message Handler Implementation + +```bash +# 1. Create message class +# src/Message/ProcessOrder.php + +# 2. Create handler test +# tests/Unit/MessageHandler/ProcessOrderHandlerTest.php + +# 3. Create handler +# src/MessageHandler/ProcessOrderHandler.php + +# 4. Configure routing in messenger.yaml + +# 5. Run tests with in-memory transport +./vendor/bin/pest tests/Unit/MessageHandler/ +``` + +## Handling Blockers + +### When tests fail unexpectedly + +```bash +# Run single test with verbose output +./vendor/bin/pest tests/path/to/Test.php --filter testName -vvv + +# Check logs +tail -f var/log/dev.log + +# Debug with dump +dd($variable); # or dump($variable); +``` + +### When migrations fail + +```bash +# Check status +bin/console doctrine:migrations:status + +# Rollback last migration +bin/console doctrine:migrations:migrate prev + +# Regenerate migration +bin/console doctrine:migrations:diff +``` + +### When services won't autowire + +```bash +# Debug autowiring +bin/console debug:autowiring ServiceName + +# Check container +bin/console debug:container ServiceName + +# Clear cache +bin/console cache:clear +``` + +## Progress Tracking + +Update plan checkboxes as you complete: + +```markdown +## Steps +1. [x] Create entity ✓ (commit: abc123) +2. [x] Create migration ✓ (commit: def456) +3. [ ] Create service <- CURRENT +4. [ ] Create tests +``` + +## Final Validation + +Before marking plan complete: + +```bash +# Full test suite +./vendor/bin/pest + +# Code coverage +./vendor/bin/pest --coverage --min=80 + +# Static analysis +./vendor/bin/phpstan analyse + +# Code style +./vendor/bin/php-cs-fixer fix + +# Manual testing +# - Test happy path +# - Test edge cases +# - Test error handling +``` + +## Merge Checklist + +Before merging feature branch: + +- [ ] All tests pass +- [ ] Code coverage maintained/improved +- [ ] No PHPStan errors +- [ ] Code style fixed +- [ ] Documentation updated +- [ ] PR reviewed +- [ ] Rebased on main + + +## Skill Operating Checklist + +### Design checklist +- Confirm operation boundaries and invariants first. +- Minimize scope while preserving contract correctness. +- Test both happy path and negative path behavior. + +### Validation commands +- rg --files +- composer validate +- ./vendor/bin/phpstan analyse + +### Failure modes to test +- Invalid payload or forbidden actor. +- Boundary values / not-found cases. +- Retry or partial-failure behavior for async flows. + diff --git a/.agents/skills/symfony-form-types-validation/SKILL.md b/.agents/skills/symfony-form-types-validation/SKILL.md new file mode 100644 index 0000000..000720d --- /dev/null +++ b/.agents/skills/symfony-form-types-validation/SKILL.md @@ -0,0 +1,42 @@ +--- + +name: symfony-form-types-validation +allowed-tools: + - Read + - Write + - Edit + - Bash + - Glob + - Grep +description: Build Symfony forms with custom Form Types, validation constraints, HTTP 422 handling, and multi-step flows +--- + +# Form Types Validation (Symfony) + +## Use when +- Hardening access-control or validation boundaries. +- Aligning voters/security expressions with domain rules. + +## Default workflow +1. Map actor/resource/action decision matrix. +2. Implement voter/constraint logic at the right boundary. +3. Wire checks at controllers and API operations. +4. Test allowed/forbidden/invalid paths comprehensively. + +## Guardrails +- Avoid policy logic duplication across layers. +- Do not leak privileged state via error detail. +- Preserve explicit deny behavior for sensitive actions. + +## Progressive disclosure +- Use this file for execution posture and risk controls. +- Open references when deep implementation details are needed. + +## Output contract +- Security boundary updates. +- Integration points enforcing decisions. +- Negative-path test results. + +## References +- `reference.md` +- `docs/complexity-tiers.md` diff --git a/.agents/skills/symfony-form-types-validation/reference.md b/.agents/skills/symfony-form-types-validation/reference.md new file mode 100644 index 0000000..eed3b7f --- /dev/null +++ b/.agents/skills/symfony-form-types-validation/reference.md @@ -0,0 +1,455 @@ +# Reference + +# Symfony Forms and Validation + +## Basic Form Type + +```php +add('name', TextType::class, [ + 'label' => 'Full Name', + 'attr' => ['placeholder' => 'John Doe'], + ]) + ->add('email', EmailType::class, [ + 'label' => 'Email Address', + ]) + ->add('password', RepeatedType::class, [ + 'type' => PasswordType::class, + 'first_options' => ['label' => 'Password'], + 'second_options' => ['label' => 'Confirm Password'], + 'invalid_message' => 'The passwords do not match.', + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => User::class, + ]); + } +} +``` + +## Validation Constraints + +### On Entity + +```php +add('website', UrlType::class, [ + 'constraints' => [ + new Assert\Url(), + new Assert\Length(['max' => 255]), + ], + ]) + ->add('age', IntegerType::class, [ + 'constraints' => [ + new Assert\Range(['min' => 18, 'max' => 120]), + ], + ]) + ; +} +``` + +## Validation Groups + +```php +setDefaults([ + 'data_class' => User::class, + 'validation_groups' => ['registration'], + ]); +} + +// src/Form/ProfileType.php + +public function configureOptions(OptionsResolver $resolver): void +{ + $resolver->setDefaults([ + 'data_class' => User::class, + 'validation_groups' => ['profile'], + ]); +} +``` + +## Custom Constraint + +```php +parse($value, $constraint->region); + if (!$phoneUtil->isValidNumber($number)) { + $this->context->buildViolation($constraint->message) + ->setParameter('{{ value }}', $value) + ->addViolation(); + } + } catch (\Exception $e) { + $this->context->buildViolation($constraint->message) + ->setParameter('{{ value }}', $value) + ->addViolation(); + } + } +} +``` + +Usage: + +```php +#[ValidPhoneNumber(region: 'US')] +private string $phone; +``` + +## Data Transformers + +```php + String (for display) + public function transform(mixed $value): string + { + if ($value->isEmpty()) { + return ''; + } + + return implode(', ', $value->map(fn(Tag $tag) => $tag->getName())->toArray()); + } + + // String -> Entity Collection (from input) + public function reverseTransform(mixed $value): ArrayCollection + { + if (!$value) { + return new ArrayCollection(); + } + + $names = array_map('trim', explode(',', $value)); + $tags = new ArrayCollection(); + + foreach ($names as $name) { + if (empty($name)) { + continue; + } + + $tag = $this->em->getRepository(Tag::class)->findOneBy(['name' => $name]); + + if (!$tag) { + $tag = new Tag(); + $tag->setName($name); + $this->em->persist($tag); + } + + $tags->add($tag); + } + + return $tags; + } +} +``` + +Usage in form: + +```php +public function buildForm(FormBuilderInterface $builder, array $options): void +{ + $builder + ->add('tags', TextType::class, [ + 'label' => 'Tags (comma-separated)', + ]) + ; + + $builder->get('tags')->addModelTransformer($this->tagsTransformer); +} +``` + +## Form Events + +```php +public function buildForm(FormBuilderInterface $builder, array $options): void +{ + $builder + ->add('country', CountryType::class) + ; + + // Add state field dynamically based on country + $builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) { + $form = $event->getForm(); + $data = $event->getData(); + + $country = $data?->getCountry(); + $this->addStateField($form, $country); + }); + + $builder->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event) { + $form = $event->getForm(); + $data = $event->getData(); + + $country = $data['country'] ?? null; + $this->addStateField($form, $country); + }); +} + +private function addStateField(FormInterface $form, ?string $country): void +{ + if ($country === 'US') { + $form->add('state', ChoiceType::class, [ + 'choices' => $this->usStates, + ]); + } else { + $form->add('state', TextType::class, [ + 'required' => false, + ]); + } +} +``` + +## Controller Usage + +```php +#[Route('/register', name: 'register')] +public function register(Request $request): Response +{ + $user = new User(); + $form = $this->createForm(UserType::class, $user); + + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $this->em->persist($user); + $this->em->flush(); + + $this->addFlash('success', 'Registration successful!'); + return $this->redirectToRoute('home'); + } + + return $this->render('security/register.html.twig', [ + 'form' => $form, + ]); +} +``` + +> **HTTP 422 on invalid submit (Symfony 8.0+):** pass the *form* (not +> `$form->createView()`) to `render()`. When the form was submitted and is +> invalid, Symfony automatically sets the response status to +> **422 Unprocessable Content** (Turbo-compatible) instead of 200. The snippet +> above already passes `'form' => $form`, so it benefits from this. + +## Recent Constraints + +```php +use Symfony\Component\Validator\Constraints as Assert; + +class ChangePassword +{ + // Strong password without a regex (weak | medium | strong | very_strong) + #[Assert\PasswordStrength(minScore: Assert\PasswordStrength::STRENGTH_STRONG)] + #[Assert\NotCompromisedPassword] + public string $newPassword; + + // Run constraints in order, stopping at the first violation + #[Assert\Sequentially([ + new Assert\NotBlank(), + new Assert\Length(min: 3), + new Assert\Regex('/^[a-z0-9_]+$/'), + ])] + public string $username; + + // Conditional validation + #[Assert\When( + expression: 'this.type === "company"', + constraints: [new Assert\NotBlank(), new Assert\Length(max: 14)], + )] + public ?string $vatNumber = null; + + public string $type = 'individual'; +} +``` + +## Multi-Step Forms (Symfony 8.1+ — verify) + +```php +// Build a form spread across several steps from a single object. +$form = $this->createFormFlowBuilder($task)->getForm(); +``` + +## Custom Violation Mapper (Symfony 8.1+ — verify) + +Override how constraint violations are mapped onto form fields by implementing +`ViolationMapperInterface`; it is auto-registered as the `form.violation_mapper` +service. + +```php +use Symfony\Component\Form\Util\ViolationMapperInterface; + +class CustomViolationMapper implements ViolationMapperInterface +{ + public function mapViolation( + ConstraintViolation $violation, + FormInterface $form, + bool $allowNonSynchronized = false, + ): void { + // custom mapping + } +} +``` + +## Best Practices + +1. **Constraints on entities**: Primary validation source +2. **Form constraints for UI-specific validation**: File uploads, etc. +3. **Validation groups**: Different rules for different contexts +4. **Data transformers**: Convert between formats +5. **Custom constraints**: Reusable business logic +6. **Test validation**: Unit test constraints + + +## Skill Operating Checklist + +### Design checklist +- Confirm operation boundaries and invariants first. +- Minimize scope while preserving contract correctness. +- Test both happy path and negative path behavior. + +### Validation commands +- ./vendor/bin/phpunit --filter=Voter +- php bin/console debug:container security +- ./vendor/bin/phpstan analyse + +### Failure modes to test +- Invalid payload or forbidden actor. +- Boundary values / not-found cases. +- Retry or partial-failure behavior for async flows. + diff --git a/.agents/skills/symfony-functional-tests/SKILL.md b/.agents/skills/symfony-functional-tests/SKILL.md new file mode 100644 index 0000000..1890969 --- /dev/null +++ b/.agents/skills/symfony-functional-tests/SKILL.md @@ -0,0 +1,41 @@ +--- +name: symfony-functional-tests +allowed-tools: + - Read + - Write + - Edit + - Bash + - Glob + - Grep +description: Write functional tests for Symfony controllers and HTTP endpoints using WebTestCase, getContainer, loginUser, and DAMA rollback +--- + +# Functional Tests (Symfony) + +## Use when +- Building regression-safe behavior with TDD/functional/e2e tests. +- Converting bug reports into executable failing tests. + +## Default workflow +1. Write failing test for target behavior and one boundary case. +2. Implement minimal code to pass. +3. Refactor while preserving green suite. +4. Broaden coverage for invalid/unauthorized/not-found paths. + +## Guardrails +- Prefer deterministic fixtures/builders. +- Assert observable behavior, not internal implementation. +- Keep tests isolated and stable in CI. + +## Progressive disclosure +- Use this file for execution posture and risk controls. +- Open references when deep implementation details are needed. + +## Output contract +- RED/GREEN/REFACTOR trace. +- Test files changed and executed commands. +- Coverage and confidence notes. + +## References +- `reference.md` +- `docs/complexity-tiers.md` diff --git a/.agents/skills/symfony-functional-tests/reference.md b/.agents/skills/symfony-functional-tests/reference.md new file mode 100644 index 0000000..ab6d88f --- /dev/null +++ b/.agents/skills/symfony-functional-tests/reference.md @@ -0,0 +1,260 @@ +# Functional Tests Reference (Symfony) + +Use this reference for implementation details and review criteria specific to `functional-tests`. + +> **Version applicability** — Targets Symfony 7.4 LTS / 8.x with PHPUnit 10/11. +> `static::getContainer()` is the modern container accessor (not legacy `self::$container`); +> `assertResponseIsUnprocessable` (422) and `assertSessionHasFlashMessage` (8.1+) are recent. + +## Setup + +```bash +composer require --dev symfony/test-pack +php bin/phpunit +``` + +Flex creates `phpunit.dist.xml` and `tests/bootstrap.php`. Test env files: +`.env` → `.env.test` (committed) → `.env.test.local` (gitignored). `.env.local` is **not** +loaded in the test env. + +## Test base classes + +| Base class | Use for | +| --- | --- | +| `TestCase` (PHPUnit) | Pure unit tests, no kernel. | +| `KernelTestCase` | Integration — boot the kernel, use the **real** container & services. | +| `WebTestCase` | Functional/application — simulate HTTP requests through the kernel. | + +```php +use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; + +final class HomeControllerTest extends WebTestCase +{ + public function testHomepageIsSuccessful(): void + { + $client = static::createClient(); + $client->request('GET', '/'); + + $this->assertResponseIsSuccessful(); + $this->assertSelectorTextContains('h1', 'Hello World'); + } +} +``` + +> `static::createClient()` boots the kernel internally — do **not** call `bootKernel()` +> first, or you'll get a "kernel already booted" error. + +## Accessing services (private services in test) + +In the test env the container exposes a special test container where **private** services +are reachable via `static::getContainer()`: + +```php +final class NewsletterTest extends KernelTestCase +{ + public function testGenerate(): void + { + self::bootKernel(); + $container = static::getContainer(); + + $generator = $container->get(NewsletterGenerator::class); + self::assertNotEmpty($generator->generate()); + } +} +``` + +The kernel reboots between tests. Configure test-only behavior under +`config/packages/test/` or with `when@test`. + +## Client API + +```php +$client = static::createClient(); + +$crawler = $client->request('GET', '/'); +$client->request($method, $uri, $parameters = [], $files = [], $server = [], $content = null); +$client->xmlHttpRequest('POST', '/submit', ['name' => 'Fabien']); // AJAX + +$client->followRedirect(); // or followRedirects() to auto-follow +$client->back(); $client->forward(); $client->reload(); +$client->disableReboot(); // reset instead of reboot between requests +$client->enableProfiler(); // collect profiler data for HttpClient/mailer asserts +$client->catchExceptions(false); // let exceptions bubble for debugging +``` + +## Authentication + +Prefer the `loginUser()` helper over crafting login form requests: + +```php +$user = $userRepository->findOneByEmail('admin@example.com'); +$client->loginUser($user); // default firewall +$client->loginUser($user, 'main'); // explicit firewall +``` + +For lightweight cases, an in-memory user + a `when@test` security provider works too: + +```php +$client->loginUser(new InMemoryUser('admin', 'password', ['ROLE_ADMIN'])); +``` + +## Crawler & forms + +```php +$client->clickLink('Add comment'); +$client->submitForm('Add comment', ['comment[text]' => 'Nice!']); + +$form = $crawler->selectButton('Submit')->form(); +$form['contact[subject]'] = 'Hello'; +$form['contact[category]']->select('support'); +$form['contact[agree]']->tick(); +$form['contact[file]']->upload('/path/to/file.pdf'); +$client->submit($form); +``` + +## Assertions + +```php +// Response +$this->assertResponseIsSuccessful(); // 2xx +$this->assertResponseStatusCodeSame(201); +$this->assertResponseRedirects('/login', 302); +$this->assertResponseHasHeader('Content-Type'); +$this->assertResponseHeaderSame('Content-Type', 'application/json'); +$this->assertResponseHasCookie('session'); +$this->assertResponseIsUnprocessable(); // 422 — invalid form (Symfony 8.0 Turbo) + +// Request / routing +$this->assertRouteSame('app_home'); +$this->assertRequestAttributeValueSame('_route', 'app_home'); +$this->assertSessionHasFlashMessage('success'); // 8.1+ + +// DOM +$this->assertSelectorExists('.alert'); +$this->assertSelectorNotExists('.error'); +$this->assertSelectorCount(3, 'li.item'); +$this->assertSelectorTextContains('h1', 'Title'); +$this->assertSelectorTextSame('h1', 'Exact Title'); +$this->assertPageTitleContains('Dashboard'); +$this->assertInputValueSame('email', 'a@b.com'); +$this->assertCheckboxChecked('agree'); + +// Mailer (needs the kernel mailer) +$this->assertEmailCount(1); +$this->assertQueuedEmailCount(1); +$email = $this->getMailerMessage(); +$this->assertEmailSubjectContains($email, 'Welcome'); +$this->assertEmailHtmlBodyContains($email, 'Confirm'); +``` + +## Database in functional tests + +Use a **real database** with real repositories — repositories are meant to be tested +against a real connection, not mocked. Mocking the EM/repository sacrifices coverage of +the actual query behavior. + +```bash +# one-time test DB setup +php bin/console --env=test doctrine:database:create +php bin/console --env=test doctrine:schema:create +``` + +### Transactional rollback per test — DAMADoctrineTestBundle + +```bash +composer require --dev dama/doctrine-test-bundle +``` + +```xml + + + + +``` + +Each test runs inside a transaction that is **rolled back** at the end — fast, isolated, +no manual cleanup. Combine with Foundry's reset/`#[ResetDatabase]` (see the +`doctrine-fixtures-foundry` skill) for fixtures. + +### Repository test pattern (KernelTestCase + real DB) + +```php +final class ProductRepositoryTest extends KernelTestCase +{ + private EntityManagerInterface $em; + + protected function setUp(): void + { + $kernel = self::bootKernel(); + $this->em = $kernel->getContainer()->get('doctrine')->getManager(); + } + + public function testFindByName(): void + { + $product = $this->em->getRepository(Product::class) + ->findOneBy(['name' => 'Widget']); + + self::assertSame(14.50, $product->getPrice()); + } + + protected function tearDown(): void + { + parent::tearDown(); + $this->em->close(); // avoid memory leaks across tests + unset($this->em); + } +} +``` + +## Smoke tests (best practice) + +Cheap, high-value: assert that key URLs return a successful (or expected) status. Use +**hardcoded URLs**, not the router — a smoke test should fail if a route URL silently +changes. + +```php +/** + * @dataProvider provideUrls + */ +public function testPageIsSuccessful(string $url): void +{ + $client = static::createClient(); + $client->request('GET', $url); + + $this->assertResponseIsSuccessful(); +} + +public static function provideUrls(): \Generator +{ + yield ['/']; + yield ['/about']; + yield ['/contact']; +} +``` + +## Review criteria + +- Functional tests use `WebTestCase` + `static::createClient()`; integration uses `KernelTestCase`. +- Real DB + real repositories (no mocked EM/repository) for data-touching tests. +- DAMADoctrineTestBundle (or Foundry reset) provides per-test isolation. +- Invalid form submissions assert **422** via `assertResponseIsUnprocessable()` (Symfony 8.0+). +- Auth via `loginUser()`, not by replaying the login form. +- Smoke tests cover key URLs with hardcoded paths. + + +## Skill Operating Checklist + +### Design checklist +- Confirm operation boundaries and invariants first. +- Minimize scope while preserving contract correctness. +- Test both happy path and negative path behavior. + +### Validation commands +- ./vendor/bin/phpunit --filter=... +- ./vendor/bin/phpunit +- ./vendor/bin/pest --filter=... + +### Failure modes to test +- Invalid payload or forbidden actor. +- Boundary values / not-found cases. +- Retry or partial-failure behavior for async flows. diff --git a/.agents/skills/symfony-interfaces-and-autowiring/SKILL.md b/.agents/skills/symfony-interfaces-and-autowiring/SKILL.md new file mode 100644 index 0000000..5ba25c7 --- /dev/null +++ b/.agents/skills/symfony-interfaces-and-autowiring/SKILL.md @@ -0,0 +1,39 @@ +--- + +name: symfony-interfaces-and-autowiring +allowed-tools: + - Read + - Glob + - Grep +description: Master Symfony Dependency Injection with autowiring, #[Target] interface binding, decoration, and tagged services +--- + +# Interfaces And Autowiring (Symfony) + +## Use when +- Refining architecture/workflows/context handling in Symfony projects. +- Planning and executing medium/complex changes safely. + +## Default workflow +1. Establish current boundaries, constraints, and coupling points. +2. Propose smallest coherent architectural adjustment. +3. Execute in checkpoints with validation at each stage. +4. Summarize tradeoffs and follow-up backlog. + +## Guardrails +- Use existing project patterns by default. +- Avoid broad refactors without explicit need. +- Keep decision log clear and auditable. + +## Progressive disclosure +- Use this file for execution posture and risk controls. +- Open references when deep implementation details are needed. + +## Output contract +- Architecture/workflow changes. +- Checkpoint validation outcomes. +- Residual risks and next steps. + +## References +- `reference.md` +- `docs/complexity-tiers.md` diff --git a/.agents/skills/symfony-interfaces-and-autowiring/reference.md b/.agents/skills/symfony-interfaces-and-autowiring/reference.md new file mode 100644 index 0000000..27b9d01 --- /dev/null +++ b/.agents/skills/symfony-interfaces-and-autowiring/reference.md @@ -0,0 +1,433 @@ +# Reference + +# Interfaces and Autowiring in Symfony + +## Basic Autowiring + +Symfony automatically injects dependencies based on type-hints: + +```php +logger->info('Creating order', $data); + // ... + } +} +``` + +No configuration needed - Symfony wires it automatically. + +## Interface Binding + +### Define Interface + +```php +logger->info('Charging payment', [ + 'amount' => $amount, + 'currency' => $currency, + ]); + + $result = $this->inner->charge($amount, $currency); + + $this->logger->info('Payment result', [ + 'success' => $result->isSuccessful(), + 'transactionId' => $result->getTransactionId(), + ]); + + return $result; + } + + public function refund(string $transactionId, int $amount): RefundResult + { + $this->logger->info('Processing refund', [ + 'transactionId' => $transactionId, + 'amount' => $amount, + ]); + + return $this->inner->refund($transactionId, $amount); + } +} +``` + +## Tagged Services + +### Define Tag + +```php + $exporters + */ + public function __construct( + #[AutowireIterator('app.exporter')] + private iterable $exporters, + ) {} + + public function export(array $data, string $format): string + { + foreach ($this->exporters as $exporter) { + if ($exporter->supports($format)) { + return $exporter->export($data); + } + } + + throw new \InvalidArgumentException("Unsupported format: {$format}"); + } + + public function getSupportedFormats(): array + { + $formats = []; + foreach ($this->exporters as $exporter) { + // Each exporter reports what it supports + } + return $formats; + } +} +``` + +## Named Autowiring + +When you have multiple implementations: + +```yaml +# config/services.yaml +services: + App\Service\StripePaymentGateway: + arguments: + $apiKey: '%env(STRIPE_API_KEY)%' + + App\Service\PaypalPaymentGateway: + arguments: + $clientId: '%env(PAYPAL_CLIENT_ID)%' + + # Named bindings + App\Service\PaymentGatewayInterface $stripeGateway: '@App\Service\StripePaymentGateway' + App\Service\PaymentGatewayInterface $paypalGateway: '@App\Service\PaypalPaymentGateway' +``` + +```php +class PaymentService +{ + public function __construct( + private PaymentGatewayInterface $stripeGateway, // Stripe + private PaymentGatewayInterface $paypalGateway, // PayPal + ) {} +} +``` + +> **Deprecation (8.1+ — verify):** matching a named alias by the *parameter name* +> alone (as above) is deprecated. Prefer `#[Target]` so the binding is explicit +> and rename-safe. + +## Named Autowiring with #[Target] + +`#[Target]` selects a specific implementation by its alias name, independent of +the parameter name: + +```php +use Symfony\Component\DependencyInjection\Attribute\Target; + +class MastodonClient +{ + public function __construct( + #[Target('shoutyTransformer')] + private TransformerInterface $transformer, + ) {} +} +``` + +```yaml +services: + App\Util\TransformerInterface $shoutyTransformer: '@App\Util\UppercaseTransformer' + App\Util\TransformerInterface: '@App\Util\Rot13Transformer' # default +``` + +Inspect candidates: `php bin/console debug:autowiring TransformerInterface`. + +## #[Autowire] — scalars, params, env, expressions + +```php +use Symfony\Component\DependencyInjection\Attribute\Autowire; + +public function __construct( + #[Autowire(service: 'monolog.logger.request')] private LoggerInterface $logger, + #[Autowire('%kernel.project_dir%/data')] private string $dataDir, + #[Autowire(param: 'kernel.debug')] private bool $debugMode, + #[Autowire(env: 'SOME_ENV_VAR')] private string $senderName, + #[Autowire(env: 'bool:ALLOW_ATTACHMENTS')] private bool $allowAttachments, + #[Autowire(expression: 'service("App\\\Mail\\\MailerConfiguration").getMailerMethod()')] private string $mailerMethod, +) {} +``` + +### Refreshable env vars in long-running workers (8.1+ — verify) + +For workers (Messenger, daemons), inject an env var as a `\Closure` so it can +refresh between messages instead of being frozen at boot: + +```php +public function __construct( + #[Autowire(env: 'DB_URL')] private \Closure $dbUrl, // ($this->dbUrl)() +) {} +``` + +### Service closures + +```php +use Symfony\Component\DependencyInjection\Attribute\AutowireServiceClosure; + +#[AutowireServiceClosure('mailer.default')] +private \Closure $mailer; // ($this->mailer)()->send(...) — lazily resolved +``` + +### Inline anonymous services (8.1+ — verify) + +```php +use Symfony\Component\DependencyInjection\Attribute\AutowireInline; + +public function __construct( + #[AutowireInline( + class: [ScopingHttpClient::class, 'forBaseUri'], + arguments: ['$baseUri' => 'https://api.example.com'], + )] + private HttpClientInterface $client, +) {} +``` + +## Lazy Services + +Load service only when actually used: + +```php +use Symfony\Component\DependencyInjection\Attribute\Lazy; + +#[Lazy] +class ExpensiveService +{ + public function __construct() + { + // Heavy initialization + } +} +``` + +## Debug Commands + +```bash +# List all services +bin/console debug:container + +# Find specific service +bin/console debug:container OrderService + +# Show autowiring candidates +bin/console debug:autowiring + +# Show autowiring for specific type +bin/console debug:autowiring Payment +``` + +## Best Practices + +1. **Program to interfaces**: Depend on interfaces, not implementations +2. **Constructor injection**: Always use constructor injection +3. **Final classes**: Make services `final` by default +4. **Readonly properties**: Use `private readonly` for dependencies +5. **Minimal interfaces**: Keep interfaces focused (ISP) +6. **Decorate, don't modify**: Use decoration for cross-cutting concerns + + +## Skill Operating Checklist + +### Design checklist +- Confirm operation boundaries and invariants first. +- Minimize scope while preserving contract correctness. +- Test both happy path and negative path behavior. + +### Validation commands +- rg --files +- composer validate +- ./vendor/bin/phpstan analyse + +### Failure modes to test +- Invalid payload or forbidden actor. +- Boundary values / not-found cases. +- Retry or partial-failure behavior for async flows. + diff --git a/.agents/skills/symfony-messenger-retry-failures/SKILL.md b/.agents/skills/symfony-messenger-retry-failures/SKILL.md new file mode 100644 index 0000000..5e07886 --- /dev/null +++ b/.agents/skills/symfony-messenger-retry-failures/SKILL.md @@ -0,0 +1,42 @@ +--- + +name: symfony-messenger-retry-failures +allowed-tools: + - Read + - Write + - Edit + - Bash + - Glob + - Grep +description: Handle message failures with retry strategies, failure transport, and recovery in Symfony Messenger (Recoverable/Unrecoverable exceptions) +--- + +# Messenger Retry Failures (Symfony) + +## Use when +- Implementing asynchronous workflows with Messenger/Scheduler/Cache. +- Stabilizing retries and failure transports. + +## Default workflow +1. Define async contract and delivery semantics. +2. Implement idempotent handlers and routing strategy. +3. Configure retries, failure transport, and observability. +4. Validate success/failure replay scenarios. + +## Guardrails +- Assume at-least-once delivery, not exactly-once. +- Keep handlers deterministic and side-effect aware. +- Surface poison-message handling strategy. + +## Progressive disclosure +- Use this file for execution posture and risk controls. +- Open references when deep implementation details are needed. + +## Output contract +- Async config/handlers updated. +- Retry/failure policy decisions. +- Operational validation evidence. + +## References +- `reference.md` +- `docs/complexity-tiers.md` diff --git a/.agents/skills/symfony-messenger-retry-failures/reference.md b/.agents/skills/symfony-messenger-retry-failures/reference.md new file mode 100644 index 0000000..56be709 --- /dev/null +++ b/.agents/skills/symfony-messenger-retry-failures/reference.md @@ -0,0 +1,357 @@ +# Reference + +# Messenger Retry and Failure Handling + +## Retry Configuration + +```yaml +# config/packages/messenger.yaml +framework: + messenger: + transports: + async: + dsn: '%env(MESSENGER_TRANSPORT_DSN)%' + retry_strategy: + max_retries: 3 + delay: 1000 # Initial delay: 1 second + multiplier: 2 # Exponential backoff + max_delay: 60000 # Max delay: 1 minute + service: null # Or custom retry strategy service + + # High-priority with aggressive retries + high_priority: + dsn: '%env(MESSENGER_TRANSPORT_DSN)%' + options: + queue_name: high_priority + retry_strategy: + max_retries: 5 + delay: 500 + multiplier: 1.5 + + # Failed message storage + failed: + dsn: 'doctrine://default?queue_name=failed' + + failure_transport: failed +``` + +## Retry Behavior + +With the above config, retries happen at: +1. **Attempt 1**: Immediate +2. **Retry 1**: +1 second (1000ms) +3. **Retry 2**: +2 seconds (2000ms) +4. **Retry 3**: +4 seconds (4000ms) +5. **Failed**: Moved to `failed` transport + +## Exception Types + +### Unrecoverable - Don't Retry + +```php +gateway->charge($message->amount); + } catch (InvalidCardException $e) { + // Card is invalid - retrying won't help + throw new UnrecoverableMessageHandlingException( + 'Payment failed: invalid card', + previous: $e + ); + } catch (InsufficientFundsException $e) { + // Permanent failure + throw new UnrecoverableMessageHandlingException( + 'Payment failed: insufficient funds', + previous: $e + ); + } + } +} +``` + +### Recoverable - Do Retry + +```php +gateway->charge($message->amount); + } catch (GatewayTimeoutException $e) { + // Gateway temporarily unavailable - retry + throw new RecoverableMessageHandlingException( + 'Payment gateway timeout', + previous: $e + ); + } catch (RateLimitException $e) { + // Rate limited - retry after delay, ignoring max_retries. + // `retryDelay` (ms) and `forceRetry` (8.1+ — verify) let you retry + // past the configured limit. forceRetry: false respects max_retries. + throw new RecoverableMessageHandlingException( + 'Rate limited, will retry', + previous: $e, + retryDelay: 5000, + forceRetry: true, + ); + } + } +} +``` + +> **Decode failures (8.1+ — verify):** messages that fail to decode are now +> routed through the retry/failure pipeline instead of being silently deleted, +> so a malformed payload lands in the `failed` transport for inspection. + +## Custom Retry Strategy + +```php += 5) { + return false; + } + + // Don't retry certain exceptions + if ($throwable instanceof \InvalidArgumentException) { + return false; + } + + // Don't retry if message is too old + $sentStamp = $message->last(SentStamp::class); + if ($sentStamp && $sentStamp->getSentAt() < new \DateTimeImmutable('-1 hour')) { + return false; + } + + return true; + } + + public function getWaitingTime(Envelope $message, ?\Throwable $throwable = null): int + { + $retryCount = RedeliveryStamp::getRetryCountFromEnvelope($message); + + // Custom delays based on retry count + return match ($retryCount) { + 0 => 1000, // 1 second + 1 => 5000, // 5 seconds + 2 => 30000, // 30 seconds + 3 => 120000, // 2 minutes + default => 300000, // 5 minutes + }; + } +} +``` + +Register: + +```yaml +services: + App\Messenger\CustomRetryStrategy: ~ + +framework: + messenger: + transports: + async: + retry_strategy: + service: App\Messenger\CustomRetryStrategy +``` + +## Managing Failed Messages + +### CLI Commands + +```bash +# View failed messages (filterable / with stats) +bin/console messenger:failed:show [--max=50] [--class-filter='App\Message\Foo'] [--stats] + +# View a specific failed message with full stack trace +bin/console messenger:failed:show 123 -vv + +# Retry messages (interactive unless --force); target a transport +bin/console messenger:failed:retry [--force] [--transport=failed] +bin/console messenger:failed:retry 123 --force + +# Remove failed messages +bin/console messenger:failed:remove 123 +bin/console messenger:failed:remove --all [--class-filter='App\Message\Foo'] +``` + +### Programmatic Retry + +```php +failedTransport->find($id); + + if (!$envelope) { + throw new \RuntimeException("Message {$id} not found"); + } + + // Re-dispatch to original transport + $this->bus->dispatch($envelope->getMessage()); + + // Remove from failed queue + $this->failedTransport->reject($envelope); + } + + public function getFailedMessages(): iterable + { + return $this->failedTransport->all(); + } +} +``` + +## Failure Notifications + +```php + 'onMessageFailed', + ]; + } + + public function onMessageFailed(WorkerMessageFailedEvent $event): void + { + // Only notify on final failure (not retries) + if ($event->willRetry()) { + return; + } + + $envelope = $event->getEnvelope(); + $message = $envelope->getMessage(); + $throwable = $event->getThrowable(); + + $this->logger->error('Message failed permanently', [ + 'message_class' => get_class($message), + 'error' => $throwable->getMessage(), + ]); + + // Send notification + $notification = (new Notification('Message Failed', ['email'])) + ->content(sprintf( + "Message %s failed: %s", + get_class($message), + $throwable->getMessage() + )); + + $this->notifier->send($notification); + } +} +``` + +## Idempotent Handlers + +Design handlers to be safely retried: + +```php +orders->find($message->orderId); + + // Idempotency check - already processed? + if ($order->getStatus() === OrderStatus::PROCESSED) { + $this->logger->info('Order already processed, skipping'); + return; // Success - don't throw + } + + // Idempotency key for external calls + $idempotencyKey = sprintf('order_%d_%s', $order->getId(), $order->getUpdatedAt()->format('U')); + + $this->paymentGateway->charge( + amount: $order->getTotal(), + idempotencyKey: $idempotencyKey + ); + + $order->setStatus(OrderStatus::PROCESSED); + $this->em->flush(); + } +} +``` + +## Best Practices + +1. **Use exception types**: `Unrecoverable` vs `Recoverable` +2. **Idempotent handlers**: Safe to retry multiple times +3. **Monitor failed queue**: Set up alerts +4. **Reasonable max retries**: 3-5 usually sufficient +5. **Exponential backoff**: Don't hammer failing services +6. **Log failures**: With context for debugging + + +## Skill Operating Checklist + +### Design checklist +- Confirm operation boundaries and invariants first. +- Minimize scope while preserving contract correctness. +- Test both happy path and negative path behavior. + +### Validation commands +- php bin/console messenger:consume --limit=1 +- php bin/console messenger:failed:show +- ./vendor/bin/phpunit --filter=Messenger + +### Failure modes to test +- Invalid payload or forbidden actor. +- Boundary values / not-found cases. +- Retry or partial-failure behavior for async flows. + diff --git a/.agents/skills/symfony-ports-and-adapters/SKILL.md b/.agents/skills/symfony-ports-and-adapters/SKILL.md new file mode 100644 index 0000000..51a2916 --- /dev/null +++ b/.agents/skills/symfony-ports-and-adapters/SKILL.md @@ -0,0 +1,39 @@ +--- + +name: symfony-ports-and-adapters +allowed-tools: + - Read + - Glob + - Grep +description: Implement Hexagonal Architecture (Ports and Adapters) in Symfony; separate domain logic from infrastructure with clear boundaries +--- + +# Ports And Adapters (Symfony) + +## Use when +- Refining architecture/workflows/context handling in Symfony projects. +- Planning and executing medium/complex changes safely. + +## Default workflow +1. Establish current boundaries, constraints, and coupling points. +2. Propose smallest coherent architectural adjustment. +3. Execute in checkpoints with validation at each stage. +4. Summarize tradeoffs and follow-up backlog. + +## Guardrails +- Use existing project patterns by default. +- Avoid broad refactors without explicit need. +- Keep decision log clear and auditable. + +## Progressive disclosure +- Use this file for execution posture and risk controls. +- Open references when deep implementation details are needed. + +## Output contract +- Architecture/workflow changes. +- Checkpoint validation outcomes. +- Residual risks and next steps. + +## References +- `reference.md` +- `docs/complexity-tiers.md` diff --git a/.agents/skills/symfony-ports-and-adapters/reference.md b/.agents/skills/symfony-ports-and-adapters/reference.md new file mode 100644 index 0000000..4cef00e --- /dev/null +++ b/.agents/skills/symfony-ports-and-adapters/reference.md @@ -0,0 +1,422 @@ +# Reference + +# Ports and Adapters (Hexagonal Architecture) + +## Concept + +Hexagonal Architecture separates your application into three layers: +- **Domain**: Pure business logic, no framework dependencies +- **Application**: Use cases, orchestration +- **Infrastructure**: Symfony, Doctrine, external APIs + +``` +┌─────────────────────────────────────────────┐ +│ Infrastructure │ +│ ┌───────────────────────────────────────┐ │ +│ │ Application │ │ +│ │ ┌─────────────────────────────────┐ │ │ +│ │ │ Domain │ │ │ +│ │ │ (Entities, Value Objects, │ │ │ +│ │ │ Domain Services, Events) │ │ │ +│ │ └─────────────────────────────────┘ │ │ +│ │ (Use Cases, Commands, Queries) │ │ +│ └───────────────────────────────────────┘ │ +│ (Controllers, Repositories, APIs) │ +└─────────────────────────────────────────────┘ +``` + +## Directory Structure + +``` +src/ +├── Domain/ +│ ├── Order/ +│ │ ├── Entity/ +│ │ │ ├── Order.php +│ │ │ └── OrderItem.php +│ │ ├── ValueObject/ +│ │ │ ├── OrderId.php +│ │ │ └── Money.php +│ │ ├── Repository/ +│ │ │ └── OrderRepositoryInterface.php +│ │ ├── Service/ +│ │ │ └── OrderPricingService.php +│ │ └── Event/ +│ │ └── OrderCreated.php +│ └── User/ +│ └── ... +├── Application/ +│ ├── Order/ +│ │ ├── Command/ +│ │ │ ├── CreateOrder.php +│ │ │ └── CreateOrderHandler.php +│ │ └── Query/ +│ │ ├── GetOrder.php +│ │ └── GetOrderHandler.php +│ └── ... +└── Infrastructure/ + ├── Doctrine/ + │ └── Repository/ + │ └── DoctrineOrderRepository.php + ├── Controller/ + │ └── Api/ + │ └── OrderController.php + └── External/ + └── PaymentGateway/ + └── StripeAdapter.php +``` + +## Domain Layer + +### Entity (No ORM Annotations) + +```php +recordEvent(new OrderCreated($id, $customerId)); + + return $order; + } + + public function addItem(int $productId, int $quantity, Money $price): void + { + $this->items[] = new OrderItem($productId, $quantity, $price); + } + + public function getTotal(): Money + { + return array_reduce( + $this->items, + fn(Money $carry, OrderItem $item) => $carry->add($item->getSubtotal()), + Money::zero('EUR') + ); + } + + public function confirm(): void + { + if ($this->status !== OrderStatus::PENDING) { + throw new \DomainException('Only pending orders can be confirmed'); + } + + $this->status = OrderStatus::CONFIRMED; + } + + private function recordEvent(object $event): void + { + $this->domainEvents[] = $event; + } + + public function pullDomainEvents(): array + { + $events = $this->domainEvents; + $this->domainEvents = []; + return $events; + } + + // Getters... +} +``` + +### Value Object + +```php +currency !== $other->currency) { + throw new \InvalidArgumentException('Cannot add different currencies'); + } + + return new self($this->amount + $other->amount, $this->currency); + } + + public function getAmount(): int + { + return $this->amount; + } + + public function getCurrency(): string + { + return $this->currency; + } + + public function equals(self $other): bool + { + return $this->amount === $other->amount + && $this->currency === $other->currency; + } +} +``` + +### Port (Repository Interface) + +```php +orders->nextId(), + $command->customerId, + ); + + foreach ($command->items as $item) { + $order->addItem( + $item['productId'], + $item['quantity'], + Money::of($item['price'], 'EUR'), + ); + } + + $this->orders->save($order); + + return $order; + } +} +``` + +## Infrastructure Layer + +### Adapter (Doctrine Repository) + +```php +toRfc4122()); + } + + public function save(Order $order): void + { + $this->em->persist($order); + $this->em->flush(); + } + + public function findById(OrderId $id): ?Order + { + return $this->em->find(Order::class, $id->toString()); + } + + public function findByCustomer(int $customerId): array + { + return $this->em->getRepository(Order::class) + ->findBy(['customerId' => $customerId]); + } +} +``` + +### Doctrine Mapping (XML) + +```xml + + + + + + + + + + + +``` + +### Service Configuration + +```yaml +# config/services.yaml +services: + # Bind interface to implementation + App\Domain\Order\Repository\OrderRepositoryInterface: + '@App\Infrastructure\Doctrine\Repository\DoctrineOrderRepository' +``` + +## Controller (Infrastructure) + +```php +getContent(), true); + + $envelope = $this->bus->dispatch(new CreateOrder( + customerId: $data['customerId'], + items: $data['items'], + )); + + $order = $envelope->last(HandledStamp::class)->getResult(); + + return new JsonResponse(['id' => $order->getId()->toString()], 201); + } +} +``` + +## Benefits + +1. **Testability**: Domain is pure PHP, easily unit tested +2. **Flexibility**: Swap infrastructure without touching domain +3. **Focus**: Domain logic is isolated and explicit +4. **Framework agnostic**: Domain doesn't know about Symfony + + +## Skill Operating Checklist + +### Design checklist +- Confirm operation boundaries and invariants first. +- Minimize scope while preserving contract correctness. +- Test both happy path and negative path behavior. + +### Validation commands +- rg --files +- composer validate +- ./vendor/bin/phpstan analyse + +### Failure modes to test +- Invalid payload or forbidden actor. +- Boundary values / not-found cases. +- Retry or partial-failure behavior for async flows. + diff --git a/.agents/skills/symfony-rate-limiting/SKILL.md b/.agents/skills/symfony-rate-limiting/SKILL.md new file mode 100644 index 0000000..5dba37c --- /dev/null +++ b/.agents/skills/symfony-rate-limiting/SKILL.md @@ -0,0 +1,42 @@ +--- + +name: symfony-rate-limiting +allowed-tools: + - Read + - Write + - Edit + - Bash + - Glob + - Grep +description: Implement rate limiting with the Symfony RateLimiter (sliding window, token bucket, fixed window) and the #[RateLimit] controller attribute +--- + +# Rate Limiting (Symfony) + +## Use when +- Implementing asynchronous workflows with Messenger/Scheduler/Cache. +- Stabilizing retries and failure transports. + +## Default workflow +1. Define async contract and delivery semantics. +2. Implement idempotent handlers and routing strategy. +3. Configure retries, failure transport, and observability. +4. Validate success/failure replay scenarios. + +## Guardrails +- Assume at-least-once delivery, not exactly-once. +- Keep handlers deterministic and side-effect aware. +- Surface poison-message handling strategy. + +## Progressive disclosure +- Use this file for execution posture and risk controls. +- Open references when deep implementation details are needed. + +## Output contract +- Async config/handlers updated. +- Retry/failure policy decisions. +- Operational validation evidence. + +## References +- `reference.md` +- `docs/complexity-tiers.md` diff --git a/.agents/skills/symfony-rate-limiting/reference.md b/.agents/skills/symfony-rate-limiting/reference.md new file mode 100644 index 0000000..1c78de8 --- /dev/null +++ b/.agents/skills/symfony-rate-limiting/reference.md @@ -0,0 +1,401 @@ +# Reference + +# Symfony Rate Limiting + +## Installation + +```bash +composer require symfony/rate-limiter +``` + +## Configuration + +```yaml +# config/packages/rate_limiter.yaml +framework: + rate_limiter: + # Anonymous API requests + anonymous_api: + policy: sliding_window + limit: 100 + interval: '1 hour' + + # Authenticated API requests + authenticated_api: + policy: sliding_window + limit: 1000 + interval: '1 hour' + + # Login attempts + login: + policy: fixed_window + limit: 5 + interval: '15 minutes' + + # Contact form + contact_form: + policy: fixed_window + limit: 3 + interval: '1 hour' + + # Expensive operations + export: + policy: token_bucket + limit: 10 + rate: { interval: '1 hour', amount: 5 } +``` + +## Rate Limiting Algorithms + +### Fixed Window + +Simple count within time window: + +```yaml +login: + policy: fixed_window + limit: 5 + interval: '15 minutes' +``` + +### Sliding Window + +Smoother rate limiting, prevents burst at window edges: + +```yaml +api: + policy: sliding_window + limit: 100 + interval: '1 hour' +``` + +### Token Bucket + +Allows bursts while maintaining average rate: + +```yaml +export: + policy: token_bucket + limit: 10 # Bucket size (max burst) + rate: + interval: '1 hour' # Refill interval + amount: 5 # Tokens added per interval +``` + +## Policies + +- **fixed_window** — simplest; allows bursts at window edges. +- **sliding_window** — `75% * previous_window_hits + current_hits`. The + `anchor_at` option (8.1+ — verify) aligns windows to a calendar instant. +- **token_bucket** — tokens refilled at a rate; tolerates bursts up to bucket size. +- **compound** — combines several limiters; *all* must accept. + +```yaml +framework: + rate_limiter: + contact_form: + policy: compound + limiters: [two_per_minute, five_per_hour] + rolling_api: + policy: sliding_window + limit: 100 + interval: '1 hour' + anchor_at: '00:00' # 8.1+ — verify +``` + +## Using Rate Limiters + +### As a controller attribute (8.1+ — verify) + +The simplest path: declare the named limiter on the action. Returns +`429 Too Many Requests` with a `Retry-After` header automatically. + +```php +use Symfony\Component\ExpressionLanguage\Expression; +use Symfony\Component\HttpKernel\Attribute\RateLimit; + +#[RateLimit('api')] // default key = IP + method + path +public function index(): JsonResponse {} + +#[RateLimit('api', methods: ['POST', 'PUT', 'PATCH', 'DELETE'])] +public function edit(): JsonResponse {} + +#[RateLimit('per_account', key: new Expression('request.request.get("email")'))] +public function resetPassword(): Response {} + +#[RateLimit('per_account', key: fn (array $args, Request $request): string => $request->query->get('email'))] +public function resetViaLink(): Response {} + +#[RateLimit('api', tokens: 5)] // consume 5 tokens +public function export(): JsonResponse {} +``` + +Stack multiple `#[RateLimit]` attributes — all must pass. + +### In Controllers (injected service) + +Typehint `RateLimiterFactoryInterface` (not the concrete `RateLimiterFactory`) +and select the named limiter with `#[Target]`: + +```php +getUser() + ? $this->authenticatedApiLimiter->create($this->getUser()->getUserIdentifier()) + : $this->anonymousApiLimiter->create($request->getClientIp()); + + $limit = $limiter->consume(); + + if (!$limit->isAccepted()) { + return new JsonResponse( + ['error' => 'Too many requests. Please try again later.'], + Response::HTTP_TOO_MANY_REQUESTS, + [ + 'X-RateLimit-Remaining' => $limit->getRemainingTokens(), + 'X-RateLimit-Retry-After' => $limit->getRetryAfter()->getTimestamp(), + 'Retry-After' => $limit->getRetryAfter()->getTimestamp() - time(), + ] + ); + } + + // Add rate limit headers + $response = new JsonResponse(['data' => '...']); + $response->headers->set('X-RateLimit-Remaining', $limit->getRemainingTokens()); + $response->headers->set('X-RateLimit-Limit', $limit->getLimit()); + + return $response; + } +} +``` + +### In Services + +```php +exportLimiter->create($user->getId()); + $limit = $limiter->consume(); + + if (!$limit->isAccepted()) { + throw new TooManyRequestsException( + 'Export limit reached. Please wait.', + $limit->getRetryAfter() + ); + } + + return $this->generateExport($user); + } +} +``` + +### Login Rate Limiting + +```php +request->get('_username', ''); + $ip = $request->getClientIp(); + + return [ + $this->loginLimiter->create($ip), + $this->loginLimiter->create($username . $ip), + ]; + } +} +``` + +Configure in security: + +```yaml +# config/packages/security.yaml +security: + firewalls: + main: + form_login: + login_path: login + check_path: login + login_throttling: + limiter: login +``` + +## Event Subscriber for Global Rate Limiting + +```php + ['onRequest', 10], + ]; + } + + public function onRequest(RequestEvent $event): void + { + $request = $event->getRequest(); + + // Only rate limit API routes + if (!str_starts_with($request->getPathInfo(), '/api/')) { + return; + } + + $limiter = $this->apiLimiter->create($request->getClientIp()); + $limit = $limiter->consume(); + + if (!$limit->isAccepted()) { + $event->setResponse(new JsonResponse( + ['error' => 'Rate limit exceeded'], + Response::HTTP_TOO_MANY_REQUESTS, + ['Retry-After' => $limit->getRetryAfter()->getTimestamp() - time()] + )); + } + } +} +``` + +## Reserve Tokens (Blocking) + +Wait for tokens instead of rejecting: + +```php +$limiter = $this->exportLimiter->create($user->getId()); + +// Will block until token is available (max 30 seconds) +$reservation = $limiter->reserve(1, 30); + +// Wait for the reservation +$reservation->wait(); + +// Proceed with rate-limited operation +$this->generateExport($user); +``` + +## Testing + +```php + 'test', + 'policy' => 'fixed_window', + 'limit' => 3, + 'interval' => '1 minute', + ], new InMemoryStorage()); + + $limiter = $factory->create('user_123'); + + // First 3 requests should succeed + for ($i = 0; $i < 3; $i++) { + $this->assertTrue($limiter->consume()->isAccepted()); + } + + // 4th request should fail + $this->assertFalse($limiter->consume()->isAccepted()); + } +} +``` + +## Best Practices + +1. **Different limits by role**: More for authenticated users +2. **Compound keys**: IP + user for login attempts +3. **Return headers**: X-RateLimit-Remaining, Retry-After +4. **Sliding window** for APIs - smoother limiting +5. **Token bucket** for burst tolerance +6. **Redis storage** for distributed systems + + +## Skill Operating Checklist + +### Design checklist +- Confirm operation boundaries and invariants first. +- Minimize scope while preserving contract correctness. +- Test both happy path and negative path behavior. + +### Validation commands +- php bin/console messenger:consume --limit=1 +- php bin/console messenger:failed:show +- ./vendor/bin/phpunit --filter=Messenger + +### Failure modes to test +- Invalid payload or forbidden actor. +- Boundary values / not-found cases. +- Retry or partial-failure behavior for async flows. + diff --git a/.agents/skills/symfony-runner-selection/SKILL.md b/.agents/skills/symfony-runner-selection/SKILL.md new file mode 100644 index 0000000..3f5f8a9 --- /dev/null +++ b/.agents/skills/symfony-runner-selection/SKILL.md @@ -0,0 +1,38 @@ +--- + +name: symfony-runner-selection +allowed-tools: + - Read + - Glob + - Grep +description: Select and configure the appropriate command runner based on Docker Compose standard, Symfony Docker (FrankenPHP), or host environment +--- + +# Runner Selection (Symfony) + +## Use when +- Refining architecture/workflows/context handling in Symfony projects. +- Planning and executing medium/complex changes safely. + +## Default workflow +1. Establish current boundaries, constraints, and coupling points. +2. Propose smallest coherent architectural adjustment. +3. Execute in checkpoints with validation at each stage. +4. Summarize tradeoffs and follow-up backlog. + +## Guardrails +- Use existing project patterns by default. +- Avoid broad refactors without explicit need. +- Keep decision log clear and auditable. + +## Progressive disclosure +- Use this file for execution posture and risk controls. +- Open references when deep implementation details are needed. + +## Output contract +- Architecture/workflow changes. +- Checkpoint validation outcomes. +- Residual risks and next steps. + +## References +- `docs/complexity-tiers.md` diff --git a/.agents/skills/symfony-strategy-pattern/SKILL.md b/.agents/skills/symfony-strategy-pattern/SKILL.md new file mode 100644 index 0000000..f670260 --- /dev/null +++ b/.agents/skills/symfony-strategy-pattern/SKILL.md @@ -0,0 +1,39 @@ +--- + +name: symfony-strategy-pattern +allowed-tools: + - Read + - Glob + - Grep +description: Implement the Strategy pattern with Symfony's tagged services for runtime algorithm selection and extensibility +--- + +# Strategy Pattern (Symfony) + +## Use when +- Refining architecture/workflows/context handling in Symfony projects. +- Planning and executing medium/complex changes safely. + +## Default workflow +1. Establish current boundaries, constraints, and coupling points. +2. Propose smallest coherent architectural adjustment. +3. Execute in checkpoints with validation at each stage. +4. Summarize tradeoffs and follow-up backlog. + +## Guardrails +- Use existing project patterns by default. +- Avoid broad refactors without explicit need. +- Keep decision log clear and auditable. + +## Progressive disclosure +- Use this file for execution posture and risk controls. +- Open references when deep implementation details are needed. + +## Output contract +- Architecture/workflow changes. +- Checkpoint validation outcomes. +- Residual risks and next steps. + +## References +- `reference.md` +- `docs/complexity-tiers.md` diff --git a/.agents/skills/symfony-strategy-pattern/reference.md b/.agents/skills/symfony-strategy-pattern/reference.md new file mode 100644 index 0000000..31af499 --- /dev/null +++ b/.agents/skills/symfony-strategy-pattern/reference.md @@ -0,0 +1,390 @@ +# Reference + +# Strategy Pattern with Tagged Services + +## The Pattern + +Strategy allows selecting an algorithm at runtime. In Symfony, use tagged services for clean implementation. + +## Example: Payment Processors + +### Define Interface + +```php +stripe->charges->create([ + 'amount' => $payment->getAmount(), + 'currency' => $payment->getCurrency(), + 'source' => $payment->getToken(), + ]); + + return new PaymentResult( + success: $charge->status === 'succeeded', + transactionId: $charge->id, + ); + } + + public function refund(Payment $payment, int $amount): RefundResult + { + // Stripe refund implementation + } +} + +// src/Payment/Processor/PayPalProcessor.php + +#[AutoconfigureTag('app.payment_processor')] +class PayPalProcessor implements PaymentProcessorInterface +{ + public function supports(string $method): bool + { + return $method === 'paypal'; + } + + public function process(Payment $payment): PaymentResult + { + // PayPal implementation + } + + public function refund(Payment $payment, int $amount): RefundResult + { + // PayPal refund implementation + } +} + +// src/Payment/Processor/BankTransferProcessor.php + +#[AutoconfigureTag('app.payment_processor')] +class BankTransferProcessor implements PaymentProcessorInterface +{ + public function supports(string $method): bool + { + return $method === 'bank_transfer'; + } + + public function process(Payment $payment): PaymentResult + { + // Bank transfer - create pending payment + return new PaymentResult( + success: true, + transactionId: uniqid('bt_'), + pending: true, + ); + } + + public function refund(Payment $payment, int $amount): RefundResult + { + // Bank transfer refund + } +} +``` + +### Strategy Manager + +```php + $processors + */ + public function __construct( + #[AutowireIterator('app.payment_processor')] + private iterable $processors, + ) {} + + public function process(Payment $payment, string $method): PaymentResult + { + $processor = $this->getProcessor($method); + + return $processor->process($payment); + } + + public function refund(Payment $payment, int $amount): RefundResult + { + $processor = $this->getProcessor($payment->getMethod()); + + return $processor->refund($payment, $amount); + } + + public function getSupportedMethods(): array + { + $methods = []; + + foreach ($this->processors as $processor) { + // Each processor reports what it supports + } + + return $methods; + } + + private function getProcessor(string $method): PaymentProcessorInterface + { + foreach ($this->processors as $processor) { + if ($processor->supports($method)) { + return $processor; + } + } + + throw new UnsupportedPaymentMethodException($method); + } +} +``` + +## Example: Export Formats + +```php +exporters->has($format)) { + throw new UnsupportedFormatException($format); + } + + /** @var ExporterInterface $exporter */ + $exporter = $this->exporters->get($format); + + return new ExportResult( + content: $exporter->export($data), + contentType: $exporter->getContentType(), + filename: 'export.' . $exporter->getFileExtension(), + ); + } + + public function getAvailableFormats(): array + { + return array_keys($this->exporters->getProvidedServices()); + } +} +``` + +## Priority in Tagged Services + +```php +#[AutoconfigureTag('app.payment_processor', ['priority' => 10])] +class StripeProcessor implements PaymentProcessorInterface +{ + // Higher priority = checked first +} + +#[AutoconfigureTag('app.payment_processor', ['priority' => 0])] +class FallbackProcessor implements PaymentProcessorInterface +{ + // Lower priority = fallback +} +``` + +## Testing + +```php +class PaymentServiceTest extends TestCase +{ + public function testSelectsCorrectProcessor(): void + { + $stripe = $this->createMock(PaymentProcessorInterface::class); + $stripe->method('supports')->willReturnCallback( + fn($m) => $m === 'card' + ); + + $paypal = $this->createMock(PaymentProcessorInterface::class); + $paypal->method('supports')->willReturnCallback( + fn($m) => $m === 'paypal' + ); + + $service = new PaymentService([$stripe, $paypal]); + + // Verify correct processor is selected + $stripe->expects($this->once())->method('process'); + $service->process($payment, 'card'); + } +} +``` + +## Best Practices + +1. **Interface first**: Define clear contract +2. **AutoconfigureTag**: On interface or each implementation +3. **Service locator**: For direct access by key +4. **Iterator**: When checking all strategies +5. **Priority**: Control evaluation order +6. **Fallback**: Include a default strategy + + +## Skill Operating Checklist + +### Design checklist +- Confirm operation boundaries and invariants first. +- Minimize scope while preserving contract correctness. +- Test both happy path and negative path behavior. + +### Validation commands +- rg --files +- composer validate +- ./vendor/bin/phpstan analyse + +### Failure modes to test +- Invalid payload or forbidden actor. +- Boundary values / not-found cases. +- Retry or partial-failure behavior for async flows. + diff --git a/.agents/skills/symfony-symfony-cache/SKILL.md b/.agents/skills/symfony-symfony-cache/SKILL.md new file mode 100644 index 0000000..a6fde23 --- /dev/null +++ b/.agents/skills/symfony-symfony-cache/SKILL.md @@ -0,0 +1,42 @@ +--- + +name: symfony-symfony-cache +allowed-tools: + - Read + - Write + - Edit + - Bash + - Glob + - Grep +description: Implement caching with the Symfony Cache component; configure pools, use tags for invalidation, prevent stampede +--- + +# Symfony Cache (Symfony) + +## Use when +- Implementing asynchronous workflows with Messenger/Scheduler/Cache. +- Stabilizing retries and failure transports. + +## Default workflow +1. Define async contract and delivery semantics. +2. Implement idempotent handlers and routing strategy. +3. Configure retries, failure transport, and observability. +4. Validate success/failure replay scenarios. + +## Guardrails +- Assume at-least-once delivery, not exactly-once. +- Keep handlers deterministic and side-effect aware. +- Surface poison-message handling strategy. + +## Progressive disclosure +- Use this file for execution posture and risk controls. +- Open references when deep implementation details are needed. + +## Output contract +- Async config/handlers updated. +- Retry/failure policy decisions. +- Operational validation evidence. + +## References +- `reference.md` +- `docs/complexity-tiers.md` diff --git a/.agents/skills/symfony-symfony-cache/reference.md b/.agents/skills/symfony-symfony-cache/reference.md new file mode 100644 index 0000000..e1fb7c3 --- /dev/null +++ b/.agents/skills/symfony-symfony-cache/reference.md @@ -0,0 +1,402 @@ +# Reference + +# Symfony Cache + +## Installation + +```bash +composer require symfony/cache +``` + +## Basic Usage + +### Inject Cache + +```php +cache->get("product_{$id}", function (ItemInterface $item) use ($id) { + $item->expiresAfter(3600); // 1 hour + + return $this->repository->find($id); + }); + } +} +``` + +### Delete Cache + +```php +$this->cache->delete("product_{$id}"); +``` + +## Configuration + +```yaml +# config/packages/cache.yaml +framework: + cache: + # Default cache adapter + app: cache.adapter.redis + system: cache.adapter.system + + # Define pools + pools: + cache.products: + adapter: cache.adapter.redis + default_lifetime: 3600 + + cache.api_responses: + adapter: cache.adapter.filesystem + default_lifetime: 300 + + cache.sessions: + adapter: cache.adapter.redis + default_lifetime: 86400 +``` + +## Cache Adapters + +### Redis + +```yaml +# .env +REDIS_URL=redis://localhost:6379 + +# config/packages/cache.yaml +framework: + cache: + app: cache.adapter.redis + default_redis_provider: '%env(REDIS_URL)%' +``` + +### Filesystem + +```yaml +framework: + cache: + app: cache.adapter.filesystem +``` + +### APCu (In-memory) + +```yaml +framework: + cache: + app: cache.adapter.apcu +``` + +### Chained (Multi-tier) + +```yaml +framework: + cache: + pools: + cache.products: + adapters: + - cache.adapter.apcu # Fast, local + - cache.adapter.redis # Shared, persistent +``` + +## Cache Pools + +### Inject Specific Pool + +```php +cache->getItem($key); + + if (!$item->isHit()) { + $item->set($this->fetchData()); + $item->expiresAfter(3600); + $this->cache->save($item); + } + + return $item->get(); + } +} +``` + +## Cache Tags + +Tags allow invalidating groups of cache items: + +```php +cache->get("product_{$id}", function (ItemInterface $item) use ($id) { + $item->expiresAfter(3600); + $item->tag(['products', "product_{$id}", "category_{$categoryId}"]); + + return $this->repository->find($id); + }); + } + + public function getProductsByCategory(int $categoryId): array + { + return $this->cache->get("category_{$categoryId}_products", function (ItemInterface $item) use ($categoryId) { + $item->tag(['products', "category_{$categoryId}"]); + + return $this->repository->findByCategory($categoryId); + }); + } + + public function invalidateProduct(int $id): void + { + // Invalidate specific product + $this->cache->invalidateTags(["product_{$id}"]); + } + + public function invalidateCategory(int $categoryId): void + { + // Invalidate all products in category + $this->cache->invalidateTags(["category_{$categoryId}"]); + } + + public function invalidateAllProducts(): void + { + // Invalidate all product caches + $this->cache->invalidateTags(['products']); + } +} +``` + +### Configuration for Tag-Aware Cache + +```yaml +framework: + cache: + pools: + cache.products: + adapter: cache.adapter.redis + tags: true # generic tag support + # On Redis, prefer the dedicated tag-aware adapter for efficient + # tag storage and invalidation: + cache.catalog: + adapter: cache.adapter.redis_tag_aware +``` + +`TagAwareCacheInterface::invalidateTags([...])` (shown above) drops every item +carrying one of those tags. + +## Early Expiration (anti-stampede) + +To avoid a cache stampede when a hot key expires under concurrent load, ask the +callback to recompute *before* expiry. The probabilistic `$beta` (1.0 is a sane +default) decides when a request recomputes early; others keep serving the stale +value. + +```php +use Symfony\Contracts\Cache\ItemInterface; + +$value = $this->cache->get('home_feed', function (ItemInterface $item) { + $item->expiresAfter(3600); + return $this->buildExpensiveFeed(); +}, beta: 1.0); +``` + +For true zero-latency refresh, offload the recompute to Messenger via +`Symfony\Component\Cache\Messenger\EarlyExpirationHandler` + a callback service +implementing `CallbackInterface`, so the early-expiration recomputation runs in +a worker instead of the web request. + +## Marshaller per pool (8.1+ — verify) + +A pool can serialize/compress its values with a custom marshaller: + +```yaml +framework: + cache: + pools: + cache.products: + adapter: cache.adapter.redis + tags: true + # e.g. cache.default_marshaller, or a service compressing/encrypting payloads + # marshaller: App\Cache\MyMarshaller +``` + +## HTTP Cache + +### Response Caching + +```php +render('product/show.html.twig', [ + 'product' => $product, + ]); + + // Public cache (CDN, proxy) + $response->setPublic(); + $response->setMaxAge(3600); + $response->setSharedMaxAge(3600); + + // ETag for validation + $response->setEtag(md5($response->getContent())); + + return $response; + } +} +``` + +### Cache-Control Headers + +```php +$response->headers->set('Cache-Control', 'public, max-age=3600, s-maxage=3600'); + +// Or using methods +$response->setPublic(); +$response->setPrivate(); +$response->setMaxAge(3600); // Browser cache +$response->setSharedMaxAge(3600); // CDN/proxy cache +$response->setExpires(new \DateTime('+1 hour')); +``` + +## Cache Attributes + +```php +render('product/show.html.twig', [ + 'product' => $product, + ]); + } +} +``` + +## Cache Warmup + +```php +products->findPopular(100) as $product) { + $this->cache->get("product_{$product->getId()}", fn() => $product); + } + + return []; + } + + public function isOptional(): bool + { + return true; + } +} +``` + +## Clear Cache + +```bash +# Clear all caches +bin/console cache:clear + +# Clear specific pool +bin/console cache:pool:clear cache.products + +# Clear by tag +bin/console cache:pool:invalidate-tags cache.products products +``` + +## Best Practices + +1. **Use tags**: For flexible invalidation +2. **Set TTLs**: Don't cache forever +3. **Warm critical caches**: Pre-populate on deploy +4. **Monitor hit rates**: Track cache effectiveness +5. **Chain adapters**: Fast local + shared persistent +6. **Invalidate precisely**: Don't clear everything + + +## Skill Operating Checklist + +### Design checklist +- Confirm operation boundaries and invariants first. +- Minimize scope while preserving contract correctness. +- Test both happy path and negative path behavior. + +### Validation commands +- php bin/console messenger:consume --limit=1 +- php bin/console messenger:failed:show +- ./vendor/bin/phpunit --filter=Messenger + +### Failure modes to test +- Invalid payload or forbidden actor. +- Boundary values / not-found cases. +- Retry or partial-failure behavior for async flows. + diff --git a/.agents/skills/symfony-symfony-messenger/SKILL.md b/.agents/skills/symfony-symfony-messenger/SKILL.md new file mode 100644 index 0000000..1e7f784 --- /dev/null +++ b/.agents/skills/symfony-symfony-messenger/SKILL.md @@ -0,0 +1,41 @@ +--- +name: symfony-symfony-messenger +allowed-tools: + - Read + - Write + - Edit + - Bash + - Glob + - Grep +description: Async message handling with Symfony Messenger; configure transports (RabbitMQ, Redis, Doctrine); implement handlers, middleware, and retry strategies +--- + +# Symfony Messenger (Symfony) + +## Use when +- Implementing asynchronous workflows with Messenger/Scheduler/Cache. +- Stabilizing retries and failure transports. + +## Default workflow +1. Define async contract and delivery semantics. +2. Implement idempotent handlers and routing strategy. +3. Configure retries, failure transport, and observability. +4. Validate success/failure replay scenarios. + +## Guardrails +- Assume at-least-once delivery, not exactly-once. +- Keep handlers deterministic and side-effect aware. +- Surface poison-message handling strategy. + +## Progressive disclosure +- Use this file for execution posture and risk controls. +- Open references when deep implementation details are needed. + +## Output contract +- Async config/handlers updated. +- Retry/failure policy decisions. +- Operational validation evidence. + +## References +- `reference.md` +- `docs/complexity-tiers.md` diff --git a/.agents/skills/symfony-symfony-messenger/reference.md b/.agents/skills/symfony-symfony-messenger/reference.md new file mode 100644 index 0000000..784a8ab --- /dev/null +++ b/.agents/skills/symfony-symfony-messenger/reference.md @@ -0,0 +1,240 @@ +# Symfony Messenger Reference (Symfony) + +Use this reference for implementation details and review criteria specific to `symfony-messenger`. + +## Message + Handler + +A message is a plain, serializable data class. The handler is a service with +`#[AsMessageHandler]` and a type-hinted `__invoke()`. + +```php +users->find($message->userId) + ?? throw new \RuntimeException("User {$message->userId} not found"); + + // ... send the email + } +} +``` + +Dispatch from anywhere with `MessageBusInterface`: + +```php +$bus->dispatch(new SendWelcomeEmail($user->getId())); +``` + +## Routing + +### Via attribute on the message class + +```php +orders->find($message->orderId); + if ($order->getStatus() === OrderStatus::Processed) { + return; // already done — success, don't throw + } + // ... process, then persist the new status + } + ``` + +## Recent features (Symfony 8.1+) + +> Marked 8.1+ — verify the exact minimum version before relying on it. + +- `PriorityStamp` for message-level prioritization (AMQP/Beanstalkd): + ```php + use Symfony\Component\Messenger\Envelope; + use Symfony\Component\Messenger\Stamp\PriorityStamp; + + $bus->dispatch((new Envelope($message))->with(new PriorityStamp(255))); + ``` +- `messenger:consume scheduler_.*` — regex receiver names. +- `--fetch-size=N` — batch prefetch from the transport. +- `--no-reset=N` — reset stateful services every N messages. +- Graceful shutdown via PCNTL signals: + ```yaml + framework: + messenger: + stop_worker_on_signals: [SIGTERM, SIGINT, SIGUSR1] + ``` + Trigger a rolling restart on deploy with `php bin/console messenger:stop-workers`. +- `ListableReceiverInterface` (Redis): `->all(10)`, `->find($id)`. +- Per-transport `rate_limiter`. +- Decode failures are now routed through the retry/failure pipeline instead of + being silently dropped. + + +## Skill Operating Checklist + +### Design checklist +- Confirm operation boundaries and invariants first. +- Minimize scope while preserving contract correctness. +- Test both happy path and negative path behavior. + +### Validation commands +- php bin/console messenger:consume --limit=1 +- php bin/console messenger:failed:show +- ./vendor/bin/phpunit --filter=Messenger + +### Failure modes to test +- Invalid payload or forbidden actor. +- Boundary values / not-found cases. +- Retry or partial-failure behavior for async flows. + diff --git a/.agents/skills/symfony-symfony-scheduler/SKILL.md b/.agents/skills/symfony-symfony-scheduler/SKILL.md new file mode 100644 index 0000000..36b25eb --- /dev/null +++ b/.agents/skills/symfony-symfony-scheduler/SKILL.md @@ -0,0 +1,42 @@ +--- + +name: symfony-symfony-scheduler +allowed-tools: + - Read + - Write + - Edit + - Bash + - Glob + - Grep +description: Schedule recurring tasks with the Symfony Scheduler component (native since 7.x); define schedules, triggers, and integrate with Messenger +--- + +# Symfony Scheduler (Symfony) + +## Use when +- Implementing asynchronous workflows with Messenger/Scheduler/Cache. +- Stabilizing retries and failure transports. + +## Default workflow +1. Define async contract and delivery semantics. +2. Implement idempotent handlers and routing strategy. +3. Configure retries, failure transport, and observability. +4. Validate success/failure replay scenarios. + +## Guardrails +- Assume at-least-once delivery, not exactly-once. +- Keep handlers deterministic and side-effect aware. +- Surface poison-message handling strategy. + +## Progressive disclosure +- Use this file for execution posture and risk controls. +- Open references when deep implementation details are needed. + +## Output contract +- Async config/handlers updated. +- Retry/failure policy decisions. +- Operational validation evidence. + +## References +- `reference.md` +- `docs/complexity-tiers.md` diff --git a/.agents/skills/symfony-symfony-scheduler/reference.md b/.agents/skills/symfony-symfony-scheduler/reference.md new file mode 100644 index 0000000..5535046 --- /dev/null +++ b/.agents/skills/symfony-symfony-scheduler/reference.md @@ -0,0 +1,352 @@ +# Reference + +# Symfony Scheduler + +Available in Symfony 7.1+ as a native component. + +## Installation + +```bash +composer require symfony/scheduler +``` + +## Basic Schedule + +### Define a Schedule + +```php +add(RecurringMessage::every('5 minutes', new SyncInventory())) + + // Daily at 2 AM + ->add(RecurringMessage::every('1 day', new GenerateDailyReport()) + ->from(new \DateTimeImmutable('02:00'))) + + // Every hour + ->add(RecurringMessage::every('1 hour', new CleanupExpiredSessions())) + + // Cron expression + ->add(RecurringMessage::cron('0 */6 * * *', new ProcessQueuedEmails())) + ; + } +} +``` + +### Message Classes + +```php +forDate ?? new \DateTimeImmutable('yesterday'); + + $this->reportService->generate($date); + $this->logger->info('Daily report generated', ['date' => $date->format('Y-m-d')]); + } +} +``` + +## Trigger Types + +### Time-Based Triggers + +```php +use Symfony\Component\Scheduler\RecurringMessage; +use Symfony\Component\Scheduler\Trigger\CronExpressionTrigger; + +// Every X interval +RecurringMessage::every('5 minutes', new MyMessage()) +RecurringMessage::every('1 hour', new MyMessage()) +RecurringMessage::every('1 day', new MyMessage()) +RecurringMessage::every('1 week', new MyMessage()) + +// Cron expressions +RecurringMessage::cron('*/5 * * * *', new MyMessage()) // Every 5 minutes +RecurringMessage::cron('0 * * * *', new MyMessage()) // Every hour +RecurringMessage::cron('0 0 * * *', new MyMessage()) // Daily at midnight +RecurringMessage::cron('0 0 * * 0', new MyMessage()) // Weekly on Sunday +RecurringMessage::cron('0 0 1 * *', new MyMessage()) // Monthly on 1st + +// With timezone +RecurringMessage::cron('0 9 * * 1-5', new MyMessage()) + ->timezone(new \DateTimeZone('Europe/Paris')) + +// Starting from specific time +RecurringMessage::every('1 day', new MyMessage()) + ->from(new \DateTimeImmutable('06:00')) + +// Until specific time +RecurringMessage::every('1 hour', new MyMessage()) + ->until(new \DateTimeImmutable('2024-12-31')) +``` + +### Custom Trigger + +```php +inner . ')'; + } + + public function getNextRunDate(\DateTimeImmutable $run): ?\DateTimeImmutable + { + $next = $this->inner->getNextRunDate($run); + + if ($next === null) { + return null; + } + + // Skip weekends + while ((int) $next->format('N') >= 6) { + $next = $next->modify('+1 day')->setTime(9, 0); + } + + // Only between 9 AM and 6 PM + $hour = (int) $next->format('H'); + if ($hour < 9) { + $next = $next->setTime(9, 0); + } elseif ($hour >= 18) { + $next = $next->modify('+1 day')->setTime(9, 0); + } + + return $next; + } +} +``` + +## Multiple Schedules + +```php +add(RecurringMessage::cron('0 3 * * *', new DatabaseBackup())) + ->add(RecurringMessage::cron('0 4 * * 0', new CleanupLogs())) + ; + } +} +``` + +Run specific schedule: + +```bash +bin/console messenger:consume scheduler_maintenance +``` + +## Running the Scheduler + +### As Messenger Transport + +The scheduler creates a special transport: + +```bash +# Run the default schedule +bin/console messenger:consume scheduler_default + +# Run specific schedule +bin/console messenger:consume scheduler_maintenance + +# With worker options +bin/console messenger:consume scheduler_default --time-limit=3600 +``` + +### Supervisor Configuration + +```ini +[program:scheduler] +command=php /var/www/app/bin/console messenger:consume scheduler_default --time-limit=3600 +user=www-data +numprocs=1 +autostart=true +autorestart=true +startretries=10 +stderr_logfile=/var/log/scheduler.err.log +stdout_logfile=/var/log/scheduler.out.log +``` + +## Stateful Schedules + +Track last run and prevent overlap: + +```php +stateful($this->cache) // Remember last run times + ->lock($this->lockFactory->createLock('scheduler')) // Prevent overlap + ->add(RecurringMessage::every('1 hour', new HeavyTask())) + ; + } +} +``` + +## Testing Schedules + +```php +getSchedule(); + + $messages = $schedule->getRecurringMessages(); + + // Find the daily report message + $dailyReport = array_filter( + iterator_to_array($messages), + fn($m) => $m->getMessage() instanceof GenerateDailyReport + ); + + $this->assertCount(1, $dailyReport); + + // Check next run time + $message = reset($dailyReport); + $trigger = $message->getTrigger(); + $nextRun = $trigger->getNextRunDate(new \DateTimeImmutable()); + + $this->assertEquals('02', $nextRun->format('H')); + } +} +``` + +## Monitoring + +```php + 'onPreRun', + PostRunEvent::class => 'onPostRun', + ]; + } + + public function onPreRun(PreRunEvent $event): void + { + $this->logger->info('Starting scheduled task', [ + 'message' => get_class($event->getMessage()), + ]); + } + + public function onPostRun(PostRunEvent $event): void + { + $this->logger->info('Completed scheduled task', [ + 'message' => get_class($event->getMessage()), + 'duration' => $event->getDuration(), + ]); + } +} +``` + +## Best Practices + +1. **Stateful schedules**: Use cache to track last run +2. **Lock heavy tasks**: Prevent overlapping executions +3. **Supervisor**: Use for production reliability +4. **Monitor execution**: Log start/end times +5. **Idempotent tasks**: Safe to re-run if needed +6. **Timezone awareness**: Be explicit about timezones + + +## Skill Operating Checklist + +### Design checklist +- Confirm operation boundaries and invariants first. +- Minimize scope while preserving contract correctness. +- Test both happy path and negative path behavior. + +### Validation commands +- php bin/console messenger:consume --limit=1 +- php bin/console messenger:failed:show +- ./vendor/bin/phpunit --filter=Messenger + +### Failure modes to test +- Invalid payload or forbidden actor. +- Boundary values / not-found cases. +- Retry or partial-failure behavior for async flows. + diff --git a/.agents/skills/symfony-symfony-voters/SKILL.md b/.agents/skills/symfony-symfony-voters/SKILL.md new file mode 100644 index 0000000..253b9e6 --- /dev/null +++ b/.agents/skills/symfony-symfony-voters/SKILL.md @@ -0,0 +1,41 @@ +--- +name: symfony-symfony-voters +allowed-tools: + - Read + - Write + - Edit + - Bash + - Glob + - Grep +description: Implement granular authorization with Symfony Voters; decouple permission logic from controllers; test authorization separately +--- + +# Symfony Voters (Symfony) + +## Use when +- Hardening access-control or validation boundaries. +- Aligning voters/security expressions with domain rules. + +## Default workflow +1. Map actor/resource/action decision matrix. +2. Implement voter/constraint logic at the right boundary. +3. Wire checks at controllers and API operations. +4. Test allowed/forbidden/invalid paths comprehensively. + +## Guardrails +- Avoid policy logic duplication across layers. +- Do not leak privileged state via error detail. +- Preserve explicit deny behavior for sensitive actions. + +## Progressive disclosure +- Use this file for execution posture and risk controls. +- Open references when deep implementation details are needed. + +## Output contract +- Security boundary updates. +- Integration points enforcing decisions. +- Negative-path test results. + +## References +- `reference.md` +- `docs/complexity-tiers.md` diff --git a/.agents/skills/symfony-symfony-voters/reference.md b/.agents/skills/symfony-symfony-voters/reference.md new file mode 100644 index 0000000..4cf7313 --- /dev/null +++ b/.agents/skills/symfony-symfony-voters/reference.md @@ -0,0 +1,154 @@ +# Symfony Voters Reference (Symfony) + +Use this reference for implementation details and review criteria specific to `symfony-voters`. + +## Anatomy of a voter + +Extend `Voter` and implement `supports()` and `voteOnAttribute()`. + +```php +getUser(); + if (!$user instanceof User) { + $vote?->addReason('The user is not authenticated.'); + return false; + } + + /** @var Post $subject */ + return match ($attribute) { + self::VIEW => $subject->isPublished() || $subject->getAuthor() === $user, + self::EDIT => $this->canEdit($subject, $user, $vote), + default => false, + }; + } + + private function canEdit(Post $post, User $user, ?Vote $vote): bool + { + if ($post->getAuthor() !== $user) { + $vote?->addReason('Only the author may edit this post.'); + $vote?->extraData['author_id'] = $post->getAuthor()->getId(); + return false; + } + return true; + } +} +``` + +## Using voters + +Controller attribute (preferred) — the second arg names the request attribute +holding the subject: + +```php +use Symfony\Component\Security\Http\Attribute\IsGranted; + +#[IsGranted(PostVoter::EDIT, 'post', message: 'You cannot edit this post.', statusCode: 403)] +public function edit(Post $post): Response { /* ... */ } +``` + +Imperatively in a controller: + +```php +$this->denyAccessUnlessGranted(PostVoter::EDIT, $post); +``` + +## Priority + +`Voter` already implements `CacheableVoterInterface` (via `supports()`), so +`isGranted()` only calls voters whose `supportsType()`/`supportsAttribute()` +match. To order voters explicitly: + +```php +use Symfony\Component\DependencyInjection\Attribute\AsTaggedItem; + +#[AsTaggedItem(priority: 10)] // higher priority runs first +final class PostVoter extends Voter {} +``` + +## Checking a role *inside* a voter + +Never call `Security::isGranted()` inside a voter — it runs with the wrong token +context and can recurse. Inject `AccessDecisionManagerInterface` and use the +token you were handed: + +```php +use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface; + +public function __construct( + private readonly AccessDecisionManagerInterface $accessDecisionManager, +) {} + +// inside voteOnAttribute(): +if ($this->accessDecisionManager->decide($token, ['ROLE_SUPER_ADMIN'])) { + return true; // admins bypass ownership checks +} +``` + +## Access decision strategies + +```yaml +# config/packages/security.yaml +security: + access_decision_manager: + strategy: affirmative # affirmative (default) | consensus | unanimous | priority + allow_if_all_abstain: false +``` + +| Strategy | Grants access when… | +|----------|---------------------| +| `affirmative` (default) | any voter grants | +| `consensus` | more voters grant than deny | +| `unanimous` | no voter denies | +| `priority` | the first non-abstaining voter decides | + +Custom strategy: `strategy_service` implementing `AccessDecisionStrategyInterface`. + + +## Skill Operating Checklist + +### Design checklist +- Confirm operation boundaries and invariants first. +- Minimize scope while preserving contract correctness. +- Test both happy path and negative path behavior. + +### Validation commands +- ./vendor/bin/phpunit --filter=Voter +- php bin/console debug:container security +- ./vendor/bin/phpstan analyse + +### Failure modes to test +- Invalid payload or forbidden actor. +- Boundary values / not-found cases. +- Retry or partial-failure behavior for async flows. + diff --git a/.agents/skills/symfony-tdd-with-phpunit/SKILL.md b/.agents/skills/symfony-tdd-with-phpunit/SKILL.md new file mode 100644 index 0000000..ad1afef --- /dev/null +++ b/.agents/skills/symfony-tdd-with-phpunit/SKILL.md @@ -0,0 +1,41 @@ +--- +name: symfony-tdd-with-phpunit +allowed-tools: + - Read + - Write + - Edit + - Bash + - Glob + - Grep +description: Apply RED-GREEN-REFACTOR with PHPUnit 10/11 for Symfony; KernelTestCase/WebTestCase, attributes (#[Test]/#[DataProvider]), Foundry +--- + +# Tdd With Phpunit (Symfony) + +## Use when +- Building regression-safe behavior with TDD/functional/e2e tests. +- Converting bug reports into executable failing tests. + +## Default workflow +1. Write failing test for target behavior and one boundary case. +2. Implement minimal code to pass. +3. Refactor while preserving green suite. +4. Broaden coverage for invalid/unauthorized/not-found paths. + +## Guardrails +- Prefer deterministic fixtures/builders. +- Assert observable behavior, not internal implementation. +- Keep tests isolated and stable in CI. + +## Progressive disclosure +- Use this file for execution posture and risk controls. +- Open references when deep implementation details are needed. + +## Output contract +- RED/GREEN/REFACTOR trace. +- Test files changed and executed commands. +- Coverage and confidence notes. + +## References +- `reference.md` +- `docs/complexity-tiers.md` diff --git a/.agents/skills/symfony-tdd-with-phpunit/reference.md b/.agents/skills/symfony-tdd-with-phpunit/reference.md new file mode 100644 index 0000000..0da9864 --- /dev/null +++ b/.agents/skills/symfony-tdd-with-phpunit/reference.md @@ -0,0 +1,222 @@ +# Tdd With Phpunit Reference (Symfony) + +Use this reference for implementation details and review criteria specific to `tdd-with-phpunit`. + +> **Version applicability** — Targets **PHPUnit 10/11** with Symfony 7.4 LTS / 8.x. +> PHPUnit 10+ uses **PHP attributes** (`#[Test]`, `#[DataProvider]`, `#[CoversClass]`) +> — the old `/** @test @dataProvider */` annotations are deprecated/removed. +> `static::getContainer()` replaces legacy `self::$container`. Console test helpers and +> 422 assertions are Symfony 8.1+ where noted. + +## The TDD cycle + +``` +RED → write a failing test that expresses the desired behavior +GREEN → write the minimum code to make it pass +REFACTOR → improve structure with the test as a safety net (stay green) +``` + +Repeat per behavior. Never write production code without a failing test first. + +## Test base classes + +| Base class | Use for | +| --- | --- | +| `TestCase` (PHPUnit) | Pure logic — no kernel, no container, fastest. | +| `KernelTestCase` | Integration — boot kernel, real container/services. | +| `WebTestCase` | Functional — simulate full HTTP requests. | + +## Attributes (PHPUnit 10+) + +Annotations are out; use attributes: + +```php +use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\CoversClass; + +#[CoversClass(PriceCalculator::class)] +final class PriceCalculatorTest extends TestCase +{ + #[Test] + public function it_applies_vat(): void + { + $calc = new PriceCalculator(); + self::assertSame(120.0, $calc->withVat(100.0, 0.20)); + } + + #[Test] + #[DataProvider('vatCases')] + public function it_applies_various_rates(float $net, float $rate, float $expected): void + { + self::assertSame($expected, (new PriceCalculator())->withVat($net, $rate)); + } + + public static function vatCases(): \Generator + { + yield 'standard' => [100.0, 0.20, 120.0]; + yield 'reduced' => [100.0, 0.055, 105.5]; + yield 'zero rate' => [100.0, 0.0, 100.0]; + } +} +``` + +## RED — write the failing test first + +### Unit (no kernel) + +```php +#[Test] +public function it_rejects_an_empty_order(): void +{ + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Order must have at least one item'); + + (new OrderService($this->createMock(EntityManagerInterface::class))) + ->createOrder($user, []); +} +``` + +### Integration (KernelTestCase + real container) + +```php +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; + +final class OrderServiceTest extends KernelTestCase +{ + #[Test] + public function it_creates_an_order(): void + { + self::bootKernel(); + $service = static::getContainer()->get(OrderService::class); + + $order = $service->createOrder($user, [['productId' => 1, 'quantity' => 2]]); + + self::assertCount(1, $order->getItems()); + } +} +``` + +## GREEN — minimum code to pass + +```php +final class OrderService +{ + public function __construct(private EntityManagerInterface $em) {} + + public function createOrder(User $user, array $items): Order + { + if (empty($items)) { + throw new \InvalidArgumentException('Order must have at least one item'); + } + // ... build + persist + flush, nothing speculative + return $order; + } +} +``` + +## REFACTOR — stay green + +Extract collaborators, introduce value objects, move queries to repositories. Re-run the +suite after each change. + +## Mocking services in the test container + +In `KernelTestCase`/`WebTestCase` you can swap a real service for a test double via the +test container: + +```php +$mock = $this->createMock(NewsRepositoryInterface::class); +$mock->method('findActive')->willReturn([]); + +static::getContainer()->set(NewsRepositoryInterface::class, $mock); +``` + +For **non-shared** services, pass a closure (Symfony 8.1+) so a fresh instance is returned +each time: + +```php +static::getContainer()->set(Mailer::class, fn(): Mailer => $mock); +``` + +> Prefer real services + real DB for repositories (see the `functional-tests` skill). +> Reserve mocks for true external boundaries (HTTP clients, third-party SDKs). + +## Testing console commands + +Classic approach with `CommandTester`: + +```php +use Symfony\Component\Console\Tester\CommandTester; + +$command = (new Application(self::$kernel))->find('app:import'); +$tester = new CommandTester($command); +$tester->execute(['file' => 'data.csv']); + +$tester->assertCommandIsSuccessful(); +self::assertStringContainsString('Imported', $tester->getDisplay()); +``` + +Symfony **8.1+** adds a `runCommand()` helper returning an `ExecutionResult`: + +```php +final class ImportCommandTest extends KernelTestCase +{ + #[Test] + public function it_imports(): void + { + $result = static::runCommand('app:import data.csv'); + + $this->assertCommandIsSuccessful($result); // 8.1+ assertion + self::assertStringContainsString('Imported', $result->getDisplay()); + } +} +``` + +Related console assertions (8.1+): `assertCommandIsSuccessful`, `assertCommandFailed`, +`assertCommandResultEquals`. + +## HTTP / form status assertions + +When a controller renders an invalid form (Symfony 8.0+ passes the form, not the view), +the response is **HTTP 422**: + +```php +#[Test] +public function it_rejects_invalid_submission(): void +{ + $client = static::createClient(); + $client->request('POST', '/register', ['email' => 'not-an-email']); + + $this->assertResponseIsUnprocessable(); // 422 + $this->assertSelectorTextContains('.form-error', 'valid email'); +} +``` + +## Review criteria + +- A failing test exists before the production change (RED first). +- Tests use PHPUnit 10+ **attributes** (`#[Test]`, `#[DataProvider]`, `#[CoversClass]`), not annotations. +- Correct base class: `TestCase` (logic) / `KernelTestCase` (services) / `WebTestCase` (HTTP). +- Services fetched via `static::getContainer()`; mocks only at external boundaries. +- Console tests use `runCommand()` + `assertCommandIsSuccessful` (8.1+) or `CommandTester`. +- Invalid form/HTTP cases assert 422 via `assertResponseIsUnprocessable()`. + + +## Skill Operating Checklist + +### Design checklist +- Confirm operation boundaries and invariants first. +- Minimize scope while preserving contract correctness. +- Test both happy path and negative path behavior. + +### Validation commands +- ./vendor/bin/phpunit --filter=... +- ./vendor/bin/phpunit +- ./vendor/bin/pest --filter=... + +### Failure modes to test +- Invalid payload or forbidden actor. +- Boundary values / not-found cases. +- Retry or partial-failure behavior for async flows. diff --git a/.agents/skills/symfony-test-doubles-mocking/SKILL.md b/.agents/skills/symfony-test-doubles-mocking/SKILL.md new file mode 100644 index 0000000..8c126c6 --- /dev/null +++ b/.agents/skills/symfony-test-doubles-mocking/SKILL.md @@ -0,0 +1,39 @@ +--- + +name: symfony-test-doubles-mocking +allowed-tools: + - Read + - Glob + - Grep +description: Create test doubles with PHPUnit mocks for isolated unit testing in Symfony +--- + +# Test Doubles Mocking (Symfony) + +## Use when +- Building regression-safe behavior with TDD/functional/e2e tests. +- Converting bug reports into executable failing tests. + +## Default workflow +1. Write failing test for target behavior and one boundary case. +2. Implement minimal code to pass. +3. Refactor while preserving green suite. +4. Broaden coverage for invalid/unauthorized/not-found paths. + +## Guardrails +- Prefer deterministic fixtures/builders. +- Assert observable behavior, not internal implementation. +- Keep tests isolated and stable in CI. + +## Progressive disclosure +- Use this file for execution posture and risk controls. +- Open references when deep implementation details are needed. + +## Output contract +- RED/GREEN/REFACTOR trace. +- Test files changed and executed commands. +- Coverage and confidence notes. + +## References +- `reference.md` +- `docs/complexity-tiers.md` diff --git a/.agents/skills/symfony-test-doubles-mocking/reference.md b/.agents/skills/symfony-test-doubles-mocking/reference.md new file mode 100644 index 0000000..82b4c79 --- /dev/null +++ b/.agents/skills/symfony-test-doubles-mocking/reference.md @@ -0,0 +1,323 @@ +# Reference + +# Test Doubles and Mocking + +## Types of Test Doubles + +| Type | Purpose | +|------|---------| +| **Dummy** | Passed but never used | +| **Stub** | Returns predetermined values | +| **Mock** | Verifies interactions | +| **Spy** | Records calls for later verification | +| **Fake** | Working implementation (simplified) | + +## PHPUnit Mocks + +### Basic Mock + +```php +createMock(PaymentGateway::class); + + // Configure return value + $gateway->method('charge') + ->willReturn(new PaymentResult(success: true, transactionId: 'tx_123')); + + $service = new OrderService($gateway); + $result = $service->processPayment(1000, 'EUR'); + + $this->assertTrue($result->isSuccessful()); + } +} +``` + +### Mock with Expectations + +```php +public function testChargesCorrectAmount(): void +{ + $gateway = $this->createMock(PaymentGateway::class); + + // Expect specific call + $gateway->expects($this->once()) + ->method('charge') + ->with( + $this->equalTo(1000), + $this->equalTo('EUR') + ) + ->willReturn(new PaymentResult(success: true)); + + $service = new OrderService($gateway); + $service->processPayment(1000, 'EUR'); +} +``` + +### Consecutive Returns + +```php +public function testRetriesOnFailure(): void +{ + $gateway = $this->createMock(PaymentGateway::class); + + $gateway->expects($this->exactly(2)) + ->method('charge') + ->willReturnOnConsecutiveCalls( + new PaymentResult(success: false), // First call fails + new PaymentResult(success: true) // Second succeeds + ); + + $service = new OrderService($gateway); + $result = $service->processPaymentWithRetry(1000, 'EUR'); + + $this->assertTrue($result->isSuccessful()); +} +``` + +### Throwing Exceptions + +```php +public function testHandlesGatewayError(): void +{ + $gateway = $this->createMock(PaymentGateway::class); + + $gateway->method('charge') + ->willThrowException(new GatewayException('Connection timeout')); + + $service = new OrderService($gateway); + + $this->expectException(PaymentFailedException::class); + $service->processPayment(1000, 'EUR'); +} +``` + +### Callback for Complex Logic + +```php +public function testDynamicResponse(): void +{ + $repository = $this->createMock(ProductRepository::class); + + $repository->method('find') + ->willReturnCallback(function (int $id) { + if ($id === 1) { + return new Product(id: 1, name: 'Product 1'); + } + return null; + }); + + $service = new ProductService($repository); + + $this->assertNotNull($service->getProduct(1)); + $this->assertNull($service->getProduct(999)); +} +``` + +## Prophecy (Alternative) + +Prophecy provides a different syntax, often considered more readable. + +```php +prophesize(PaymentGateway::class); + + // Stub method + $gateway->charge(1000, 'EUR') + ->willReturn(new PaymentResult(success: true)); + + // Reveal to get actual mock + $service = new OrderService($gateway->reveal()); + + $result = $service->processPayment(1000, 'EUR'); + $this->assertTrue($result->isSuccessful()); + } + + public function testCallsGatewayOnce(): void + { + $gateway = $this->prophesize(PaymentGateway::class); + + // Expect call + $gateway->charge(1000, 'EUR') + ->shouldBeCalledOnce() + ->willReturn(new PaymentResult(success: true)); + + $service = new OrderService($gateway->reveal()); + $service->processPayment(1000, 'EUR'); + } +} +``` + +## Mocking Symfony Services + +### EntityManager + +```php +public function testPersistsEntity(): void +{ + $em = $this->createMock(EntityManagerInterface::class); + + $em->expects($this->once()) + ->method('persist') + ->with($this->isInstanceOf(User::class)); + + $em->expects($this->once()) + ->method('flush'); + + $service = new UserService($em); + $service->createUser('test@example.com'); +} +``` + +### Repository + +```php +public function testFindsUser(): void +{ + $user = new User(); + $user->setEmail('test@example.com'); + + $repository = $this->createMock(UserRepository::class); + $repository->method('findOneByEmail') + ->with('test@example.com') + ->willReturn($user); + + $service = new UserService($repository); + $found = $service->findByEmail('test@example.com'); + + $this->assertSame($user, $found); +} +``` + +### MessageBus + +```php +public function testDispatchesMessage(): void +{ + $bus = $this->createMock(MessageBusInterface::class); + + $bus->expects($this->once()) + ->method('dispatch') + ->with($this->callback(function ($message) { + return $message instanceof SendWelcomeEmail + && $message->userId === 123; + })) + ->willReturn(new Envelope(new \stdClass())); + + $service = new RegistrationService($bus); + $service->register(123, 'test@example.com'); +} +``` + +## Partial Mocks + +Mock only some methods: + +```php +public function testPartialMock(): void +{ + $service = $this->getMockBuilder(OrderService::class) + ->setConstructorArgs([$this->gateway]) + ->onlyMethods(['sendNotification']) // Only mock this + ->getMock(); + + $service->method('sendNotification') + ->willReturn(true); + + // Real processPayment, mocked sendNotification + $service->processPayment(1000, 'EUR'); +} +``` + +## Fakes (Working Implementations) + +```php +users[$user->getId()] = $user; + } + + public function find(int $id): ?User + { + return $this->users[$id] ?? null; + } + + public function findByEmail(string $email): ?User + { + foreach ($this->users as $user) { + if ($user->getEmail() === $email) { + return $user; + } + } + return null; + } +} +``` + +Usage: + +```php +public function testCreatesUser(): void +{ + $repository = new InMemoryUserRepository(); + $service = new UserService($repository); + + $user = $service->createUser('test@example.com'); + + $this->assertNotNull($repository->findByEmail('test@example.com')); +} +``` + +## Best Practices + +1. **Mock dependencies, not the SUT**: Don't mock the class you're testing +2. **Use interfaces**: Mock interfaces, not concrete classes +3. **One mock assertion per test**: Keep tests focused +4. **Prefer stubs over mocks**: Only verify when behavior matters +5. **Use fakes for repositories**: More realistic tests +6. **Don't over-mock**: Integration tests have value too + + +## Skill Operating Checklist + +### Design checklist +- Confirm operation boundaries and invariants first. +- Minimize scope while preserving contract correctness. +- Test both happy path and negative path behavior. + +### Validation commands +- ./vendor/bin/phpunit --filter=... +- ./vendor/bin/phpunit +- ./vendor/bin/pest --filter=... + +### Failure modes to test +- Invalid payload or forbidden actor. +- Boundary values / not-found cases. +- Retry or partial-failure behavior for async flows. + diff --git a/.agents/skills/symfony-twig-components/SKILL.md b/.agents/skills/symfony-twig-components/SKILL.md new file mode 100644 index 0000000..a12d28d --- /dev/null +++ b/.agents/skills/symfony-twig-components/SKILL.md @@ -0,0 +1,39 @@ +--- + +name: symfony-twig-components +allowed-tools: + - Read + - Glob + - Grep +description: Build reusable UI components with Symfony UX Twig Components (props, slots, anonymous components, CVA) for clean templates +--- + +# Twig Components (Symfony) + +## Use when +- Refining architecture/workflows/context handling in Symfony projects. +- Planning and executing medium/complex changes safely. + +## Default workflow +1. Establish current boundaries, constraints, and coupling points. +2. Propose smallest coherent architectural adjustment. +3. Execute in checkpoints with validation at each stage. +4. Summarize tradeoffs and follow-up backlog. + +## Guardrails +- Use existing project patterns by default. +- Avoid broad refactors without explicit need. +- Keep decision log clear and auditable. + +## Progressive disclosure +- Use this file for execution posture and risk controls. +- Open references when deep implementation details are needed. + +## Output contract +- Architecture/workflow changes. +- Checkpoint validation outcomes. +- Residual risks and next steps. + +## References +- `reference.md` +- `docs/complexity-tiers.md` diff --git a/.agents/skills/symfony-twig-components/reference.md b/.agents/skills/symfony-twig-components/reference.md new file mode 100644 index 0000000..e030a2b --- /dev/null +++ b/.agents/skills/symfony-twig-components/reference.md @@ -0,0 +1,443 @@ +# Reference + +# Twig Components (Symfony UX) + +## Installation + +```bash +composer require symfony/ux-twig-component +# For reactive components +composer require symfony/ux-live-component +``` + +## Basic Twig Component + +### Create Component Class + +```php + + {{ message }} + {% if dismissible %} + + {% endif %} + +``` + +### Use Component + +```twig +{# In any template #} + + +``` + +## Component with Slots + +### Component Class + +```php + + {% if title %} +
+
{{ title }}
+
+ {% endif %} + +
+ {% block content %}{% endblock %} +
+ + {% if block('footer') is not empty %} + + {% endif %} + +``` + +### Usage + +```twig + + +

Name: {{ user.name }}

+

Email: {{ user.email }}

+
+ + + Edit + +
+``` + +## Component with Logic + +```php +postRepository->countByAuthor($this->user); + } + + public function getRecentPosts(): array + { + return $this->postRepository->findRecentByAuthor($this->user, 3); + } +} +``` + +```twig +{# templates/components/UserCard.html.twig #} +
+

{{ user.name }}

+

{{ this.postCount }} posts

+ +
    + {% for post in this.recentPosts %} +
  • {{ post.title }}
  • + {% endfor %} +
+
+``` + +## readonly caveat + +Do **not** mark a Twig component class or its public props as `readonly`: props +are assigned *after* instantiation, so a readonly public prop throws. Inject +services as `private readonly`, but keep public props mutable. + +```php +#[AsTwigComponent] +class Alert +{ + public function __construct( + private readonly LoggerInterface $logger, // services: readonly OK + ) {} + + public string $type = 'info'; // props: must stay mutable, no readonly + public string $message = ''; +} +``` + +If the class must be `readonly`, assign the props inside `mount()` instead. + +## Anonymous Components + +A component with no PHP class — the name comes from the template path. Declare +its props with `{% props %}`: + +```twig +{# templates/components/Button.html.twig #} +{% props variant = 'primary', label %} + +``` + +```twig + +``` + +## CVA (Class Variant Authority) + +Manage Tailwind/utility class variants with `html_cva` (from +`twig/html-extra` >= 3.12) and merge conflicting classes with `|tailwind_merge`: + +```twig +{# templates/components/Badge.html.twig #} +{% set badge = html_cva( + base: 'inline-flex items-center rounded px-2 py-1 text-xs font-medium', + variants: { + color: { gray: 'bg-gray-100 text-gray-800', red: 'bg-red-100 text-red-800' }, + size: { sm: 'text-xs', md: 'text-sm' }, + } +) %} + + {{ label }} + +``` + +## Testing Components + +```php +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\UX\TwigComponent\Test\InteractsWithTwigComponents; + +final class AlertTest extends KernelTestCase +{ + use InteractsWithTwigComponents; + + public function testMount(): void + { + $component = $this->mountTwigComponent(name: 'Alert', data: ['message' => 'Hi']); + self::assertSame('info', $component->type); + } + + public function testRender(): void + { + $rendered = $this->renderTwigComponent(name: 'Alert', data: ['message' => 'Hi']); + self::assertStringContainsString('Hi', (string) $rendered); + } +} +``` + +## Live Components (Reactive) + +### Counter Example + +```php +count++; + } + + #[LiveAction] + public function decrement(): void + { + $this->count--; + } +} +``` + +```twig +{# templates/components/Counter.html.twig #} +
+ Count: {{ count }} + + +
+``` + +### Search Component + +```php +query) < 2) { + return []; + } + + return $this->products->search($this->query, $this->page); + } +} +``` + +```twig +{# templates/components/ProductSearch.html.twig #} +
+ + +
+ {% for product in this.products %} +
+

{{ product.name }}

+

{{ product.price|format_currency('EUR') }}

+
+ {% else %} + {% if query|length >= 2 %} +

No products found.

+ {% endif %} + {% endfor %} +
+
+``` + +### Form Component + +```php +createForm(ContactType::class); + } + + #[LiveAction] + public function submit(): void + { + $this->submitForm(); + + if ($this->getForm()->isValid()) { + $data = $this->getForm()->getData(); + // Process form... + $this->submitted = true; + } + } +} +``` + +```twig +{# templates/components/ContactForm.html.twig #} +
+ {% if submitted %} +
Thank you for your message!
+ {% else %} + {{ form_start(form) }} + {{ form_row(form.name) }} + {{ form_row(form.email) }} + {{ form_row(form.message) }} + + + {{ form_end(form) }} + {% endif %} +
+``` + +## Best Practices + +1. **Keep components focused**: Single responsibility +2. **Use slots for flexibility**: Allow content injection +3. **LiveProp for state**: Mark writable props explicitly +4. **Debounce search**: Use `data-model="debounce(300)|query"` +5. **URL sync**: Use `url: true` for bookmarkable state +6. **Test components**: Unit test the PHP class + + +## Skill Operating Checklist + +### Design checklist +- Confirm operation boundaries and invariants first. +- Minimize scope while preserving contract correctness. +- Test both happy path and negative path behavior. + +### Validation commands +- rg --files +- composer validate +- ./vendor/bin/phpstan analyse + +### Failure modes to test +- Invalid payload or forbidden actor. +- Boundary values / not-found cases. +- Retry or partial-failure behavior for async flows. + diff --git a/.agents/skills/symfony-using-symfony-superpowers/SKILL.md b/.agents/skills/symfony-using-symfony-superpowers/SKILL.md new file mode 100644 index 0000000..231fb3d --- /dev/null +++ b/.agents/skills/symfony-using-symfony-superpowers/SKILL.md @@ -0,0 +1,38 @@ +--- + +name: symfony-using-symfony-superpowers +allowed-tools: + - Read + - Glob + - Grep +description: Entry point for Symfony Superpowers - lightweight workflow guidance and command map +--- + +# Using Symfony Superpowers (Symfony) + +## Use when +- Refining architecture/workflows/context handling in Symfony projects. +- Planning and executing medium/complex changes safely. + +## Default workflow +1. Establish current boundaries, constraints, and coupling points. +2. Propose smallest coherent architectural adjustment. +3. Execute in checkpoints with validation at each stage. +4. Summarize tradeoffs and follow-up backlog. + +## Guardrails +- Use existing project patterns by default. +- Avoid broad refactors without explicit need. +- Keep decision log clear and auditable. + +## Progressive disclosure +- Use this file for execution posture and risk controls. +- Open references when deep implementation details are needed. + +## Output contract +- Architecture/workflow changes. +- Checkpoint validation outcomes. +- Residual risks and next steps. + +## References +- `docs/complexity-tiers.md` diff --git a/.agents/skills/symfony-value-objects-and-dtos/SKILL.md b/.agents/skills/symfony-value-objects-and-dtos/SKILL.md new file mode 100644 index 0000000..bec5386 --- /dev/null +++ b/.agents/skills/symfony-value-objects-and-dtos/SKILL.md @@ -0,0 +1,39 @@ +--- + +name: symfony-value-objects-and-dtos +allowed-tools: + - Read + - Glob + - Grep +description: Design Value Objects for domain concepts and DTOs for data transfer with proper immutability (readonly) and validation +--- + +# Value Objects And Dtos (Symfony) + +## Use when +- Refining architecture/workflows/context handling in Symfony projects. +- Planning and executing medium/complex changes safely. + +## Default workflow +1. Establish current boundaries, constraints, and coupling points. +2. Propose smallest coherent architectural adjustment. +3. Execute in checkpoints with validation at each stage. +4. Summarize tradeoffs and follow-up backlog. + +## Guardrails +- Use existing project patterns by default. +- Avoid broad refactors without explicit need. +- Keep decision log clear and auditable. + +## Progressive disclosure +- Use this file for execution posture and risk controls. +- Open references when deep implementation details are needed. + +## Output contract +- Architecture/workflow changes. +- Checkpoint validation outcomes. +- Residual risks and next steps. + +## References +- `reference.md` +- `docs/complexity-tiers.md` diff --git a/.agents/skills/symfony-value-objects-and-dtos/reference.md b/.agents/skills/symfony-value-objects-and-dtos/reference.md new file mode 100644 index 0000000..e8a38cc --- /dev/null +++ b/.agents/skills/symfony-value-objects-and-dtos/reference.md @@ -0,0 +1,447 @@ +# Reference + +# Value Objects and DTOs in Symfony + +## Value Objects + +Value Objects are immutable objects defined by their attributes, not identity. + +### Money Value Object + +```php +assertSameCurrency($other); + return new self($this->amount + $other->amount, $this->currency); + } + + public function subtract(self $other): self + { + $this->assertSameCurrency($other); + return new self($this->amount - $other->amount, $this->currency); + } + + public function multiply(float $multiplier): self + { + return new self((int) round($this->amount * $multiplier), $this->currency); + } + + public function getAmount(): int + { + return $this->amount; + } + + public function getCurrency(): string + { + return $this->currency; + } + + public function format(): string + { + return number_format($this->amount / 100, 2) . ' ' . $this->currency; + } + + public function equals(self $other): bool + { + return $this->amount === $other->amount + && $this->currency === $other->currency; + } + + public function isGreaterThan(self $other): bool + { + $this->assertSameCurrency($other); + return $this->amount > $other->amount; + } + + public function isZero(): bool + { + return $this->amount === 0; + } + + private function assertSameCurrency(self $other): void + { + if ($this->currency !== $other->currency) { + throw new \InvalidArgumentException( + "Cannot operate on different currencies: {$this->currency} vs {$other->currency}" + ); + } + } +} +``` + +### Email Value Object + +```php +value; + } + + public function getDomain(): string + { + return substr($this->value, strpos($this->value, '@') + 1); + } + + public function equals(self $other): bool + { + return $this->value === $other->value; + } + + public function __toString(): string + { + return $this->value; + } +} +``` + +### Address Value Object + +```php +city, $this->postalCode, $this->country, $this->state); + } + + public function format(): string + { + $parts = [$this->street, $this->postalCode . ' ' . $this->city]; + + if ($this->state) { + $parts[] = $this->state; + } + + $parts[] = $this->country; + + return implode("\n", $parts); + } + + public function equals(self $other): bool + { + return $this->street === $other->street + && $this->city === $other->city + && $this->postalCode === $other->postalCode + && $this->country === $other->country + && $this->state === $other->state; + } +} +``` + +## Doctrine Embeddables + +Store Value Objects in database: + +```php +total; + } +} +``` + +## DTOs (Data Transfer Objects) + +DTOs carry data between layers without behavior. + +### Input DTO + +```php +getId(), + customerId: $order->getCustomer()->getId(), + items: array_map( + fn($item) => OrderItemOutput::fromEntity($item), + $order->getItems()->toArray() + ), + total: MoneyOutput::fromValueObject($order->getTotal()), + status: $order->getStatus()->value, + createdAt: $order->getCreatedAt()->format('c'), + ); + } +} + +// src/Dto/MoneyOutput.php + +final readonly class MoneyOutput +{ + public function __construct( + public int $amount, + public string $currency, + public string $formatted, + ) {} + + public static function fromValueObject(Money $money): self + { + return new self( + amount: $money->getAmount(), + currency: $money->getCurrency(), + formatted: $money->format(), + ); + } +} +``` + +### API Platform Integration + +```php +") | .body' /tmp/telemetry.jsonl +``` + +For progress records, the invariants worth checking: records are keyed by (span, item) with latest-wins; emitters throttle (~100ms) but MUST emit a final converged state (`current == total` for known totals); bodies are explicit empty strings (an unset body breaks the OTLP round-trip). + +## Gotchas + +- The output file is appended across runs — delete it (or use a fresh `-out` path) between captures, and don't delete it out from under a running receiver (it keeps the old fd). +- Cached operations emit no transfer telemetry: cold-state setup matters (e.g. pull a not-yet-pulled image; `./hack/build` fully resets the dev engine cache). +- A missing span in the capture usually means the engine running is stale, not that the code is wrong — verify the dev engine actually contains your change before debugging the emitter. diff --git a/.agents/skills/tui-qa/SKILL.md b/.agents/skills/tui-qa/SKILL.md new file mode 100644 index 0000000..0f7c23d --- /dev/null +++ b/.agents/skills/tui-qa/SKILL.md @@ -0,0 +1,159 @@ +--- +name: tui-qa +description: QA CLI and TUI applications with asciinema recordings, timestamped terminal snapshots, and hang/timing analysis. Use when validating terminal output, progress behavior, delays, or what a user would see at a given moment. +--- + +# TUI QA + +Use this skill for operator-style QA of terminal applications. + +Prefer the bundled helper at `scripts/tui_qa.py` over ad hoc shell pipelines. It standardizes artifacts, snapshot capture, and timing analysis so follow-up sessions can inspect the same evidence. + +## Backend + +This skill requires `asciinema`. + +Artifact meanings: + +- `.cast`: source of truth for timing and terminal behavior +- `final.txt`: final rendered screen as plain text +- `snapshots/*.txt`: rendered screen at specific timestamps +- `.raw`: optional low-level byte stream for terminal-control debugging +- `report.json`: machine-readable timings, findings, and artifact paths +- `report.md`: short human-readable QA summary + +Use `.cast` for time-sensitive behavior. Use text snapshots for "what the user saw at time T". If terminal control behavior itself is suspect, inspect the `.cast` replay rather than relying on `.txt`. + +## Workflow + +1. State the operator expectation before recording. + + - expected first visible response + - expected milestones + - expected final screen or files written + - acceptable delays + +1. Record and analyze in one step when possible: + +```bash +python3 skills/tui-qa/scripts/tui_qa.py run \ + --name workspace-install \ + --command 'dagger install github.com/dagger/dagger/modules/wolfi@main' \ + --workdir /path/to/repo \ + --snapshot-at 0.5 \ + --snapshot-at 2 \ + --milestone 'Initialized workspace' \ + --milestone 'Installed module' +``` + +1. Review: + + - `report.md` for the summary + - `snapshots/*.txt` for point-in-time screens + - `session.cast` with `asciinema play` when timing or redraw behavior matters + +1. Classify issues: + + - content bug + - timing bug + - hard hang: no output for too long + - semantic hang: output continues but no meaningful milestone progress + - polish issue + +## Commands + +`record` + +- Use for manual or interactive sessions. +- Stores `session.cast` and `meta.json`. +- If the current shell is not attached to a tty, the helper automatically records in headless mode. + +```bash +python3 skills/tui-qa/scripts/tui_qa.py record \ + --name interactive-playground +``` + +```bash +python3 skills/tui-qa/scripts/tui_qa.py record \ + --name module-init \ + --command 'dagger sdk install go && dagger module init go demo' \ + --workdir /tmp/playground +``` + +`snapshot` + +- Produces a plain-text screen at a chosen timestamp. +- The helper truncates the cast at the last full event at or before `--at`, then converts that truncated cast to text. + +```bash +python3 skills/tui-qa/scripts/tui_qa.py snapshot \ + .qa/tui/module-init-20260404-120000/session.cast \ + --at 1.25 +``` + +`analyze` + +- Parses the event stream. +- Generates `final.txt`, snapshots, `report.json`, and `report.md`. +- Detects startup delay and hard hangs from periods with no output. +- If milestones are supplied, also reports semantic-hang candidates. + +Default thresholds: + +- startup warning: 2s +- startup failure: 5s +- idle warning: 10s +- idle failure: 30s +- semantic-hang warning: 120s + +`run` + +- Runs `record`, then `analyze`. +- This is the default path for non-interactive QA. + +## Guidance + +- Treat `.cast` as the authority for timing. +- Treat `final.txt` as the authority for final rendered text. +- When low-level terminal control bytes matter, export `.raw` directly with `asciinema convert -f raw session.cast session.raw`. +- Use milestones for higher-level progress checks. Without milestones, semantic-hang analysis is intentionally reported as not evaluated. +- Input events do not count as progress. +- Output events that only repaint the terminal still count for hard-hang timing. Use milestone timing and snapshots to decide whether that output felt meaningfully progressive. +- For commands that write files, inspect the filesystem after the run in addition to the terminal artifacts. + +## Examples + +Batch CLI: + +```bash +python3 skills/tui-qa/scripts/tui_qa.py run \ + --name help-output \ + --command 'dagger --help' \ + --snapshot-at 0.1 +``` + +Progress UI: + +```bash +python3 skills/tui-qa/scripts/tui_qa.py run \ + --name generate \ + --command 'dagger generate' \ + --milestone 'Generated' \ + --milestone 'done' +``` + +Manual recording, then analysis: + +```bash +python3 skills/tui-qa/scripts/tui_qa.py record --name manual-flow +python3 skills/tui-qa/scripts/tui_qa.py analyze .qa/tui/manual-flow-*/session.cast +``` + +Specific screen sample: + +```bash +python3 skills/tui-qa/scripts/tui_qa.py snapshot \ + .qa/tui/generate-20260404-120000/session.cast \ + --at 12.3 \ + --label before-finish +``` diff --git a/.agents/skills/tui-qa/agents/openai.yaml b/.agents/skills/tui-qa/agents/openai.yaml new file mode 100644 index 0000000..967393a --- /dev/null +++ b/.agents/skills/tui-qa/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "TUI QA" + short_description: "QA terminal output, timing, and hangs" + default_prompt: "Use $tui-qa to record a terminal session, capture timestamped screen snapshots, and report output, delay, and hang issues." diff --git a/.agents/skills/tui-qa/scripts/tui_qa.py b/.agents/skills/tui-qa/scripts/tui_qa.py new file mode 100755 index 0000000..873fcc8 --- /dev/null +++ b/.agents/skills/tui-qa/scripts/tui_qa.py @@ -0,0 +1,954 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Any + + +DEFAULT_STARTUP_WARN = 2.0 +DEFAULT_STARTUP_FAIL = 5.0 +DEFAULT_IDLE_WARN = 10.0 +DEFAULT_IDLE_FAIL = 30.0 +DEFAULT_SEMANTIC_WARN = 120.0 +SNAPSHOT_EPSILON = 0.000001 + +ANSI_ESCAPE_RE = re.compile( + r""" + \x1B + (?: + \[[0-?]*[ -/]*[@-~] + |\][^\x07]*(?:\x07|\x1B\\) + |[@-Z\\-_] + ) + """, + re.VERBOSE, +) + + +@dataclass +class CastEvent: + delta: float + kind: str + data: Any + raw: list[Any] + time: float + + +def main() -> int: + parser = build_parser() + args = parser.parse_args() + try: + return args.func(args) + except QaError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + + +class QaError(RuntimeError): + pass + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Record and analyze terminal QA sessions with asciinema.", + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + record = subparsers.add_parser("record", help="Record a terminal session to a cast file.") + add_common_record_args(record) + record.set_defaults(func=cmd_record) + + snapshot = subparsers.add_parser( + "snapshot", + help="Render the visible terminal screen at a given timestamp.", + ) + snapshot.add_argument("cast", type=Path, help="Path to an asciinema cast file.") + snapshot.add_argument("--at", type=float, required=True, help="Timestamp in seconds.") + snapshot.add_argument("--output", type=Path, help="Output path for the text snapshot.") + snapshot.add_argument("--label", help="Optional label for the snapshot file name.") + snapshot.set_defaults(func=cmd_snapshot) + + analyze = subparsers.add_parser("analyze", help="Analyze a recorded cast.") + analyze.add_argument("cast", type=Path, help="Path to an asciinema cast file.") + analyze.add_argument( + "--snapshot-at", + action="append", + default=[], + type=float, + help="Additional timestamp to snapshot. Repeat as needed.", + ) + analyze.add_argument( + "--milestone", + action="append", + default=[], + help="Regex describing a meaningful progress milestone. Repeat as needed.", + ) + analyze.add_argument( + "--startup-warn", + type=float, + default=DEFAULT_STARTUP_WARN, + help="Warn if first output takes at least this many seconds.", + ) + analyze.add_argument( + "--startup-fail", + type=float, + default=DEFAULT_STARTUP_FAIL, + help="Fail if first output takes at least this many seconds.", + ) + analyze.add_argument( + "--idle-warn", + type=float, + default=DEFAULT_IDLE_WARN, + help="Warn on no-output gaps of at least this many seconds.", + ) + analyze.add_argument( + "--idle-fail", + type=float, + default=DEFAULT_IDLE_FAIL, + help="Fail on no-output gaps of at least this many seconds.", + ) + analyze.add_argument( + "--semantic-warn", + type=float, + default=DEFAULT_SEMANTIC_WARN, + help="Warn when output continues without milestone progress for this long.", + ) + analyze.add_argument( + "--output-dir", + type=Path, + help="Directory for generated analysis artifacts. Defaults to the cast directory.", + ) + analyze.set_defaults(func=cmd_analyze) + + run = subparsers.add_parser( + "run", + help="Record a terminal session and analyze it in one step.", + ) + add_common_record_args(run) + run.add_argument( + "--snapshot-at", + action="append", + default=[], + type=float, + help="Additional timestamp to snapshot. Repeat as needed.", + ) + run.add_argument( + "--milestone", + action="append", + default=[], + help="Regex describing a meaningful progress milestone. Repeat as needed.", + ) + run.add_argument("--startup-warn", type=float, default=DEFAULT_STARTUP_WARN) + run.add_argument("--startup-fail", type=float, default=DEFAULT_STARTUP_FAIL) + run.add_argument("--idle-warn", type=float, default=DEFAULT_IDLE_WARN) + run.add_argument("--idle-fail", type=float, default=DEFAULT_IDLE_FAIL) + run.add_argument("--semantic-warn", type=float, default=DEFAULT_SEMANTIC_WARN) + run.set_defaults(func=cmd_run) + + return parser + + +def add_common_record_args(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--name", required=True, help="Artifact name prefix.") + parser.add_argument( + "--command", + help="Command to run inside the recording. Omit for an interactive session.", + ) + parser.add_argument( + "--artifact-dir", + type=Path, + help="Artifact directory. Defaults to .qa/tui/-.", + ) + parser.add_argument( + "--capture-input", + action="store_true", + help="Record keyboard input as well as output.", + ) + parser.add_argument( + "--workdir", + type=Path, + help="Working directory for the recorded command.", + ) + parser.add_argument( + "--window-size", + help="Override terminal size as COLSxROWS.", + ) + parser.add_argument( + "--idle-time-limit", + type=float, + help="Optional playback idle-time cap stored in the cast metadata.", + ) + parser.add_argument( + "--headless", + action="store_true", + help="Force headless recording mode.", + ) + + +def cmd_record(args: argparse.Namespace) -> int: + artifacts = ensure_artifact_dir(args.name, args.artifact_dir) + result = record_session( + artifact_dir=artifacts, + name=args.name, + command=args.command, + workdir=args.workdir, + capture_input=args.capture_input, + window_size=args.window_size, + idle_time_limit=args.idle_time_limit, + headless=args.headless, + ) + print(result["cast"]) + if result.get("command_exit_code") not in (None, 0): + print( + f"recorded command exit code: {result['command_exit_code']}", + file=sys.stderr, + ) + return 0 + + +def cmd_snapshot(args: argparse.Namespace) -> int: + cast_path = args.cast.resolve() + if not cast_path.is_file(): + raise QaError(f"cast not found: {cast_path}") + output_path = args.output + if output_path is None: + label = sanitize_label(args.label or f"at-{format_seconds(args.at)}") + output_path = cast_path.parent / "snapshots" / f"{label}.txt" + render_snapshot(cast_path, args.at, output_path) + print(output_path.resolve()) + return 0 + + +def cmd_analyze(args: argparse.Namespace) -> int: + report = analyze_cast( + cast_path=args.cast.resolve(), + output_dir=(args.output_dir.resolve() if args.output_dir else None), + snapshot_times=list(args.snapshot_at), + milestone_patterns=list(args.milestone), + startup_warn=args.startup_warn, + startup_fail=args.startup_fail, + idle_warn=args.idle_warn, + idle_fail=args.idle_fail, + semantic_warn=args.semantic_warn, + ) + print(report["report_md"]) + return 0 + + +def cmd_run(args: argparse.Namespace) -> int: + artifacts = ensure_artifact_dir(args.name, args.artifact_dir) + record_session( + artifact_dir=artifacts, + name=args.name, + command=args.command, + workdir=args.workdir, + capture_input=args.capture_input, + window_size=args.window_size, + idle_time_limit=args.idle_time_limit, + headless=args.headless, + ) + report = analyze_cast( + cast_path=artifacts / "session.cast", + output_dir=artifacts, + snapshot_times=list(args.snapshot_at), + milestone_patterns=list(args.milestone), + startup_warn=args.startup_warn, + startup_fail=args.startup_fail, + idle_warn=args.idle_warn, + idle_fail=args.idle_fail, + semantic_warn=args.semantic_warn, + ) + summary = report["summary"] + print(f"artifacts: {artifacts}") + print(f"verdict: {summary['verdict']}") + print(f"report: {report['report_md']}") + return 0 + + +def ensure_artifact_dir(name: str, artifact_dir: Path | None) -> Path: + if artifact_dir is None: + stamp = datetime.now().strftime("%Y%m%d-%H%M%S") + artifact_dir = Path(".qa") / "tui" / f"{sanitize_label(name)}-{stamp}" + artifact_dir = artifact_dir.resolve() + artifact_dir.mkdir(parents=True, exist_ok=True) + (artifact_dir / "snapshots").mkdir(parents=True, exist_ok=True) + return artifact_dir + + +def record_session( + *, + artifact_dir: Path, + name: str, + command: str | None, + workdir: Path | None, + capture_input: bool, + window_size: str | None, + idle_time_limit: float | None, + headless: bool, +) -> dict[str, Any]: + asciinema = shutil.which("asciinema") + if not asciinema: + raise QaError("asciinema is required but was not found in PATH") + + cast_path = artifact_dir / "session.cast" + meta_path = artifact_dir / "meta.json" + workdir_path = workdir.resolve() if workdir else None + should_headless = headless or not (sys.stdin.isatty() and sys.stdout.isatty()) + effective_window_size = window_size or infer_window_size() + + cmd = [asciinema, "record", "--quiet", "--overwrite", "--return"] + if command: + cmd.extend(["--command", command]) + if capture_input: + cmd.append("--capture-input") + if idle_time_limit is not None: + cmd.extend(["--idle-time-limit", str(idle_time_limit)]) + if should_headless: + cmd.append("--headless") + if effective_window_size: + cmd.extend(["--window-size", effective_window_size]) + cmd.extend(["--title", name, str(cast_path)]) + + started_at = datetime.now().astimezone().isoformat() + result = subprocess.run( + cmd, + cwd=str(workdir_path) if workdir_path else None, + text=True, + capture_output=True, + check=False, + ) + finished_at = datetime.now().astimezone().isoformat() + + if not cast_path.exists(): + stderr = result.stderr.strip() + if stderr: + raise QaError(stderr) + raise QaError("recording failed before a cast file was produced") + + meta = { + "name": name, + "command": command, + "workdir": str(workdir_path) if workdir_path else None, + "artifact_dir": str(artifact_dir), + "cast": str(cast_path), + "capture_input": capture_input, + "headless": should_headless, + "window_size": effective_window_size, + "idle_time_limit": idle_time_limit, + "started_at": started_at, + "finished_at": finished_at, + "command_exit_code": result.returncode, + "stderr": result.stderr, + } + meta_path.write_text(json.dumps(meta, indent=2, sort_keys=True) + "\n") + return meta + + +def analyze_cast( + *, + cast_path: Path, + output_dir: Path | None, + snapshot_times: list[float], + milestone_patterns: list[str], + startup_warn: float, + startup_fail: float, + idle_warn: float, + idle_fail: float, + semantic_warn: float, +) -> dict[str, Any]: + if not cast_path.is_file(): + raise QaError(f"cast not found: {cast_path}") + output_dir = (output_dir or cast_path.parent).resolve() + output_dir.mkdir(parents=True, exist_ok=True) + snapshots_dir = output_dir / "snapshots" + snapshots_dir.mkdir(parents=True, exist_ok=True) + + header, events = load_cast(cast_path) + total_duration = events[-1].time if events else 0.0 + output_events = [event for event in events if event.kind == "o"] + output_times = [event.time for event in output_events] + first_output_time = output_times[0] if output_times else None + last_output_time = output_times[-1] if output_times else None + exit_event = next((event for event in events if event.kind == "x"), None) + exit_time = exit_event.time if exit_event else total_duration + + final_txt = output_dir / "final.txt" + convert_cast_to_text(cast_path, final_txt) + + thresholds = { + "startup_warn": startup_warn, + "startup_fail": startup_fail, + "idle_warn": idle_warn, + "idle_fail": idle_fail, + "semantic_warn": semantic_warn, + } + + findings: list[dict[str, Any]] = [] + startup_duration = first_output_time if first_output_time is not None else total_duration + startup_severity = severity_for_duration(startup_duration, startup_warn, startup_fail) + if startup_severity != "pass": + findings.append( + finding( + startup_severity, + "startup_delay", + f"First output took {format_seconds(startup_duration)}.", + ) + ) + + gaps = build_no_output_gaps(output_times, total_duration) + idle_gaps = [] + max_no_output_gap = 0.0 + longest_gap = None + for gap in gaps: + max_no_output_gap = max(max_no_output_gap, gap["duration"]) + if longest_gap is None or gap["duration"] > longest_gap["duration"]: + longest_gap = gap + if gap["kind"] == "startup": + continue + gap_severity = severity_for_duration(gap["duration"], idle_warn, idle_fail) + gap["severity"] = gap_severity + if gap_severity != "pass": + findings.append( + finding( + gap_severity, + "idle_gap", + ( + f"No output for {format_seconds(gap['duration'])} " + f"between {format_seconds(gap['start'])} and {format_seconds(gap['end'])}." + ), + ) + ) + idle_gaps.append(gap) + + milestones = evaluate_milestones(output_events, milestone_patterns) + semantic = evaluate_semantic_hangs( + output_times=output_times, + total_duration=total_duration, + milestones=milestones, + semantic_warn=semantic_warn, + ) + for candidate in semantic["candidates"]: + findings.append( + finding( + "warn", + "semantic_hang", + ( + f"Output continued for {format_seconds(candidate['duration'])} " + f"without a milestone between {format_seconds(candidate['start'])} " + f"and {format_seconds(candidate['end'])}." + ), + ) + ) + + meta = load_optional_json(cast_path.parent / "meta.json") + if meta and meta.get("command_exit_code") not in (None, 0): + findings.append( + finding( + "fail", + "command_exit", + f"Recorded command exited with status {meta['command_exit_code']}.", + ) + ) + + requested_snapshots = [ + snapshot_spec(at, f"at-{format_seconds(at)}") for at in snapshot_times + ] + auto_snapshots = [] + if first_output_time is not None: + auto_snapshots.append(snapshot_spec(first_output_time, "first-output")) + if longest_gap is not None and longest_gap["duration"] > 0: + auto_snapshots.append(snapshot_spec(longest_gap["start"], "longest-idle-start")) + if longest_gap["end"] > longest_gap["start"]: + auto_snapshots.append( + snapshot_spec( + max(longest_gap["start"], longest_gap["end"] - SNAPSHOT_EPSILON), + "longest-idle-end", + ) + ) + auto_snapshots.append(snapshot_spec(total_duration, "final")) + for index, candidate in enumerate(semantic["candidates"], start=1): + auto_snapshots.append(snapshot_spec(candidate["start"], f"semantic-{index}-start")) + auto_snapshots.append( + snapshot_spec( + max(candidate["start"], candidate["end"] - SNAPSHOT_EPSILON), + f"semantic-{index}-end", + ) + ) + + snapshot_results = write_snapshots( + cast_path=cast_path, + snapshots_dir=snapshots_dir, + final_txt=final_txt, + specs=requested_snapshots + auto_snapshots, + total_duration=total_duration, + ) + + if longest_gap is not None: + mark_visual_change( + gap=longest_gap, + snapshot_results=snapshot_results, + start_label="longest-idle-start", + end_label="longest-idle-end", + ) + for index, candidate in enumerate(semantic["candidates"], start=1): + mark_visual_change( + gap=candidate, + snapshot_results=snapshot_results, + start_label=f"semantic-{index}-start", + end_label=f"semantic-{index}-end", + ) + + summary = { + "verdict": summarize_verdict(findings), + "total_duration": total_duration, + "time_to_first_output": first_output_time, + "time_to_exit": exit_time, + "output_event_count": len(output_events), + "max_no_output_gap": max_no_output_gap, + } + + report = { + "cast": str(cast_path), + "header": header, + "meta": meta, + "thresholds": thresholds, + "summary": summary, + "startup": { + "duration": startup_duration, + "severity": startup_severity, + }, + "idle_gaps": idle_gaps, + "milestones": milestones, + "semantic_hang": semantic, + "snapshots": snapshot_results, + "final_txt": str(final_txt), + "findings": findings, + "report_json": str((output_dir / "report.json").resolve()), + "report_md": str((output_dir / "report.md").resolve()), + } + + report_json_path = output_dir / "report.json" + report_json_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n") + report_md_path = output_dir / "report.md" + report_md_path.write_text(render_markdown_report(report) + "\n") + return report + + +def build_no_output_gaps(output_times: list[float], total_duration: float) -> list[dict[str, Any]]: + gaps = [] + if not output_times: + return [ + { + "kind": "startup", + "start": 0.0, + "end": total_duration, + "duration": total_duration, + } + ] + + if output_times[0] > 0: + gaps.append( + { + "kind": "startup", + "start": 0.0, + "end": output_times[0], + "duration": output_times[0], + } + ) + + for start, end in zip(output_times, output_times[1:]): + gaps.append( + { + "kind": "idle", + "start": start, + "end": end, + "duration": end - start, + } + ) + + if total_duration > output_times[-1]: + gaps.append( + { + "kind": "tail", + "start": output_times[-1], + "end": total_duration, + "duration": total_duration - output_times[-1], + } + ) + + return gaps + + +def evaluate_milestones( + output_events: list[CastEvent], + patterns: list[str], +) -> list[dict[str, Any]]: + if not patterns: + return [] + + compiled = [re.compile(pattern) for pattern in patterns] + hits = [None] * len(compiled) + transcript = "" + + for event in output_events: + transcript += normalize_output_text(str(event.data)) + for index, regex in enumerate(compiled): + if hits[index] is None and regex.search(transcript): + hits[index] = event.time + + result = [] + for pattern, hit in zip(patterns, hits): + result.append( + { + "pattern": pattern, + "matched": hit is not None, + "time": hit, + } + ) + return result + + +def evaluate_semantic_hangs( + *, + output_times: list[float], + total_duration: float, + milestones: list[dict[str, Any]], + semantic_warn: float, +) -> dict[str, Any]: + if not milestones: + return { + "status": "not_evaluated", + "candidates": [], + } + + milestone_times = sorted( + milestone["time"] for milestone in milestones if milestone["matched"] and milestone["time"] is not None + ) + checkpoints = [] + if output_times: + checkpoints.append(output_times[0]) + checkpoints.extend(milestone_times) + checkpoints.append(total_duration) + + candidates = [] + for start, end in zip(checkpoints, checkpoints[1:]): + if end - start < semantic_warn: + continue + if not any(start < time <= end for time in output_times): + continue + candidates.append( + { + "start": start, + "end": end, + "duration": end - start, + } + ) + + return { + "status": "warn" if candidates else "pass", + "candidates": candidates, + } + + +def write_snapshots( + *, + cast_path: Path, + snapshots_dir: Path, + final_txt: Path, + specs: list[dict[str, Any]], + total_duration: float, +) -> list[dict[str, Any]]: + results = [] + seen_labels = set() + for spec in specs: + label = sanitize_label(spec["label"]) + if label in seen_labels: + continue + seen_labels.add(label) + if label == "final": + path = final_txt + else: + path = snapshots_dir / f"{label}.txt" + render_snapshot(cast_path, min(spec["time"], total_duration), path) + results.append( + { + "label": label, + "time": spec["time"], + "path": str(path.resolve()), + } + ) + return results + + +def mark_visual_change( + *, + gap: dict[str, Any], + snapshot_results: list[dict[str, Any]], + start_label: str, + end_label: str, +) -> None: + start_snapshot = next((item for item in snapshot_results if item["label"] == start_label), None) + end_snapshot = next((item for item in snapshot_results if item["label"] == end_label), None) + if not start_snapshot or not end_snapshot: + return + start_text = Path(start_snapshot["path"]).read_text() + end_text = Path(end_snapshot["path"]).read_text() + gap["screen_changed"] = normalize_snapshot_text(start_text) != normalize_snapshot_text(end_text) + gap["start_snapshot"] = start_snapshot["path"] + gap["end_snapshot"] = end_snapshot["path"] + + +def render_snapshot(cast_path: Path, at: float, output_path: Path) -> None: + header, events = load_cast(cast_path) + truncated = [] + for event in events: + if event.time <= at + SNAPSHOT_EPSILON: + truncated.append(event.raw) + else: + break + output_path.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile("w", suffix=".cast", delete=False) as handle: + temp_cast = Path(handle.name) + handle.write(json.dumps(header) + "\n") + for raw in truncated: + handle.write(json.dumps(raw) + "\n") + try: + convert_cast_to_text(temp_cast, output_path) + finally: + temp_cast.unlink(missing_ok=True) + + +def load_cast(path: Path) -> tuple[dict[str, Any], list[CastEvent]]: + lines = path.read_text().splitlines() + if not lines: + raise QaError(f"cast is empty: {path}") + try: + header = json.loads(lines[0]) + except json.JSONDecodeError as exc: + raise QaError(f"invalid cast header in {path}: {exc}") from exc + + events = [] + cumulative = 0.0 + for index, line in enumerate(lines[1:], start=2): + if not line.strip(): + continue + try: + raw = json.loads(line) + except json.JSONDecodeError as exc: + raise QaError(f"invalid cast event on line {index}: {exc}") from exc + if not isinstance(raw, list) or len(raw) < 3: + raise QaError(f"invalid cast event on line {index}: expected [delta, type, data]") + delta = float(raw[0]) + cumulative += delta + events.append( + CastEvent( + delta=delta, + kind=str(raw[1]), + data=raw[2], + raw=raw, + time=cumulative, + ) + ) + return header, events + + +def convert_cast_to_text(input_path: Path, output_path: Path) -> None: + asciinema = shutil.which("asciinema") + if not asciinema: + raise QaError("asciinema is required but was not found in PATH") + output_path.parent.mkdir(parents=True, exist_ok=True) + result = subprocess.run( + [asciinema, "convert", "--overwrite", str(input_path), str(output_path)], + text=True, + capture_output=True, + check=False, + ) + if result.returncode != 0: + stderr = result.stderr.strip() + if stderr: + raise QaError(stderr) + raise QaError(f"failed to convert cast to text: {input_path}") + + +def finding(severity: str, kind: str, message: str) -> dict[str, str]: + return { + "severity": severity, + "kind": kind, + "message": message, + } + + +def severity_for_duration(duration: float, warn_after: float, fail_after: float) -> str: + if duration >= fail_after: + return "fail" + if duration >= warn_after: + return "warn" + return "pass" + + +def summarize_verdict(findings: list[dict[str, Any]]) -> str: + severities = {item["severity"] for item in findings} + if "fail" in severities: + return "fail" + if "warn" in severities: + return "warn" + return "pass" + + +def snapshot_spec(at: float, label: str) -> dict[str, Any]: + return { + "time": max(at, 0.0), + "label": label, + } + + +def load_optional_json(path: Path) -> dict[str, Any] | None: + if not path.is_file(): + return None + try: + return json.loads(path.read_text()) + except json.JSONDecodeError: + return None + + +def normalize_output_text(data: str) -> str: + data = ANSI_ESCAPE_RE.sub("", data) + buffer: list[str] = [] + for char in data: + if char == "\r": + buffer.append("\n") + elif char == "\n" or char == "\t" or char >= " ": + buffer.append(char) + elif char == "\b": + if buffer: + buffer.pop() + else: + continue + return "".join(buffer) + + +def normalize_snapshot_text(text: str) -> str: + return "\n".join(line.rstrip() for line in text.strip().splitlines()) + + +def render_markdown_report(report: dict[str, Any]) -> str: + lines = ["# TUI QA Report", ""] + meta = report.get("meta") or {} + command = meta.get("command") + if command: + lines.append(f"Command: `{command}`") + lines.append("") + lines.append(f"Cast: `{report['cast']}`") + lines.append(f"Verdict: **{report['summary']['verdict']}**") + lines.append("") + + lines.append("## Timings") + lines.append("") + lines.append(f"- Total duration: {format_seconds(report['summary']['total_duration'])}") + lines.append( + "- Time to first output: " + + ( + format_seconds(report['summary']['time_to_first_output']) + if report['summary']['time_to_first_output'] is not None + else "none" + ) + ) + lines.append(f"- Time to exit: {format_seconds(report['summary']['time_to_exit'])}") + lines.append(f"- Output events: {report['summary']['output_event_count']}") + lines.append( + f"- Max no-output gap: {format_seconds(report['summary']['max_no_output_gap'])}" + ) + lines.append("") + + lines.append("## Findings") + lines.append("") + if report["findings"]: + for item in report["findings"]: + lines.append(f"- {item['severity'].upper()}: {item['message']}") + else: + lines.append("- PASS: No warnings or failures.") + lines.append("") + + if report["milestones"]: + lines.append("## Milestones") + lines.append("") + for milestone in report["milestones"]: + if milestone["matched"]: + lines.append( + f"- Matched `{milestone['pattern']}` at {format_seconds(milestone['time'])}" + ) + else: + lines.append(f"- Missing `{milestone['pattern']}`") + lines.append("") + + if report["semantic_hang"]["status"] == "not_evaluated": + lines.append("## Semantic Hang") + lines.append("") + lines.append("- Not evaluated. Supply one or more `--milestone` regexes.") + lines.append("") + elif report["semantic_hang"]["candidates"]: + lines.append("## Semantic Hang") + lines.append("") + for candidate in report["semantic_hang"]["candidates"]: + screen_note = "" + if "screen_changed" in candidate: + screen_note = ( + " screen changed." + if candidate["screen_changed"] + else " screen did not materially change." + ) + lines.append( + "- " + + ( + f"{format_seconds(candidate['duration'])} without milestone progress " + f"from {format_seconds(candidate['start'])} to {format_seconds(candidate['end'])};" + f"{screen_note}" + ) + ) + lines.append("") + + lines.append("## Artifacts") + lines.append("") + lines.append(f"- Final text: `{report['final_txt']}`") + lines.append(f"- JSON report: `{report['report_json']}`") + for snapshot in report["snapshots"]: + lines.append( + f"- Snapshot `{snapshot['label']}` at {format_seconds(snapshot['time'])}: " + f"`{snapshot['path']}`" + ) + + return "\n".join(lines).rstrip() + + +def sanitize_label(label: str) -> str: + return re.sub(r"[^A-Za-z0-9._-]+", "-", label).strip("-").lower() or "snapshot" + + +def format_seconds(value: float | None) -> str: + if value is None: + return "none" + return f"{value:.3f}s" + + +def infer_window_size() -> str: + if sys.stdout.isatty(): + size = shutil.get_terminal_size(fallback=(120, 40)) + return f"{size.columns}x{size.lines}" + columns = os.environ.get("COLUMNS") or "120" + lines = os.environ.get("LINES") or "40" + return f"{columns}x{lines}" + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills-lock.json b/skills-lock.json index d2179fc..07d21d3 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -1,6 +1,27 @@ { "version": 1, "skills": { + "dagger-chores": { + "source": "dagger/dagger", + "ref": "main", + "sourceType": "github", + "skillPath": "skills/dagger-chores/SKILL.md", + "computedHash": "4fd04ea3490ae1ebee94e2d627640d90e2d23c3f3341ae31bf818a1f4c8a3867" + }, + "dagger-design-proposals": { + "source": "dagger/dagger", + "ref": "main", + "sourceType": "github", + "skillPath": "skills/dagger-design-proposals/SKILL.md", + "computedHash": "2fcd51276cffed132e8b92b157473125bf0737facfe02a3ad796e537a60151be" + }, + "engine-debugging": { + "source": "dagger/dagger", + "ref": "main", + "sourceType": "github", + "skillPath": "skills/engine-debugging/SKILL.md", + "computedHash": "9d1bb94949b6b2da0015f98a21d93b6246e4bd92e7e73f668aeefbf3f6f13bd3" + }, "frontend-design": { "source": "anthropics/skills", "sourceType": "github", @@ -18,6 +39,218 @@ "sourceType": "github", "skillPath": "skills/skill-creator/SKILL.md", "computedHash": "5ea13a6d9f0d4bb694405d79acd00cadec0d21bb138c4dd10fcf3c500cb835c2" + }, + "symfony-brainstorming": { + "source": "MakFly/superpowers-symfony", + "sourceType": "github", + "skillPath": "skills/brainstorming/SKILL.md", + "computedHash": "981515b64d86a2330b2f04a4019eb9b51cc255a89b4c99f06af366ac189f56d6" + }, + "symfony-config-env-parameters": { + "source": "MakFly/superpowers-symfony", + "sourceType": "github", + "skillPath": "skills/config-env-parameters/SKILL.md", + "computedHash": "287526c4720227b11fce4ced64bfe8873bac91e9b2b8dc9cfb897d2cbb28be4e" + }, + "symfony-controller-cleanup": { + "source": "MakFly/superpowers-symfony", + "sourceType": "github", + "skillPath": "skills/controller-cleanup/SKILL.md", + "computedHash": "f0beeb17ef438a1fd82318b8280ebda08232d0feb8a8c9855c3894c487b09c96" + }, + "symfony-cqrs-and-handlers": { + "source": "MakFly/superpowers-symfony", + "sourceType": "github", + "skillPath": "skills/cqrs-and-handlers/SKILL.md", + "computedHash": "4976bea544440ff29847a2bd9c482737c7b832f42c9c8dffa0e453da5f0b35e9" + }, + "symfony-daily-workflow": { + "source": "MakFly/superpowers-symfony", + "sourceType": "github", + "skillPath": "skills/daily-workflow/SKILL.md", + "computedHash": "2f6c5461645fcc674593ebdf29568c86ba10acc304fecf6c3c1b063601a432b8" + }, + "symfony-doctrine-batch-processing": { + "source": "MakFly/superpowers-symfony", + "sourceType": "github", + "skillPath": "skills/doctrine-batch-processing/SKILL.md", + "computedHash": "512dfce5145cc1ce7d20f177d81d93b40401d7941721fa8984a6d677054c691e" + }, + "symfony-doctrine-events": { + "source": "MakFly/superpowers-symfony", + "sourceType": "github", + "skillPath": "skills/doctrine-events/SKILL.md", + "computedHash": "e9ffd42dd504aa422819c6b8f9c53e36d3390aad3b1b37bf75db7f6a693afd92" + }, + "symfony-doctrine-fetch-modes": { + "source": "MakFly/superpowers-symfony", + "sourceType": "github", + "skillPath": "skills/doctrine-fetch-modes/SKILL.md", + "computedHash": "dfd8d1499e20da9e93a71277b33b09402f15e7b4f81eeb0b396a4f1d243a98cb" + }, + "symfony-doctrine-fixtures-foundry": { + "source": "MakFly/superpowers-symfony", + "sourceType": "github", + "skillPath": "skills/doctrine-fixtures-foundry/SKILL.md", + "computedHash": "19e4b0dbb990e12771bb9f8e6052cc7533f0059a89c8c54cd6c96c9250d87ffe" + }, + "symfony-doctrine-migrations": { + "source": "MakFly/superpowers-symfony", + "sourceType": "github", + "skillPath": "skills/doctrine-migrations/SKILL.md", + "computedHash": "284a94643f4fd3614445349f3c2f23e9e73d27502afe22ebd3fea776689c6cc1" + }, + "symfony-doctrine-relations": { + "source": "MakFly/superpowers-symfony", + "sourceType": "github", + "skillPath": "skills/doctrine-relations/SKILL.md", + "computedHash": "56e30f4e47d0bc3a5af54b0ca533c262138db4b526e7b52a704d72bd8c74d56e" + }, + "symfony-doctrine-transactions": { + "source": "MakFly/superpowers-symfony", + "sourceType": "github", + "skillPath": "skills/doctrine-transactions/SKILL.md", + "computedHash": "b97c45764b31563bb56d620f9a36bc73c8121d85511a17c901a6f8aed9dc053f" + }, + "symfony-e2e-panther-playwright": { + "source": "MakFly/superpowers-symfony", + "sourceType": "github", + "skillPath": "skills/e2e-panther-playwright/SKILL.md", + "computedHash": "cfdd93cda493b2d7569b3b9e04a5bc485a2501f193532b901174beb4bb4f3503" + }, + "symfony-effective-context": { + "source": "MakFly/superpowers-symfony", + "sourceType": "github", + "skillPath": "skills/effective-context/SKILL.md", + "computedHash": "3a06bccd9f22367617f551d29ce7fca55a573aaec1ec5df6d4cc8f95644ee940" + }, + "symfony-executing-plans": { + "source": "MakFly/superpowers-symfony", + "sourceType": "github", + "skillPath": "skills/executing-plans/SKILL.md", + "computedHash": "b90ec16f568c936b6715e880728a379adfd70baac6038f0ebb70963dfb408ee4" + }, + "symfony-form-types-validation": { + "source": "MakFly/superpowers-symfony", + "sourceType": "github", + "skillPath": "skills/form-types-validation/SKILL.md", + "computedHash": "9a7a2a4b8a05933a196bce5e3cb76e60f6bc811490a6fcf2382b5c86b8e0aab0" + }, + "symfony-functional-tests": { + "source": "MakFly/superpowers-symfony", + "sourceType": "github", + "skillPath": "skills/functional-tests/SKILL.md", + "computedHash": "febc85539e459c86a884dec31c0cb1efeb07e583424540c042fe6159c9f8e557" + }, + "symfony-interfaces-and-autowiring": { + "source": "MakFly/superpowers-symfony", + "sourceType": "github", + "skillPath": "skills/interfaces-and-autowiring/SKILL.md", + "computedHash": "2ce82c9a5d54bffa3a636ce6b6c927814c0bebdc854dcbfe74dffa8b80b054f6" + }, + "symfony-messenger-retry-failures": { + "source": "MakFly/superpowers-symfony", + "sourceType": "github", + "skillPath": "skills/messenger-retry-failures/SKILL.md", + "computedHash": "2b133c2d91cb77e192c48bb5616adb93b55fc5b9c4b5cc30d8c74f90be259b9a" + }, + "symfony-ports-and-adapters": { + "source": "MakFly/superpowers-symfony", + "sourceType": "github", + "skillPath": "skills/ports-and-adapters/SKILL.md", + "computedHash": "bc9b73a5cdd76d2ee45b81164d07613acc8ce2858e72e7fb3b6f2bde98acab62" + }, + "symfony-rate-limiting": { + "source": "MakFly/superpowers-symfony", + "sourceType": "github", + "skillPath": "skills/rate-limiting/SKILL.md", + "computedHash": "edc3514363335c799bf7ba70e47b5503ccfd28d046ee56eea23c2e6ccb009c32" + }, + "symfony-runner-selection": { + "source": "MakFly/superpowers-symfony", + "sourceType": "github", + "skillPath": "skills/runner-selection/SKILL.md", + "computedHash": "643759848bd049497617d79dcc0d8913cc50ef484016d3cd9601cac04b0de047" + }, + "symfony-strategy-pattern": { + "source": "MakFly/superpowers-symfony", + "sourceType": "github", + "skillPath": "skills/strategy-pattern/SKILL.md", + "computedHash": "7c4af4dc7958686bb2b5f916e59dc8a44342438a54662b23cb5fadff368e0386" + }, + "symfony-symfony-cache": { + "source": "MakFly/superpowers-symfony", + "sourceType": "github", + "skillPath": "skills/symfony-cache/SKILL.md", + "computedHash": "b409c61c6dede2974bbf21cc283a586326ed25b27c5bc9717dfa0f1e98f3097b" + }, + "symfony-symfony-messenger": { + "source": "MakFly/superpowers-symfony", + "sourceType": "github", + "skillPath": "skills/symfony-messenger/SKILL.md", + "computedHash": "ac58a2a3f19154b4e94b8f240e5a593fa828bdc40783336a0b96dfe8dc3b6bfe" + }, + "symfony-symfony-scheduler": { + "source": "MakFly/superpowers-symfony", + "sourceType": "github", + "skillPath": "skills/symfony-scheduler/SKILL.md", + "computedHash": "a3e9197f96e882323d480e0f63e4b90bdfbeead7f4acdb88540ee4f5abd136a4" + }, + "symfony-symfony-voters": { + "source": "MakFly/superpowers-symfony", + "sourceType": "github", + "skillPath": "skills/symfony-voters/SKILL.md", + "computedHash": "61aa9c4b3d9ce940864d2aeccec54a905a9d6685cc9f59625c74f546d70e8380" + }, + "symfony-tdd-with-phpunit": { + "source": "MakFly/superpowers-symfony", + "sourceType": "github", + "skillPath": "skills/tdd-with-phpunit/SKILL.md", + "computedHash": "71598ee5c3b73d4821b30058adaadcf5ab45e1623676f484e2bb4212fee8c431" + }, + "symfony-test-doubles-mocking": { + "source": "MakFly/superpowers-symfony", + "sourceType": "github", + "skillPath": "skills/test-doubles-mocking/SKILL.md", + "computedHash": "45523a2697669e3294ee63c4baffb8cecc6e3ea412065e50ea82ba9b7ed621da" + }, + "symfony-twig-components": { + "source": "MakFly/superpowers-symfony", + "sourceType": "github", + "skillPath": "skills/twig-components/SKILL.md", + "computedHash": "babf20588dc7b99139ff862e59916a4d105b23eed25a2eb488cdaeb39dae144b" + }, + "symfony-using-symfony-superpowers": { + "source": "MakFly/superpowers-symfony", + "sourceType": "github", + "skillPath": "skills/using-symfony-superpowers/SKILL.md", + "computedHash": "5de5c7c351db214020f88cbc2be56d1bef8a63c130b886e27ed441ba6b35a447" + }, + "symfony-value-objects-and-dtos": { + "source": "MakFly/superpowers-symfony", + "sourceType": "github", + "skillPath": "skills/value-objects-and-dtos/SKILL.md", + "computedHash": "6ee277f295b8ed5e18fefe64cb522f3bffc6415ea29c9e306b5c5b027a40f1e1" + }, + "symfony-writing-plans": { + "source": "MakFly/superpowers-symfony", + "sourceType": "github", + "skillPath": "skills/writing-plans/SKILL.md", + "computedHash": "8990dd5cab321e8128c76684c490eb150f4f5d4b1df14e8f6ee0177b0b92937a" + }, + "telemetry-capture": { + "source": "dagger/dagger", + "ref": "main", + "sourceType": "github", + "skillPath": "skills/telemetry-capture/SKILL.md", + "computedHash": "d0eb55bfe6c66109e26fe237ab9f96bc06e0029bb8ebbd2c770ec28e6a8d2a1a" + }, + "tui-qa": { + "source": "dagger/dagger", + "ref": "main", + "sourceType": "github", + "skillPath": "skills/tui-qa/SKILL.md", + "computedHash": "8787639e27d36553490bc1ba25273eba29776bd66e4dedec752413ff3821cdee" } } } -- 2.51.2